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
17
.env.example
17
.env.example
|
|
@ -132,6 +132,23 @@ TELESRV_MAPBOX_TOKEN=
|
|||
TELESRV_MAPTILE_CACHE_DIR=data/maptiles
|
||||
|
||||
TELESRV_LANGPACK_SEED_DIR=data/langpack
|
||||
TELESRV_OFFICIAL_GIFTS_DIR=data/official-gifts
|
||||
# Star Gift expiry/auction worker. TON values are handled by the local ledger;
|
||||
# no wallet, Fragment or chain node endpoint is configured or contacted.
|
||||
TELESRV_STARGIFT_SWEEP_INTERVAL=15s
|
||||
TELESRV_STARGIFT_SWEEP_BATCH=1000
|
||||
# Internal nanoton granted once per user on first local-ledger access.
|
||||
TELESRV_STARGIFT_TON_STARTING_GRANT=10000000000
|
||||
TELESRV_STARGIFT_TRANSFER_STARS=25
|
||||
TELESRV_STARGIFT_DROP_DETAILS_STARS=25
|
||||
TELESRV_STARGIFT_OFFER_MIN_STARS=1
|
||||
TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE=1000
|
||||
TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE=1000
|
||||
TELESRV_STARGIFT_EXPORT_DELAY=0s
|
||||
TELESRV_STARGIFT_TRANSFER_DELAY=0s
|
||||
TELESRV_STARGIFT_RESELL_DELAY=0s
|
||||
TELESRV_STARGIFT_CRAFT_DELAY=0s
|
||||
TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250
|
||||
TELESRV_BLOB_DIR=data/blobs
|
||||
TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
||||
|
||||
|
|
|
|||
1042
cmd/giftfetch/main.go
Normal file
1042
cmd/giftfetch/main.go
Normal file
File diff suppressed because it is too large
Load diff
238
cmd/giftfetch/main_test.go
Normal file
238
cmd/giftfetch/main_test.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestHasRenderableStickerAttribute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []tg.DocumentAttributeClass
|
||||
want bool
|
||||
}{
|
||||
{name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true},
|
||||
{name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true},
|
||||
{name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want {
|
||||
t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"},
|
||||
{name: "", mime: "application/x-tgsticker", want: ".tgs"},
|
||||
{name: "unsafe.exe", mime: "video/webm", want: ".webm"},
|
||||
{name: "", mime: "application/octet-stream", want: ".bin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := documentExtension(test.name, test.mime); got != test.want {
|
||||
t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedBuffer(t *testing.T) {
|
||||
buffer := &boundedBuffer{max: 4}
|
||||
if _, err := buffer.Write([]byte("abc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written, err := buffer.Write([]byte("def")); err == nil || written != 1 {
|
||||
t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err)
|
||||
}
|
||||
if !bytes.Equal(buffer.Bytes(), []byte("abcd")) {
|
||||
t.Fatalf("buffer = %q, want abcd", buffer.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
size int64
|
||||
want int
|
||||
}{
|
||||
{size: 1, want: 4 << 10},
|
||||
{size: (4 << 10) - 1, want: 4 << 10},
|
||||
{size: 4 << 10, want: 8 << 10},
|
||||
{size: (512 << 10) - 1, want: 512 << 10},
|
||||
{size: 512 << 10, want: 512 << 10},
|
||||
{size: 1 << 20, want: 512 << 10},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := downloadPartSize(test.size); got != test.want {
|
||||
t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedMissingThumbs(t *testing.T) {
|
||||
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") {
|
||||
t.Fatalf("allowed = %v", allowed)
|
||||
}
|
||||
for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} {
|
||||
if _, err := parseAllowedMissingThumbs(invalid); err == nil {
|
||||
t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, reused, err := existingArtifact(root, "resource.bin", 4, 16)
|
||||
if err != nil || !reused || string(data) != "gift" {
|
||||
t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTLArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var encoded bin.Buffer
|
||||
if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded tg.PaymentsStarGiftUpgradeAttributes
|
||||
artifact, err := readTLArtifact(root, "attributes.tl", &decoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" {
|
||||
t.Fatalf("artifact = %+v", artifact)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil {
|
||||
t.Fatal("expected trailing-byte error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeableGiftIDs(t *testing.T) {
|
||||
classes := []tg.StarGiftClass{
|
||||
&tg.StarGift{ID: 1, UpgradeStars: 10},
|
||||
&tg.StarGift{ID: 2, UpgradeVariants: 3},
|
||||
&tg.StarGift{ID: 3},
|
||||
&tg.StarGiftUnique{ID: 4, GiftID: 1},
|
||||
}
|
||||
got := collectUpgradeableGiftIDs(classes)
|
||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributes(t *testing.T) {
|
||||
modelDoc := testGiftDocument(101)
|
||||
patternDoc := testGiftDocument(102)
|
||||
model := &tg.StarGiftAttributeModel{
|
||||
Name: "Crafted model",
|
||||
Document: modelDoc,
|
||||
Rarity: &tg.StarGiftAttributeRarityLegendary{},
|
||||
}
|
||||
model.SetCrafted(true)
|
||||
result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
model,
|
||||
&tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}},
|
||||
&tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}},
|
||||
}}
|
||||
added := make(map[int64]string)
|
||||
set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
|
||||
doc, ok := class.(*tg.Document)
|
||||
if !ok {
|
||||
return nil, errors.New("not a document")
|
||||
}
|
||||
added[doc.ID] = purpose
|
||||
return doc, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 {
|
||||
t.Fatalf("unexpected attribute counts: %+v", set)
|
||||
}
|
||||
if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" {
|
||||
t.Fatalf("model = %+v", set.Models[0])
|
||||
}
|
||||
if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 {
|
||||
t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity)
|
||||
}
|
||||
if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" {
|
||||
t.Fatalf("backdrop = %+v", set.Backdrops[0])
|
||||
}
|
||||
if len(set.DocumentIDs) != 2 || len(added) != 2 {
|
||||
t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) {
|
||||
_, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
&tg.StarGiftAttributeOriginalDetails{},
|
||||
}}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported-constructor error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRarityKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
class tg.StarGiftAttributeRarityClass
|
||||
kind string
|
||||
}{
|
||||
{class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"},
|
||||
{class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"},
|
||||
{class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"},
|
||||
{class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := collectRarity(test.class)
|
||||
if err != nil || got.Kind != test.kind || got.ConstructorID == "" {
|
||||
t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := collectRarity(nil); err == nil {
|
||||
t.Fatal("expected nil-rarity error")
|
||||
}
|
||||
}
|
||||
|
||||
func testGiftDocument(id int64) *tg.Document {
|
||||
return &tg.Document{
|
||||
ID: id,
|
||||
Size: 1,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Attributes: []tg.DocumentAttributeClass{
|
||||
&tg.DocumentAttributeCustomEmoji{Alt: "gift"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -134,23 +134,23 @@ type ChannelDetail struct {
|
|||
}
|
||||
|
||||
type StarGiftRow struct {
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
GiftID int64 `json:"GiftID,string"`
|
||||
RevisionID int64 `json:"RevisionID,string"`
|
||||
Revision int
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
Stars int64 `json:"Stars,string"`
|
||||
ConvertStars int64 `json:"ConvertStars,string"`
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
DocumentID int64
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
AnimationSHA string
|
||||
AnimationSize int64
|
||||
AnimationSize int64 `json:"AnimationSize,string"`
|
||||
Width int
|
||||
Height int
|
||||
FrameRate float64
|
||||
ReceivedCount int64
|
||||
ReceivedCount int64 `json:"ReceivedCount,string"`
|
||||
CreatedBy string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||
|
|
@ -70,6 +72,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
|
|
@ -228,6 +231,19 @@ func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Re
|
|||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
|
|
@ -696,10 +712,10 @@ type importStarGiftAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
|
@ -746,11 +762,48 @@ func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importOfficialStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importOfficialStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportOfficialStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
|
||||
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
|
||||
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
|
||||
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
|
||||
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
|
||||
|
|
@ -832,7 +885,7 @@ type setStarGiftEnabledAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
|
|
@ -853,7 +906,7 @@ type setStarGiftSortOrderAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,3 +82,76 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
|
|||
t.Fatalf("forwarded freeze request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(StarGiftRow{
|
||||
GiftID: maxInt64,
|
||||
RevisionID: maxInt64,
|
||||
Stars: maxInt64,
|
||||
ConvertStars: maxInt64,
|
||||
DocumentID: maxInt64,
|
||||
AnimationSize: maxInt64,
|
||||
ReceivedCount: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal star gift row: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal star gift row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} {
|
||||
if got[field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want exact decimal string", field, got[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
|
||||
"source_gift_id":"5895603153683874485",
|
||||
"gift_id":"9223372036854775807",
|
||||
"stars":"9223372036854775807",
|
||||
"convert_stars":"9223372036854775807",
|
||||
"upgrade_stars":"9223372036854775807"
|
||||
}`))
|
||||
var got importOfficialStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode gift action: %v", err)
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
|
||||
t.Fatalf("decoded gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.SetStarGiftEnabledRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{
|
||||
"reason":"precision regression","confirm":false,
|
||||
"gift_id":"9223372036854775807","enabled":false
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetStarGiftEnabledAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun {
|
||||
t.Fatalf("forwarded gift request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-DHdrFM5j.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-DKmJO2ZY.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-BFkUM6v2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BaxMq_AT.css">
|
||||
<script type="module" crossorigin src="/assets/index-DKmJO2ZY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
} from "./types";
|
||||
|
|
@ -66,11 +67,14 @@ export const api = {
|
|||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
|
||||
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
|
||||
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
|
||||
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
|
||||
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -260,6 +260,26 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.importEyebrow": "Gift catalog operation",
|
||||
"gifts.newRevision": "Create revision for gift #{id}",
|
||||
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
|
||||
"gifts.officialSource": "Official snapshot",
|
||||
"gifts.fileSource": "Upload file",
|
||||
"gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.",
|
||||
"gifts.officialSearch": "Search official gift ID or title",
|
||||
"gifts.officialSelect": "Choose an official gift",
|
||||
"gifts.officialRequired": "Choose an official gift first",
|
||||
"gifts.officialResults": "Showing {shown} of {total}",
|
||||
"gifts.officialCategoryLabel": "Official gift capability category",
|
||||
"gifts.officialCategory.all": "All",
|
||||
"gifts.officialCategory.upgrade": "Upgradable",
|
||||
"gifts.officialCategory.craft": "Craftable",
|
||||
"gifts.officialCategory.basic": "Not upgradable",
|
||||
"gifts.officialUnnamed": "Unnamed official gift #{id}",
|
||||
"gifts.officialAttributes": "{count} attributes",
|
||||
"gifts.canUpgrade": "Can upgrade",
|
||||
"gifts.cannotUpgrade": "Cannot upgrade",
|
||||
"gifts.canCraft": "Can Craft",
|
||||
"gifts.cannotCraft": "Cannot Craft",
|
||||
"gifts.officialEmpty": "No official gifts match this category and search.",
|
||||
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
|
||||
"gifts.animation": "Animation file",
|
||||
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
|
||||
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
|
||||
|
|
@ -307,7 +327,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "Pattern",
|
||||
"collectibles.backdrop": "Backdrop",
|
||||
"collectibles.rarity": "Rarity ‰",
|
||||
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
|
||||
"collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.",
|
||||
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
|
||||
"collectibles.addAttribute": "Add",
|
||||
"collectibles.remove": "Remove attribute",
|
||||
|
|
@ -610,6 +630,26 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.importEyebrow": "礼物目录操作",
|
||||
"gifts.newRevision": "为礼物 #{id} 创建新版本",
|
||||
"gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。",
|
||||
"gifts.officialSource": "官方资源库",
|
||||
"gifts.fileSource": "上传文件",
|
||||
"gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。",
|
||||
"gifts.officialSearch": "搜索官方礼物 ID 或标题",
|
||||
"gifts.officialSelect": "请选择官方礼物",
|
||||
"gifts.officialRequired": "请先选择一个官方礼物",
|
||||
"gifts.officialResults": "显示 {shown} / {total} 项",
|
||||
"gifts.officialCategoryLabel": "官方礼物能力分类",
|
||||
"gifts.officialCategory.all": "全部",
|
||||
"gifts.officialCategory.upgrade": "可升级",
|
||||
"gifts.officialCategory.craft": "可 Craft",
|
||||
"gifts.officialCategory.basic": "不可升级",
|
||||
"gifts.officialUnnamed": "未命名官方礼物 #{id}",
|
||||
"gifts.officialAttributes": "{count} 个属性",
|
||||
"gifts.canUpgrade": "可升级",
|
||||
"gifts.cannotUpgrade": "不可升级",
|
||||
"gifts.canCraft": "可 Craft",
|
||||
"gifts.cannotCraft": "不可 Craft",
|
||||
"gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。",
|
||||
"gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)",
|
||||
"gifts.animation": "动画文件",
|
||||
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
|
||||
"gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验",
|
||||
|
|
@ -657,7 +697,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "图案",
|
||||
"collectibles.backdrop": "背景",
|
||||
"collectibles.rarity": "稀有度 ‰",
|
||||
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
|
||||
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
|
||||
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
|
||||
"collectibles.addAttribute": "添加",
|
||||
"collectibles.remove": "删除属性",
|
||||
|
|
@ -960,6 +1000,26 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.importEyebrow": "Управление каталогом подарков",
|
||||
"gifts.newRevision": "Создать версию для подарка #{id}",
|
||||
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
|
||||
"gifts.officialSource": "Официальный снимок",
|
||||
"gifts.fileSource": "Загрузить файл",
|
||||
"gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.",
|
||||
"gifts.officialSearch": "Поиск по ID или названию официального подарка",
|
||||
"gifts.officialSelect": "Выберите официальный подарок",
|
||||
"gifts.officialRequired": "Сначала выберите официальный подарок",
|
||||
"gifts.officialResults": "Показано {shown} из {total}",
|
||||
"gifts.officialCategoryLabel": "Категория возможностей официального подарка",
|
||||
"gifts.officialCategory.all": "Все",
|
||||
"gifts.officialCategory.upgrade": "Можно улучшить",
|
||||
"gifts.officialCategory.craft": "Можно создать",
|
||||
"gifts.officialCategory.basic": "Нельзя улучшить",
|
||||
"gifts.officialUnnamed": "Официальный подарок без названия #{id}",
|
||||
"gifts.officialAttributes": "Атрибутов: {count}",
|
||||
"gifts.canUpgrade": "Можно улучшить",
|
||||
"gifts.cannotUpgrade": "Нельзя улучшить",
|
||||
"gifts.canCraft": "Можно создать",
|
||||
"gifts.cannotCraft": "Нельзя создать",
|
||||
"gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.",
|
||||
"gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели",
|
||||
"gifts.animation": "Файл анимации",
|
||||
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
|
||||
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
|
||||
|
|
@ -1007,7 +1067,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "Узор",
|
||||
"collectibles.backdrop": "Фон",
|
||||
"collectibles.rarity": "Редкость ‰",
|
||||
"collectibles.rarityHint": "Сумма по каждому разделу должна составлять ровно 1000‰.",
|
||||
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
|
||||
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.",
|
||||
"collectibles.addAttribute": "Добавить",
|
||||
"collectibles.remove": "Удалить атрибут",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function AnimationPreview({ data, compact = false }: { data: AnimationData; comp
|
|||
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
|
||||
}
|
||||
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
const [data, setData] = useState<AnimationData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -74,6 +74,7 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
|
|||
}
|
||||
|
||||
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
|
||||
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -135,7 +136,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
|
||||
upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
|
||||
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
|
||||
backdrops: backdrops.map((row) => ({
|
||||
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
|
||||
|
|
@ -167,7 +168,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
<section className="collectible-section">
|
||||
<div className="collectible-section-head">
|
||||
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
</div>
|
||||
<div className="collectible-rows">
|
||||
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
|
||||
|
|
@ -194,8 +195,8 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
|
||||
<div className="collectible-active-grid">
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}‰</span></div></article>)}
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
|
||||
|
||||
|
|
@ -210,11 +211,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
{renderAnimatedRows("models", models, setModels)}
|
||||
{renderAnimatedRows("patterns", patterns, setPatterns)}
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,23 @@ import { ActionButton } from "../components/ActionButton";
|
|||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, StarGiftRow } from "../types";
|
||||
import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
|
||||
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | string) {
|
||||
const bytes = Number(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) {
|
||||
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
|
@ -59,6 +66,20 @@ function LottiePreview({ giftID, revision, compact = false }: { giftID: number;
|
|||
);
|
||||
}
|
||||
|
||||
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.officialGiftAnimation(sourceGiftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [sourceGiftID]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const { t } = useI18n();
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
|
|
@ -66,7 +87,16 @@ export function GiftsPage() {
|
|||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [giftID, setGiftID] = useState(0);
|
||||
const [importSource, setImportSource] = useState<"official" | "file">("official");
|
||||
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
|
||||
const [officialQuery, setOfficialQuery] = useState("");
|
||||
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
|
||||
const [sourceGiftID, setSourceGiftID] = useState("");
|
||||
const [includeCollectible, setIncludeCollectible] = useState(true);
|
||||
const [upgradeStars, setUpgradeStars] = useState("0");
|
||||
const [supplyTotal, setSupplyTotal] = useState("0");
|
||||
const [slugPrefix, setSlugPrefix] = useState("");
|
||||
const [giftID, setGiftID] = useState("0");
|
||||
const [title, setTitle] = useState("");
|
||||
const [stars, setStars] = useState("50");
|
||||
const [convertStars, setConvertStars] = useState("50");
|
||||
|
|
@ -89,6 +119,29 @@ export function GiftsPage() {
|
|||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
|
||||
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, officialGifts.length]);
|
||||
|
||||
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
|
||||
const officialCategoryCounts = useMemo(() => ({
|
||||
all: officialGifts.length,
|
||||
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
|
||||
craft: officialGifts.filter((gift) => gift.can_craft).length,
|
||||
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
|
||||
}), [officialGifts]);
|
||||
const visibleOfficial = useMemo(() => {
|
||||
const normalized = officialQuery.trim().toLowerCase();
|
||||
return officialGifts.filter((gift) => {
|
||||
const categoryMatches = officialCategory === "all" ||
|
||||
(officialCategory === "upgrade" && gift.can_upgrade) ||
|
||||
(officialCategory === "craft" && gift.can_craft) ||
|
||||
(officialCategory === "basic" && !gift.can_upgrade);
|
||||
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
|
||||
});
|
||||
}, [officialGifts, officialQuery, officialCategory]);
|
||||
|
||||
const visibleGifts = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
|
|
@ -109,8 +162,8 @@ export function GiftsPage() {
|
|||
confirm,
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars: Number(stars),
|
||||
convert_stars: Number(convertStars),
|
||||
stars,
|
||||
convert_stars: convertStars,
|
||||
enabled,
|
||||
sort_order: Number(sortOrder)
|
||||
}));
|
||||
|
|
@ -118,10 +171,34 @@ export function GiftsPage() {
|
|||
return form;
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
return {
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
|
||||
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
|
||||
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
|
||||
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function chooseOfficial(gift: OfficialStarGiftRow) {
|
||||
setSourceGiftID(gift.source_gift_id);
|
||||
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
|
||||
setStars(String(gift.stars));
|
||||
setConvertStars(String(gift.convert_stars));
|
||||
setIncludeCollectible(gift.can_upgrade);
|
||||
setUpgradeStars(gift.upgrade_stars);
|
||||
setSupplyTotal(String(gift.availability_total || 1));
|
||||
setSlugPrefix(`official-${gift.source_gift_id}`);
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
setPreview(await api.importGift(uploadForm(false)));
|
||||
setPreview(importSource === "official" ? await api.importOfficialGift(officialPayload(false)) : await api.importGift(uploadForm(false)));
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
|
|
@ -131,8 +208,9 @@ export function GiftsPage() {
|
|||
if (!preview) return;
|
||||
setBusy(true); setImportError("");
|
||||
try {
|
||||
await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID(0); setTitle("");
|
||||
if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
|
||||
else await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSourceGiftID("");
|
||||
await load();
|
||||
setImportOpen(false);
|
||||
} catch (err) {
|
||||
|
|
@ -141,14 +219,16 @@ export function GiftsPage() {
|
|||
}
|
||||
|
||||
function startImport() {
|
||||
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
|
||||
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("official"); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -160,7 +240,7 @@ export function GiftsPage() {
|
|||
<div className="metric-row gift-metrics">
|
||||
<Metric label={t("gifts.total")} value={String(gifts.length)} />
|
||||
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
|
||||
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
|
|
@ -193,17 +273,69 @@ export function GiftsPage() {
|
|||
|
||||
{importOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${(importSource === "official" ? sourceGiftID : file) ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : (importSource === "official" ? sourceGiftID : file) ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
|
||||
</div>
|
||||
<div className="gift-source-tabs">
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
|
||||
</div>
|
||||
{importSource === "official" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
|
||||
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
|
||||
</div>
|
||||
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
|
||||
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
|
||||
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
|
||||
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
|
||||
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
|
||||
{visibleOfficial.map((gift) => {
|
||||
const selected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${selected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={selected} onClick={() => chooseOfficial(gift)}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
|
||||
<span className="mono">#{gift.source_gift_id}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>⭐ {gift.stars}</span>
|
||||
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
|
||||
</div>
|
||||
{selectedOfficial && <div className="official-gift-selected">
|
||||
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
|
||||
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
|
||||
</div>}
|
||||
{selectedOfficial?.can_upgrade && <>
|
||||
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
|
||||
{includeCollectible && <div className="gift-fields-grid">
|
||||
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
</div>}
|
||||
</>}
|
||||
</section> : <>
|
||||
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
|
|
@ -211,6 +343,7 @@ export function GiftsPage() {
|
|||
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
</label>
|
||||
</>}
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
|
|
|
|||
|
|
@ -276,6 +276,60 @@
|
|||
|
||||
.gift-import-modal { width: min(860px, 100%); }
|
||||
.gift-import-modal-body { gap: 14px; }
|
||||
.gift-source-tabs { display: flex; gap: 8px; }
|
||||
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
|
||||
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.official-gift-tools .searchbox { width: 100%; }
|
||||
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }
|
||||
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.official-gift-categories button {
|
||||
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
|
||||
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
|
||||
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
|
||||
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
|
||||
.official-gift-categories button span {
|
||||
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
|
||||
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
|
||||
}
|
||||
.official-gift-categories button.active span { color: var(--brand); }
|
||||
.official-gift-list {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
|
||||
background: #f6f9f8; scrollbar-gutter: stable;
|
||||
}
|
||||
.official-gift-option {
|
||||
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
|
||||
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
|
||||
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
|
||||
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
|
||||
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.official-gift-capabilities > span {
|
||||
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
|
||||
}
|
||||
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
|
||||
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
|
||||
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
|
||||
.official-gift-empty {
|
||||
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
|
||||
color: var(--muted); text-align: center; font-size: 12px;
|
||||
}
|
||||
.official-gift-selected {
|
||||
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
|
||||
}
|
||||
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; }
|
||||
.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; }
|
||||
.official-gift-selected small { color: var(--muted); }
|
||||
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
|
||||
|
||||
.gift-file-picker {
|
||||
|
|
@ -321,6 +375,7 @@
|
|||
|
||||
.gift-fields-grid input,
|
||||
.gift-reason-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
|
|
@ -440,6 +495,10 @@
|
|||
.gift-file-action { display: none; }
|
||||
.gift-fields-grid { grid-template-columns: 1fr; }
|
||||
.gift-list-summary { width: 100%; margin-left: 0; }
|
||||
.official-gift-tools { align-items: stretch; flex-direction: column; }
|
||||
.official-gift-list { grid-template-columns: 1fr; max-height: 340px; }
|
||||
.official-gift-selected { grid-template-columns: 82px minmax(0, 1fr); }
|
||||
.official-gift-selected .gift-animation-shell { width: 72px; height: 72px; }
|
||||
.collectible-modal-body { padding: 10px; }
|
||||
.collectible-definition-head,
|
||||
.collectible-section-head { align-items: flex-start; flex-direction: column; }
|
||||
|
|
|
|||
|
|
@ -161,34 +161,58 @@ export type OutboxRow = {
|
|||
};
|
||||
|
||||
export type StarGiftRow = {
|
||||
GiftID: number;
|
||||
RevisionID: number;
|
||||
GiftID: string;
|
||||
RevisionID: string;
|
||||
Revision: number;
|
||||
Title: string;
|
||||
Stars: number;
|
||||
ConvertStars: number;
|
||||
Stars: string;
|
||||
ConvertStars: string;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
DocumentID: number;
|
||||
DocumentID: string;
|
||||
SourceName: string;
|
||||
SourceFormat: "tgs" | "lottie";
|
||||
AnimationSHA: string;
|
||||
AnimationSize: number;
|
||||
AnimationSize: string;
|
||||
Width: number;
|
||||
Height: number;
|
||||
FrameRate: number;
|
||||
ReceivedCount: number;
|
||||
ReceivedCount: string;
|
||||
CreatedBy: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
|
||||
|
||||
export type OfficialStarGiftRow = {
|
||||
source_gift_id: string;
|
||||
title: string;
|
||||
stars: string;
|
||||
convert_stars: string;
|
||||
upgrade_stars: string;
|
||||
availability_total: number;
|
||||
limited: boolean;
|
||||
sold_out: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_model_count: number;
|
||||
can_upgrade: boolean;
|
||||
can_craft: boolean;
|
||||
document_id: string;
|
||||
animation_validated: boolean;
|
||||
};
|
||||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: number;
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
name: string;
|
||||
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
rarity_permille: number;
|
||||
crafted: boolean;
|
||||
official_document_id: string;
|
||||
sort_order: number;
|
||||
source_name?: string;
|
||||
source_format?: "tgs" | "lottie";
|
||||
|
|
@ -201,9 +225,9 @@ export type StarGiftCollectibleAttributeRow = {
|
|||
|
||||
export type StarGiftCollectiblePreview = {
|
||||
found: boolean;
|
||||
gift_id: number;
|
||||
gift_id: string;
|
||||
revision?: number;
|
||||
upgrade_stars?: number;
|
||||
upgrade_stars?: string;
|
||||
supply_total?: number;
|
||||
issued?: number;
|
||||
slug_prefix?: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -56,6 +56,7 @@ import (
|
|||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
|
|
@ -469,6 +470,7 @@ func run(logger *zap.Logger) error {
|
|||
adminService := adminapp.NewService(adminapp.Dependencies{
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
|
||||
})
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
|
|
@ -663,9 +665,26 @@ func run(logger *zap.Logger) error {
|
|||
starsStore := postgres.NewStarsStore(pool)
|
||||
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
|
||||
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
|
||||
OfferMinStars: cfg.StarGiftOfferMinStars,
|
||||
ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second),
|
||||
ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second),
|
||||
CraftChancePermille: cfg.StarGiftCraftChancePermille,
|
||||
}))
|
||||
starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant,
|
||||
postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
||||
StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille,
|
||||
TONProceedsPermille: cfg.StarGiftTONProceedsPermille,
|
||||
}))
|
||||
starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init local star gift withdrawal provider: %w", err)
|
||||
}
|
||||
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore))
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore),
|
||||
stargifts.WithLifecycleStore(starGiftLifecycleStore),
|
||||
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -852,6 +871,32 @@ func run(logger *zap.Logger) error {
|
|||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
go func() {
|
||||
interval := cfg.StarGiftSweepInterval
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
batch := cfg.StarGiftSweepBatch
|
||||
if batch <= 0 {
|
||||
batch = 1000
|
||||
}
|
||||
run := func() {
|
||||
if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}()
|
||||
go router.RunInlineBotPushSubscriber(ctx)
|
||||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
|
||||
return fmt.Errorf("start bot api: %w", err)
|
||||
|
|
@ -870,6 +915,8 @@ func run(logger *zap.Logger) error {
|
|||
Channels: channelStore,
|
||||
Privacy: privacyService,
|
||||
Photos: filesService,
|
||||
UniqueGifts: giftsService,
|
||||
GiftWithdrawals: giftsService,
|
||||
}, logger.Named("public-web")); err != nil {
|
||||
return fmt.Errorf("start public Web: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.star_gift_collectible_models
|
||||
WHERE rarity_kind <> 'permille' OR crafted
|
||||
) THEN
|
||||
RAISE EXCEPTION 'cannot downgrade while categorical/crafted collectible models exist';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP INDEX IF EXISTS public.star_gift_catalog_revisions_official_source_idx;
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_backdrops
|
||||
DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check,
|
||||
ALTER COLUMN rarity_permille SET NOT NULL,
|
||||
DROP COLUMN rarity_kind,
|
||||
ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_patterns
|
||||
DROP CONSTRAINT star_gift_collectible_pattern_official_document_check,
|
||||
DROP CONSTRAINT star_gift_collectible_pattern_rarity_check,
|
||||
ALTER COLUMN rarity_permille SET NOT NULL,
|
||||
DROP COLUMN official_document_id,
|
||||
DROP COLUMN rarity_kind,
|
||||
ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_models
|
||||
DROP CONSTRAINT star_gift_collectible_model_official_document_check,
|
||||
DROP CONSTRAINT star_gift_collectible_model_rarity_check,
|
||||
ALTER COLUMN rarity_permille SET NOT NULL,
|
||||
DROP COLUMN official_document_id,
|
||||
DROP COLUMN crafted,
|
||||
DROP COLUMN rarity_kind,
|
||||
ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_revisions
|
||||
DROP CONSTRAINT star_gift_collectible_official_source_check,
|
||||
DROP COLUMN source_manifest_sha256,
|
||||
DROP COLUMN official_gift_id;
|
||||
|
||||
ALTER TABLE public.star_gift_catalog_revisions
|
||||
DROP CONSTRAINT star_gift_catalog_official_source_check,
|
||||
DROP COLUMN official_source,
|
||||
DROP COLUMN source_manifest_sha256,
|
||||
DROP COLUMN official_gift_id;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.status = 'published' THEN
|
||||
RAISE EXCEPTION 'published collectible revision is immutable';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'published' THEN
|
||||
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
|
||||
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
|
||||
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
|
||||
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
|
||||
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN
|
||||
RAISE EXCEPTION 'published collectible revision is immutable';
|
||||
END IF;
|
||||
IF NEW.issued <> OLD.issued + 1 THEN
|
||||
RAISE EXCEPTION 'published collectible issuance must advance exactly once';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
88
deploy/migrations/0093_official_star_gift_attributes.up.sql
Normal file
88
deploy/migrations/0093_official_star_gift_attributes.up.sql
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
-- Preserve the complete Layer 228 official collectible attribute shape. Display rarity is
|
||||
-- distinct from regular-upgrade selection eligibility, and official provenance is recorded
|
||||
-- on both immutable revisions created by one import command.
|
||||
|
||||
ALTER TABLE public.star_gift_catalog_revisions
|
||||
ADD COLUMN official_gift_id bigint,
|
||||
ADD COLUMN source_manifest_sha256 bytea,
|
||||
ADD COLUMN official_source jsonb,
|
||||
ADD CONSTRAINT star_gift_catalog_official_source_check CHECK (
|
||||
(official_gift_id IS NULL AND source_manifest_sha256 IS NULL AND official_source IS NULL) OR
|
||||
(official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND official_source IS NOT NULL AND
|
||||
octet_length(source_manifest_sha256) = 32 AND jsonb_typeof(official_source) = 'object')
|
||||
);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_revisions
|
||||
ADD COLUMN official_gift_id bigint,
|
||||
ADD COLUMN source_manifest_sha256 bytea,
|
||||
ADD CONSTRAINT star_gift_collectible_official_source_check CHECK (
|
||||
(official_gift_id IS NULL AND source_manifest_sha256 IS NULL) OR
|
||||
(official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND octet_length(source_manifest_sha256) = 32)
|
||||
);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_models
|
||||
DROP CONSTRAINT star_gift_collectible_model_rarity_check,
|
||||
ALTER COLUMN rarity_permille DROP NOT NULL,
|
||||
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
|
||||
ADD COLUMN crafted boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN official_document_id bigint,
|
||||
ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (
|
||||
rarity_kind IN ('permille', 'uncommon', 'rare', 'epic', 'legendary') AND
|
||||
((rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 AND NOT crafted) OR
|
||||
(rarity_kind <> 'permille' AND rarity_permille IS NULL AND crafted))
|
||||
),
|
||||
ADD CONSTRAINT star_gift_collectible_model_official_document_check CHECK (
|
||||
official_document_id IS NULL OR official_document_id > 0
|
||||
);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_patterns
|
||||
DROP CONSTRAINT star_gift_collectible_pattern_rarity_check,
|
||||
ALTER COLUMN rarity_permille DROP NOT NULL,
|
||||
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
|
||||
ADD COLUMN official_document_id bigint,
|
||||
ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (
|
||||
rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000
|
||||
),
|
||||
ADD CONSTRAINT star_gift_collectible_pattern_official_document_check CHECK (
|
||||
official_document_id IS NULL OR official_document_id > 0
|
||||
);
|
||||
|
||||
ALTER TABLE public.star_gift_collectible_backdrops
|
||||
DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check,
|
||||
ALTER COLUMN rarity_permille DROP NOT NULL,
|
||||
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
|
||||
ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (
|
||||
rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000
|
||||
);
|
||||
|
||||
CREATE INDEX star_gift_catalog_revisions_official_source_idx
|
||||
ON public.star_gift_catalog_revisions(official_gift_id, id DESC)
|
||||
WHERE official_gift_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.status = 'published' THEN
|
||||
RAISE EXCEPTION 'published collectible revision is immutable';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'published' THEN
|
||||
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
|
||||
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
|
||||
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
|
||||
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
|
||||
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at OR
|
||||
NEW.official_gift_id IS DISTINCT FROM OLD.official_gift_id OR
|
||||
NEW.source_manifest_sha256 IS DISTINCT FROM OLD.source_manifest_sha256 THEN
|
||||
RAISE EXCEPTION 'published collectible revision is immutable';
|
||||
END IF;
|
||||
IF NEW.issued <> OLD.issued + 1 THEN
|
||||
RAISE EXCEPTION 'published collectible issuance must advance exactly once';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
34
deploy/migrations/0094_star_gift_catalog_shape.down.sql
Normal file
34
deploy/migrations/0094_star_gift_catalog_shape.down.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_user_purchases;
|
||||
|
||||
ALTER TABLE public.star_gift_catalog_revisions
|
||||
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_background_check,
|
||||
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_auction_check,
|
||||
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_released_by_check,
|
||||
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_supply_check,
|
||||
DROP COLUMN IF EXISTS background_text_color,
|
||||
DROP COLUMN IF EXISTS background_edge_color,
|
||||
DROP COLUMN IF EXISTS background_center_color,
|
||||
DROP COLUMN IF EXISTS upgrade_variants,
|
||||
DROP COLUMN IF EXISTS auction_start_date,
|
||||
DROP COLUMN IF EXISTS gifts_per_round,
|
||||
DROP COLUMN IF EXISTS auction_slug,
|
||||
DROP COLUMN IF EXISTS locked_until_date,
|
||||
DROP COLUMN IF EXISTS per_user_total,
|
||||
DROP COLUMN IF EXISTS released_by_peer_id,
|
||||
DROP COLUMN IF EXISTS released_by_peer_type,
|
||||
DROP COLUMN IF EXISTS availability_total,
|
||||
DROP COLUMN IF EXISTS auction,
|
||||
DROP COLUMN IF EXISTS peer_color_available,
|
||||
DROP COLUMN IF EXISTS limited_per_user,
|
||||
DROP COLUMN IF EXISTS require_premium,
|
||||
DROP COLUMN IF EXISTS birthday,
|
||||
DROP COLUMN IF EXISTS sold_out,
|
||||
DROP COLUMN IF EXISTS limited;
|
||||
|
||||
ALTER TABLE public.star_gift_catalog
|
||||
DROP CONSTRAINT IF EXISTS star_gift_catalog_inventory_check,
|
||||
DROP COLUMN IF EXISTS last_sale_date,
|
||||
DROP COLUMN IF EXISTS first_sale_date,
|
||||
DROP COLUMN IF EXISTS availability_resale,
|
||||
DROP COLUMN IF EXISTS resell_min_stars,
|
||||
DROP COLUMN IF EXISTS availability_remains;
|
||||
68
deploy/migrations/0094_star_gift_catalog_shape.up.sql
Normal file
68
deploy/migrations/0094_star_gift_catalog_shape.up.sql
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
-- Preserve the complete Layer 228 regular StarGift shape. Release facts are immutable
|
||||
-- catalog-revision data; inventory and sale timestamps belong to the mutable catalog
|
||||
-- aggregate. Per-user ownership limits are enforced by the transaction boundary rather
|
||||
-- than reconstructed from peer_star_gifts after the fact.
|
||||
|
||||
ALTER TABLE public.star_gift_catalog
|
||||
ADD COLUMN availability_remains integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN availability_resale bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN resell_min_stars bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN first_sale_date integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL,
|
||||
ADD CONSTRAINT star_gift_catalog_inventory_check CHECK (
|
||||
availability_remains >= 0 AND availability_resale >= 0 AND resell_min_stars >= 0 AND
|
||||
first_sale_date >= 0 AND last_sale_date >= 0 AND
|
||||
(last_sale_date = 0 OR first_sale_date > 0) AND
|
||||
(first_sale_date = 0 OR last_sale_date = 0 OR last_sale_date >= first_sale_date)
|
||||
);
|
||||
|
||||
ALTER TABLE public.star_gift_catalog_revisions
|
||||
ADD COLUMN limited boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN sold_out boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN birthday boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN require_premium boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN limited_per_user boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN peer_color_available boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN auction boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN availability_total integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN released_by_peer_type text,
|
||||
ADD COLUMN released_by_peer_id bigint,
|
||||
ADD COLUMN per_user_total integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN locked_until_date integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN auction_slug text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN gifts_per_round integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN auction_start_date integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN upgrade_variants integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN background_center_color integer,
|
||||
ADD COLUMN background_edge_color integer,
|
||||
ADD COLUMN background_text_color integer,
|
||||
ADD CONSTRAINT star_gift_catalog_revision_supply_check CHECK (
|
||||
availability_total >= 0 AND
|
||||
per_user_total >= 0 AND locked_until_date >= 0 AND upgrade_variants >= 0 AND
|
||||
((limited AND availability_total > 0) OR (NOT limited AND availability_total = 0)) AND
|
||||
(NOT sold_out OR limited) AND
|
||||
((limited_per_user AND per_user_total > 0) OR (NOT limited_per_user AND per_user_total = 0))
|
||||
),
|
||||
ADD CONSTRAINT star_gift_catalog_revision_released_by_check CHECK (
|
||||
(released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR
|
||||
(released_by_peer_type IN ('user', 'chat', 'channel') AND released_by_peer_id > 0)
|
||||
),
|
||||
ADD CONSTRAINT star_gift_catalog_revision_auction_check CHECK (
|
||||
(auction AND limited AND auction_slug <> '' AND gifts_per_round > 0 AND auction_start_date > 0) OR
|
||||
(NOT auction AND auction_slug = '' AND gifts_per_round = 0 AND auction_start_date = 0)
|
||||
),
|
||||
ADD CONSTRAINT star_gift_catalog_revision_background_check CHECK (
|
||||
(background_center_color IS NULL AND background_edge_color IS NULL AND background_text_color IS NULL) OR
|
||||
(background_center_color BETWEEN 0 AND 16777215 AND
|
||||
background_edge_color BETWEEN 0 AND 16777215 AND
|
||||
background_text_color BETWEEN 0 AND 16777215)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_user_purchases (
|
||||
user_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
|
||||
purchased_count integer DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_user_purchases_pkey PRIMARY KEY (user_id, gift_id),
|
||||
CONSTRAINT star_gift_user_purchases_count_check CHECK (user_id > 0 AND purchased_count >= 0)
|
||||
);
|
||||
76
deploy/migrations/0095_star_gift_lifecycle.down.sql
Normal file
76
deploy/migrations/0095_star_gift_lifecycle.down.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
DROP TRIGGER IF EXISTS peer_unique_star_gift_owner_guard ON public.peer_star_gifts;
|
||||
DROP TRIGGER IF EXISTS unique_star_gift_owner_guard ON public.unique_star_gifts;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_check_unique_star_gift_owner();
|
||||
DROP TRIGGER IF EXISTS star_gift_listing_guard ON public.star_gift_listings;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_guard_star_gift_listing();
|
||||
|
||||
DROP TABLE IF EXISTS public.ton_transactions;
|
||||
DROP TABLE IF EXISTS public.ton_balances;
|
||||
DROP TABLE IF EXISTS public.star_gift_auction_acquired;
|
||||
DROP TABLE IF EXISTS public.star_gift_auction_bid_payments;
|
||||
DROP TABLE IF EXISTS public.star_gift_auction_bids;
|
||||
DROP TABLE IF EXISTS public.star_gift_auctions;
|
||||
DROP TABLE IF EXISTS public.star_gift_withdrawal_requests;
|
||||
DROP TABLE IF EXISTS public.star_gift_notification_settings;
|
||||
DROP TABLE IF EXISTS public.star_gift_craft_commands;
|
||||
DROP TABLE IF EXISTS public.star_gift_transfer_commands;
|
||||
DROP TABLE IF EXISTS public.star_gift_purchase_commands;
|
||||
DROP TABLE IF EXISTS public.star_gift_drop_details_commands;
|
||||
DROP TABLE IF EXISTS public.star_gift_prepaid_upgrade_commands;
|
||||
DROP TABLE IF EXISTS public.star_gift_offers;
|
||||
DROP TABLE IF EXISTS public.star_gift_sales;
|
||||
DROP TABLE IF EXISTS public.star_gift_listings;
|
||||
|
||||
ALTER TABLE public.unique_star_gifts
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_value_check,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_original_owner_check,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_host_peer_check,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_theme_peer_check,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_released_by_check,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check,
|
||||
DROP COLUMN IF EXISTS last_sale_amount,
|
||||
DROP COLUMN IF EXISTS last_sale_currency,
|
||||
DROP COLUMN IF EXISTS last_sale_date,
|
||||
DROP COLUMN IF EXISTS craft_chance_permille,
|
||||
DROP COLUMN IF EXISTS offer_min_stars,
|
||||
DROP COLUMN IF EXISTS host_peer_id,
|
||||
DROP COLUMN IF EXISTS host_peer_type,
|
||||
DROP COLUMN IF EXISTS theme_peer_id,
|
||||
DROP COLUMN IF EXISTS theme_peer_type,
|
||||
DROP COLUMN IF EXISTS value_usd_amount,
|
||||
DROP COLUMN IF EXISTS value_currency,
|
||||
DROP COLUMN IF EXISTS value_amount,
|
||||
DROP COLUMN IF EXISTS released_by_peer_id,
|
||||
DROP COLUMN IF EXISTS released_by_peer_type,
|
||||
DROP COLUMN IF EXISTS gift_address,
|
||||
DROP COLUMN IF EXISTS owner_address,
|
||||
DROP COLUMN IF EXISTS owner_name,
|
||||
DROP COLUMN IF EXISTS crafted,
|
||||
DROP COLUMN IF EXISTS original_owner_peer_id,
|
||||
DROP COLUMN IF EXISTS original_owner_peer_type,
|
||||
DROP COLUMN IF EXISTS burned,
|
||||
DROP COLUMN IF EXISTS theme_available,
|
||||
DROP COLUMN IF EXISTS resale_ton_only,
|
||||
DROP COLUMN IF EXISTS require_premium;
|
||||
|
||||
UPDATE public.unique_star_gifts u
|
||||
SET owner_peer_type=p.owner_peer_type, owner_peer_id=p.owner_peer_id
|
||||
FROM public.peer_star_gifts p
|
||||
WHERE p.unique_gift_id=u.id AND (u.owner_peer_type IS NULL OR u.owner_peer_id IS NULL);
|
||||
ALTER TABLE public.unique_star_gifts
|
||||
ALTER COLUMN owner_peer_type SET NOT NULL,
|
||||
ALTER COLUMN owner_peer_id SET NOT NULL,
|
||||
ADD CONSTRAINT unique_star_gift_owner_check CHECK (owner_peer_type IN ('user','channel') AND owner_peer_id>0);
|
||||
|
||||
ALTER TABLE public.peer_star_gifts
|
||||
DROP COLUMN IF EXISTS prepaid_upgrade_hash,
|
||||
DROP COLUMN IF EXISTS gift_num,
|
||||
DROP CONSTRAINT IF EXISTS peer_star_gifts_lifecycle_check,
|
||||
DROP COLUMN IF EXISTS can_craft_at,
|
||||
DROP COLUMN IF EXISTS drop_original_details_stars,
|
||||
DROP COLUMN IF EXISTS can_resell_at,
|
||||
DROP COLUMN IF EXISTS can_transfer_at,
|
||||
DROP COLUMN IF EXISTS can_export_at,
|
||||
DROP COLUMN IF EXISTS transfer_stars,
|
||||
DROP COLUMN IF EXISTS lifecycle_status,
|
||||
ADD CONSTRAINT peer_star_gifts_terminal_state_check CHECK (NOT converted OR unique_gift_id IS NULL);
|
||||
425
deploy/migrations/0095_star_gift_lifecycle.up.sql
Normal file
425
deploy/migrations/0095_star_gift_lifecycle.up.sql
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
-- Complete collectible Star Gift lifecycle: ownership state, transfer/resale, purchase
|
||||
-- offers, crafting, auctions, notification preferences and the explicit TON boundary.
|
||||
|
||||
ALTER TABLE public.peer_star_gifts
|
||||
DROP CONSTRAINT IF EXISTS peer_star_gifts_terminal_state_check,
|
||||
ADD COLUMN lifecycle_status text DEFAULT 'active' NOT NULL,
|
||||
ADD COLUMN transfer_stars bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN prepaid_upgrade_hash text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN gift_num integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN can_export_at integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN can_transfer_at integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN can_resell_at integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN drop_original_details_stars bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN can_craft_at integer DEFAULT 0 NOT NULL;
|
||||
|
||||
UPDATE public.peer_star_gifts SET lifecycle_status='converted' WHERE converted;
|
||||
|
||||
ALTER TABLE public.peer_star_gifts
|
||||
ADD CONSTRAINT peer_star_gifts_lifecycle_check CHECK (
|
||||
lifecycle_status IN ('active', 'converted', 'burned', 'exported') AND
|
||||
transfer_stars >= 0 AND gift_num >= 0 AND can_export_at >= 0 AND can_transfer_at >= 0 AND
|
||||
can_resell_at >= 0 AND drop_original_details_stars >= 0 AND can_craft_at >= 0 AND
|
||||
((lifecycle_status='converted' AND converted AND unique_gift_id IS NULL) OR
|
||||
(lifecycle_status='active' AND NOT converted) OR
|
||||
(lifecycle_status IN ('burned','exported') AND NOT converted AND unique_gift_id IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX peer_star_gifts_prepaid_upgrade_hash_uniq
|
||||
ON public.peer_star_gifts(prepaid_upgrade_hash) WHERE prepaid_upgrade_hash<>'';
|
||||
|
||||
ALTER TABLE public.unique_star_gifts
|
||||
ALTER COLUMN owner_peer_type DROP NOT NULL,
|
||||
ALTER COLUMN owner_peer_id DROP NOT NULL,
|
||||
DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check,
|
||||
ADD COLUMN require_premium boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN resale_ton_only boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN theme_available boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN burned boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN crafted boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN original_owner_peer_type text,
|
||||
ADD COLUMN original_owner_peer_id bigint,
|
||||
ADD COLUMN owner_name text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN owner_address text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN gift_address text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN released_by_peer_type text,
|
||||
ADD COLUMN released_by_peer_id bigint,
|
||||
ADD COLUMN value_amount bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN value_currency text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN value_usd_amount bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN theme_peer_type text,
|
||||
ADD COLUMN theme_peer_id bigint,
|
||||
ADD COLUMN host_peer_type text,
|
||||
ADD COLUMN host_peer_id bigint,
|
||||
ADD COLUMN offer_min_stars integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN craft_chance_permille integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN last_sale_currency text DEFAULT '' NOT NULL,
|
||||
ADD COLUMN last_sale_amount bigint DEFAULT 0 NOT NULL,
|
||||
ADD CONSTRAINT unique_star_gift_owner_check CHECK (
|
||||
(owner_peer_type IN ('user','channel') AND owner_peer_id > 0 AND owner_address='') OR
|
||||
(owner_peer_type IS NULL AND owner_peer_id IS NULL AND owner_address<>'')
|
||||
),
|
||||
ADD CONSTRAINT unique_star_gift_released_by_check CHECK (
|
||||
(released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR
|
||||
(released_by_peer_type IN ('user','channel') AND released_by_peer_id > 0)
|
||||
),
|
||||
ADD CONSTRAINT unique_star_gift_theme_peer_check CHECK (
|
||||
(theme_peer_type IS NULL AND theme_peer_id IS NULL) OR
|
||||
(theme_peer_type IN ('user','channel') AND theme_peer_id > 0)
|
||||
),
|
||||
ADD CONSTRAINT unique_star_gift_host_peer_check CHECK (
|
||||
(host_peer_type IS NULL AND host_peer_id IS NULL) OR
|
||||
(host_peer_type IN ('user','channel') AND host_peer_id > 0)
|
||||
),
|
||||
ADD CONSTRAINT unique_star_gift_value_check CHECK (
|
||||
value_amount >= 0 AND value_usd_amount >= 0 AND offer_min_stars >= 0 AND
|
||||
craft_chance_permille BETWEEN 0 AND 1000 AND last_sale_date >= 0 AND last_sale_amount >= 0 AND
|
||||
((value_currency='' AND value_amount=0) OR value_currency<>'') AND
|
||||
((last_sale_currency='' AND last_sale_amount=0 AND last_sale_date=0) OR
|
||||
(last_sale_currency IN ('XTR','TON') AND last_sale_amount>0 AND last_sale_date>0)) AND
|
||||
(NOT burned OR owner_address='') AND
|
||||
((owner_address='' AND gift_address='') OR (owner_address<>'' AND gift_address<>''))
|
||||
);
|
||||
|
||||
UPDATE public.unique_star_gifts u
|
||||
SET original_owner_peer_type=p.owner_peer_type, original_owner_peer_id=p.owner_peer_id
|
||||
FROM public.peer_star_gifts p WHERE p.id=u.source_saved_gift_id;
|
||||
|
||||
ALTER TABLE public.unique_star_gifts
|
||||
ALTER COLUMN original_owner_peer_type SET NOT NULL,
|
||||
ALTER COLUMN original_owner_peer_id SET NOT NULL,
|
||||
ADD CONSTRAINT unique_star_gift_original_owner_check CHECK (
|
||||
original_owner_peer_type IN ('user','channel') AND original_owner_peer_id>0
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_listings (
|
||||
unique_gift_id bigint PRIMARY KEY REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
seller_peer_type text NOT NULL,
|
||||
seller_peer_id bigint NOT NULL,
|
||||
currency text NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
listed_at integer NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
version bigint DEFAULT 1 NOT NULL,
|
||||
CONSTRAINT star_gift_listing_seller_check CHECK (seller_peer_type IN ('user','channel') AND seller_peer_id>0),
|
||||
CONSTRAINT star_gift_listing_amount_check CHECK (currency IN ('XTR','TON') AND amount>0 AND listed_at>0 AND updated_at>=listed_at)
|
||||
);
|
||||
CREATE INDEX star_gift_listings_gift_price_idx ON public.star_gift_listings(currency, amount, unique_gift_id);
|
||||
CREATE INDEX star_gift_listings_updated_idx ON public.star_gift_listings(updated_at DESC, unique_gift_id DESC);
|
||||
|
||||
CREATE TABLE public.star_gift_sales (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
seller_peer_type text NOT NULL,
|
||||
seller_peer_id bigint NOT NULL,
|
||||
buyer_peer_type text NOT NULL,
|
||||
buyer_peer_id bigint NOT NULL,
|
||||
currency text NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
commission_amount bigint DEFAULT 0 NOT NULL,
|
||||
sold_at integer NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
CONSTRAINT star_gift_sales_command_uniq UNIQUE(command_key),
|
||||
CONSTRAINT star_gift_sales_peer_check CHECK (
|
||||
seller_peer_type IN ('user','channel') AND seller_peer_id>0 AND
|
||||
buyer_peer_type IN ('user','channel') AND buyer_peer_id>0),
|
||||
CONSTRAINT star_gift_sales_amount_check CHECK (
|
||||
currency IN ('XTR','TON') AND amount>0 AND commission_amount>=0 AND commission_amount<=amount AND sold_at>0)
|
||||
);
|
||||
CREATE INDEX star_gift_sales_unique_date_idx ON public.star_gift_sales(unique_gift_id, sold_at DESC);
|
||||
|
||||
CREATE TABLE public.star_gift_offers (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
buyer_user_id bigint NOT NULL,
|
||||
owner_peer_type text NOT NULL,
|
||||
owner_peer_id bigint NOT NULL,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
currency text NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
random_id bigint NOT NULL,
|
||||
offer_msg_id integer DEFAULT 0 NOT NULL,
|
||||
buyer_msg_id integer DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
expires_at integer NOT NULL,
|
||||
resolved_at integer DEFAULT 0 NOT NULL,
|
||||
balance_after bigint DEFAULT 0 NOT NULL,
|
||||
expiry_notified boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT star_gift_offer_random_uniq UNIQUE(buyer_user_id, random_id),
|
||||
CONSTRAINT star_gift_offer_owner_msg_uniq UNIQUE(owner_peer_type, owner_peer_id, offer_msg_id),
|
||||
CONSTRAINT star_gift_offer_peer_check CHECK (buyer_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0),
|
||||
CONSTRAINT star_gift_offer_amount_check CHECK (currency IN ('XTR','TON') AND amount>0),
|
||||
CONSTRAINT star_gift_offer_status_check CHECK (status IN ('pending','accepted','declined','expired','cancelled')),
|
||||
CONSTRAINT star_gift_offer_time_check CHECK (created_at>0 AND expires_at>created_at AND resolved_at>=0 AND
|
||||
((status='pending' AND resolved_at=0) OR (status<>'pending' AND resolved_at>=created_at)))
|
||||
);
|
||||
CREATE INDEX star_gift_offers_pending_expiry_idx ON public.star_gift_offers(expires_at, id) WHERE status='pending';
|
||||
CREATE INDEX star_gift_offers_unique_pending_idx ON public.star_gift_offers(unique_gift_id, id) WHERE status='pending';
|
||||
|
||||
CREATE TABLE public.star_gift_transfer_commands (
|
||||
actor_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
from_peer_type text NOT NULL,
|
||||
from_peer_id bigint NOT NULL,
|
||||
to_peer_type text NOT NULL,
|
||||
to_peer_id bigint NOT NULL,
|
||||
charge_stars bigint DEFAULT 0 NOT NULL,
|
||||
balance_after bigint DEFAULT 0 NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_transfer_commands_pkey PRIMARY KEY(actor_user_id, command_key),
|
||||
CONSTRAINT star_gift_transfer_command_peer_check CHECK (
|
||||
actor_user_id>0 AND from_peer_type IN ('user','channel') AND from_peer_id>0 AND
|
||||
to_peer_type IN ('user','channel') AND to_peer_id>0 AND charge_stars>=0 AND created_at>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_purchase_commands (
|
||||
buyer_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
|
||||
recipient_peer_type text NOT NULL,
|
||||
recipient_peer_id bigint NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
form_id bigint NOT NULL,
|
||||
charge_stars bigint NOT NULL,
|
||||
balance_after bigint NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_purchase_commands_pkey PRIMARY KEY(buyer_user_id,command_key),
|
||||
CONSTRAINT star_gift_purchase_commands_form_uniq UNIQUE(buyer_user_id,form_id),
|
||||
CONSTRAINT star_gift_purchase_command_shape_check CHECK (
|
||||
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
|
||||
form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_prepaid_upgrade_commands (
|
||||
payer_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
form_id bigint NOT NULL,
|
||||
charge_stars bigint NOT NULL,
|
||||
balance_after bigint NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_prepaid_upgrade_commands_pkey PRIMARY KEY(payer_user_id, command_key),
|
||||
CONSTRAINT star_gift_prepaid_upgrade_commands_form_uniq UNIQUE(payer_user_id, form_id),
|
||||
CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
|
||||
payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_drop_details_commands (
|
||||
user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
form_id bigint NOT NULL,
|
||||
charge_stars bigint NOT NULL,
|
||||
balance_after bigint NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_drop_details_commands_pkey PRIMARY KEY(user_id, command_key),
|
||||
CONSTRAINT star_gift_drop_details_commands_form_uniq UNIQUE(user_id, form_id),
|
||||
CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
|
||||
user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_craft_commands (
|
||||
user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
input_unique_gift_ids bigint[] NOT NULL,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
|
||||
success boolean NOT NULL,
|
||||
result_unique_gift_id bigint REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
chance_permille integer NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_craft_commands_pkey PRIMARY KEY(user_id, command_key),
|
||||
CONSTRAINT star_gift_craft_shape_check CHECK (
|
||||
user_id>0 AND cardinality(input_unique_gift_ids) BETWEEN 1 AND 4 AND
|
||||
chance_permille BETWEEN 0 AND 1000 AND created_at>0 AND
|
||||
((success AND result_unique_gift_id IS NOT NULL) OR (NOT success AND result_unique_gift_id IS NULL)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_notification_settings (
|
||||
user_id bigint NOT NULL,
|
||||
channel_id bigint NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_notification_settings_pkey PRIMARY KEY(user_id, channel_id),
|
||||
CONSTRAINT star_gift_notification_settings_peer_check CHECK (user_id>0 AND channel_id>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_withdrawal_requests (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
owner_user_id bigint NOT NULL,
|
||||
provider text NOT NULL,
|
||||
provider_request_id text NOT NULL,
|
||||
url text NOT NULL,
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
expires_at integer NOT NULL,
|
||||
completed_at integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT star_gift_withdrawal_request_unique UNIQUE(unique_gift_id),
|
||||
CONSTRAINT star_gift_withdrawal_provider_request_uniq UNIQUE(provider, provider_request_id),
|
||||
CONSTRAINT star_gift_withdrawal_shape_check CHECK (
|
||||
owner_user_id>0 AND provider<>'' AND provider_request_id<>'' AND url<>'' AND
|
||||
status IN ('pending','completed','failed') AND created_at>0 AND expires_at>created_at AND completed_at>=0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_auctions (
|
||||
gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
|
||||
slug text NOT NULL UNIQUE,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
start_date integer NOT NULL,
|
||||
end_date integer NOT NULL,
|
||||
round_duration integer NOT NULL,
|
||||
gifts_per_round integer NOT NULL,
|
||||
total_rounds integer NOT NULL,
|
||||
current_round integer DEFAULT 0 NOT NULL,
|
||||
next_round_at integer NOT NULL,
|
||||
last_gift_num integer DEFAULT 0 NOT NULL,
|
||||
gifts_left integer NOT NULL,
|
||||
min_bid_amount bigint NOT NULL,
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_auction_shape_check CHECK (
|
||||
version>0 AND start_date>0 AND end_date>start_date AND round_duration>0 AND
|
||||
gifts_per_round>0 AND total_rounds>0 AND current_round BETWEEN 0 AND total_rounds AND
|
||||
next_round_at>=start_date AND last_gift_num>=0 AND gifts_left>=0 AND min_bid_amount>0 AND
|
||||
status IN ('pending','active','completed','cancelled'))
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_auction_bids (
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
|
||||
bidder_user_id bigint NOT NULL,
|
||||
recipient_peer_type text NOT NULL,
|
||||
recipient_peer_id bigint NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
bid_date integer NOT NULL,
|
||||
hide_name boolean DEFAULT false NOT NULL,
|
||||
message text DEFAULT '' NOT NULL,
|
||||
returned boolean DEFAULT false NOT NULL,
|
||||
acquired_count integer DEFAULT 0 NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
version bigint DEFAULT 1 NOT NULL,
|
||||
CONSTRAINT star_gift_auction_bids_pkey PRIMARY KEY(gift_id, bidder_user_id),
|
||||
CONSTRAINT star_gift_auction_bid_peer_check CHECK (
|
||||
bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0),
|
||||
CONSTRAINT star_gift_auction_bid_amount_check CHECK (amount>0 AND bid_date>0 AND acquired_count>=0 AND version>0)
|
||||
);
|
||||
CREATE INDEX star_gift_auction_bids_rank_idx ON public.star_gift_auction_bids(gift_id, amount DESC, bid_date, bidder_user_id) WHERE active;
|
||||
|
||||
CREATE TABLE public.star_gift_auction_bid_payments (
|
||||
user_id bigint NOT NULL,
|
||||
form_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
|
||||
bid_amount bigint NOT NULL,
|
||||
balance_after bigint NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_auction_bid_payments_pkey PRIMARY KEY(user_id, form_id),
|
||||
CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
|
||||
user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.star_gift_auction_acquired (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
|
||||
bidder_user_id bigint NOT NULL,
|
||||
recipient_peer_type text NOT NULL,
|
||||
recipient_peer_id bigint NOT NULL,
|
||||
saved_gift_id bigint REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
bid_amount bigint NOT NULL,
|
||||
round integer NOT NULL,
|
||||
pos integer NOT NULL,
|
||||
gift_num integer,
|
||||
acquired_at integer NOT NULL,
|
||||
hide_name boolean DEFAULT false NOT NULL,
|
||||
message text DEFAULT '' NOT NULL,
|
||||
CONSTRAINT star_gift_auction_acquired_round_pos_uniq UNIQUE(gift_id, round, pos),
|
||||
CONSTRAINT star_gift_auction_acquired_shape_check CHECK (
|
||||
bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
|
||||
bid_amount>0 AND round>0 AND pos>0 AND acquired_at>0 AND (gift_num IS NULL OR gift_num>0))
|
||||
);
|
||||
CREATE INDEX star_gift_auction_acquired_user_idx ON public.star_gift_auction_acquired(bidder_user_id, gift_id, id);
|
||||
|
||||
CREATE TABLE public.ton_balances (
|
||||
user_id bigint PRIMARY KEY,
|
||||
balance_nanoton bigint DEFAULT 0 NOT NULL,
|
||||
granted boolean DEFAULT false NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT ton_balances_check CHECK (user_id>0 AND balance_nanoton>=0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.ton_transactions (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL,
|
||||
amount_nanoton bigint NOT NULL,
|
||||
reason text NOT NULL,
|
||||
peer_type text,
|
||||
peer_id bigint,
|
||||
gift_id bigint,
|
||||
date integer NOT NULL,
|
||||
CONSTRAINT ton_transaction_amount_check CHECK (user_id>0 AND amount_nanoton<>0 AND date>0),
|
||||
CONSTRAINT ton_transaction_peer_check CHECK (
|
||||
(peer_type IS NULL AND peer_id IS NULL) OR (peer_type IN ('user','channel') AND peer_id>0))
|
||||
);
|
||||
CREATE INDEX ton_transactions_user_idx ON public.ton_transactions(user_id, id DESC);
|
||||
|
||||
CREATE FUNCTION public.telesrv_guard_star_gift_listing() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_burned boolean;
|
||||
BEGIN
|
||||
SELECT owner_peer_type, owner_peer_id, burned
|
||||
INTO gift_owner_type, gift_owner_id, gift_burned
|
||||
FROM public.unique_star_gifts WHERE id=NEW.unique_gift_id FOR SHARE;
|
||||
IF gift_burned OR gift_owner_type IS DISTINCT FROM NEW.seller_peer_type OR gift_owner_id IS DISTINCT FROM NEW.seller_peer_id THEN
|
||||
RAISE EXCEPTION 'star gift listing owner/state mismatch';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER star_gift_listing_guard BEFORE INSERT OR UPDATE ON public.star_gift_listings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_star_gift_listing();
|
||||
|
||||
CREATE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id
|
||||
INTO saved_status, saved_owner_type, saved_owner_id
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
CREATE CONSTRAINT TRIGGER unique_star_gift_owner_guard
|
||||
AFTER INSERT OR UPDATE ON public.unique_star_gifts DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner();
|
||||
CREATE CONSTRAINT TRIGGER peer_unique_star_gift_owner_guard
|
||||
AFTER INSERT OR UPDATE ON public.peer_star_gifts DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW WHEN (NEW.unique_gift_id IS NOT NULL)
|
||||
EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner();
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
DROP INDEX IF EXISTS public.star_gift_auction_acquired_delivery_idx;
|
||||
DROP INDEX IF EXISTS public.star_gift_auctions_due_idx;
|
||||
DROP INDEX IF EXISTS public.star_gift_offers_resolution_outbox_idx;
|
||||
|
||||
ALTER TABLE public.star_gift_offers
|
||||
RENAME COLUMN resolution_notified TO expiry_notified;
|
||||
16
deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql
Normal file
16
deploy/migrations/0096_star_gift_lifecycle_sweeper.up.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Durable lifecycle sweep support. 0095 originally named this column after the
|
||||
-- first use case (expiry); cancelled offers use the same outbox boundary.
|
||||
ALTER TABLE public.star_gift_offers
|
||||
RENAME COLUMN expiry_notified TO resolution_notified;
|
||||
|
||||
CREATE INDEX star_gift_offers_resolution_outbox_idx
|
||||
ON public.star_gift_offers(id)
|
||||
WHERE status IN ('expired','cancelled') AND NOT resolution_notified;
|
||||
|
||||
CREATE INDEX star_gift_auctions_due_idx
|
||||
ON public.star_gift_auctions(status, next_round_at, gift_id)
|
||||
WHERE status IN ('pending','active');
|
||||
|
||||
CREATE INDEX star_gift_auction_acquired_delivery_idx
|
||||
ON public.star_gift_auction_acquired(gift_id, id)
|
||||
WHERE saved_gift_id IS NULL;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_conversions;
|
||||
DROP TABLE IF EXISTS public.channel_stars_transactions;
|
||||
DROP TABLE IF EXISTS public.channel_stars_balances;
|
||||
40
deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql
Normal file
40
deploy/migrations/0097_star_gift_peer_stars_ledger.up.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Owner-scoped Stars revenue for channel Star Gifts. These are internal
|
||||
-- telesrv ledgers only; no blockchain, wallet, TON node or Fragment endpoint is
|
||||
-- contacted. Conversion is recorded as one aggregate terminal transition.
|
||||
|
||||
CREATE TABLE public.channel_stars_balances (
|
||||
channel_id bigint PRIMARY KEY,
|
||||
balance bigint DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT channel_stars_balances_shape_check CHECK (channel_id>0 AND balance>=0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.channel_stars_transactions (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
channel_id bigint NOT NULL,
|
||||
actor_user_id bigint NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
reason text NOT NULL,
|
||||
peer_type text DEFAULT '' NOT NULL,
|
||||
peer_id bigint DEFAULT 0 NOT NULL,
|
||||
gift_id bigint,
|
||||
date integer NOT NULL,
|
||||
CONSTRAINT channel_stars_transactions_shape_check CHECK (
|
||||
channel_id>0 AND actor_user_id>0 AND amount<>0 AND date>0 AND
|
||||
((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0)))
|
||||
);
|
||||
CREATE INDEX channel_stars_transactions_channel_idx
|
||||
ON public.channel_stars_transactions(channel_id,id DESC);
|
||||
|
||||
CREATE TABLE public.star_gift_conversions (
|
||||
saved_gift_id bigint PRIMARY KEY REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
actor_user_id bigint NOT NULL,
|
||||
owner_peer_type text NOT NULL,
|
||||
owner_peer_id bigint NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
balance_after bigint NOT NULL,
|
||||
converted_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_conversions_shape_check CHECK (
|
||||
actor_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0 AND
|
||||
amount>=0 AND balance_after>=0 AND converted_at>0)
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS public.channel_ton_transactions;
|
||||
DROP TABLE IF EXISTS public.channel_ton_balances;
|
||||
25
deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql
Normal file
25
deploy/migrations/0098_star_gift_channel_ton_ledger.up.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- TON-denominated channel marketplace proceeds remain a telesrv-local ledger.
|
||||
-- No wallet, chain node, smart contract, Fragment or external RPC is involved.
|
||||
CREATE TABLE public.channel_ton_balances (
|
||||
channel_id bigint PRIMARY KEY,
|
||||
balance_nanoton bigint DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT channel_ton_balances_shape_check CHECK (channel_id>0 AND balance_nanoton>=0)
|
||||
);
|
||||
|
||||
CREATE TABLE public.channel_ton_transactions (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
channel_id bigint NOT NULL,
|
||||
actor_user_id bigint NOT NULL,
|
||||
amount_nanoton bigint NOT NULL,
|
||||
reason text NOT NULL,
|
||||
peer_type text DEFAULT '' NOT NULL,
|
||||
peer_id bigint DEFAULT 0 NOT NULL,
|
||||
gift_id bigint,
|
||||
date integer NOT NULL,
|
||||
CONSTRAINT channel_ton_transactions_shape_check CHECK (
|
||||
channel_id>0 AND actor_user_id>0 AND amount_nanoton<>0 AND date>0 AND
|
||||
((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0)))
|
||||
);
|
||||
CREATE INDEX channel_ton_transactions_channel_idx
|
||||
ON public.channel_ton_transactions(channel_id,id DESC);
|
||||
20
deploy/migrations/0099_star_gift_signed_form_ids.down.sql
Normal file
20
deploy/migrations/0099_star_gift_signed_form_ids.down.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
ALTER TABLE public.star_gift_purchase_commands
|
||||
DROP CONSTRAINT star_gift_purchase_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK (
|
||||
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
|
||||
form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
|
||||
|
||||
ALTER TABLE public.star_gift_prepaid_upgrade_commands
|
||||
DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
|
||||
payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
|
||||
|
||||
ALTER TABLE public.star_gift_drop_details_commands
|
||||
DROP CONSTRAINT star_gift_drop_details_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
|
||||
user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
|
||||
|
||||
ALTER TABLE public.star_gift_auction_bid_payments
|
||||
DROP CONSTRAINT star_gift_auction_bid_payment_shape_check,
|
||||
ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
|
||||
user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) NOT VALID;
|
||||
20
deploy/migrations/0099_star_gift_signed_form_ids.up.sql
Normal file
20
deploy/migrations/0099_star_gift_signed_form_ids.up.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
ALTER TABLE public.star_gift_purchase_commands
|
||||
DROP CONSTRAINT star_gift_purchase_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK (
|
||||
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
|
||||
form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
|
||||
|
||||
ALTER TABLE public.star_gift_prepaid_upgrade_commands
|
||||
DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
|
||||
payer_user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
|
||||
|
||||
ALTER TABLE public.star_gift_drop_details_commands
|
||||
DROP CONSTRAINT star_gift_drop_details_command_shape_check,
|
||||
ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
|
||||
user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
|
||||
|
||||
ALTER TABLE public.star_gift_auction_bid_payments
|
||||
DROP CONSTRAINT star_gift_auction_bid_payment_shape_check,
|
||||
ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
|
||||
user_id>0 AND form_id<>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0);
|
||||
35
deploy/migrations/0100_star_gift_upgrade_semantics.down.sql
Normal file
35
deploy/migrations/0100_star_gift_upgrade_semantics.down.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
UPDATE public.message_boxes
|
||||
SET media = jsonb_set(media, '{service_action,star_gift,upgrade_stars}', media #> '{service_action,star_gift,upgrade_price_stars}', true)
|
||||
#- '{service_action,star_gift,upgrade_price_stars}'
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND media #> '{service_action,star_gift,upgrade_price_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_messages
|
||||
SET action = jsonb_set(action, '{StarGift,upgrade_stars}', action #> '{StarGift,upgrade_price_stars}', true)
|
||||
#- '{StarGift,upgrade_price_stars}'
|
||||
WHERE action #>> '{Type}' = 'star_gift'
|
||||
AND action #> '{StarGift,upgrade_price_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET message = jsonb_set(message, '{Action,StarGift,upgrade_stars}', message #> '{Action,StarGift,upgrade_price_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_price_stars}'
|
||||
WHERE message #>> '{Action,Type}' = 'star_gift'
|
||||
AND message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET prev_message = jsonb_set(prev_message, '{Action,StarGift,upgrade_stars}', prev_message #> '{Action,StarGift,upgrade_price_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_price_stars}'
|
||||
WHERE prev_message #>> '{Action,Type}' = 'star_gift'
|
||||
AND prev_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET new_message = jsonb_set(new_message, '{Action,StarGift,upgrade_stars}', new_message #> '{Action,StarGift,upgrade_price_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_price_stars}'
|
||||
WHERE new_message #>> '{Action,Type}' = 'star_gift'
|
||||
AND new_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.star_gift_upgrade_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_replay_shape_check,
|
||||
DROP COLUMN IF EXISTS keep_original_details,
|
||||
DROP COLUMN IF EXISTS require_prepaid,
|
||||
DROP COLUMN IF EXISTS charge_stars;
|
||||
79
deploy/migrations/0100_star_gift_upgrade_semantics.up.sql
Normal file
79
deploy/migrations/0100_star_gift_upgrade_semantics.up.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- Split the two TL fields that TDesktop consumes differently:
|
||||
-- StarGift.upgrade_stars = current paid-upgrade price
|
||||
-- messageActionStarGift.upgrade_stars = amount already prepaid by sender
|
||||
-- Also persist the immutable command envelope required to replay an upgrade
|
||||
-- after the saved gift has entered its unique terminal state.
|
||||
|
||||
ALTER TABLE public.star_gift_upgrade_commands
|
||||
ADD COLUMN charge_stars bigint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN require_prepaid boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN keep_original_details boolean NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE public.star_gift_upgrade_commands c
|
||||
SET charge_stars = CASE WHEN c.form_id = 0 THEN 0 ELSE r.upgrade_stars END,
|
||||
require_prepaid = (c.form_id = 0),
|
||||
keep_original_details = u.keep_original_details
|
||||
FROM public.unique_star_gifts u
|
||||
JOIN public.star_gift_collectible_revisions r ON r.id = u.collectible_revision_id
|
||||
WHERE u.id = c.unique_gift_id;
|
||||
|
||||
ALTER TABLE public.star_gift_upgrade_commands
|
||||
ADD CONSTRAINT star_gift_upgrade_command_replay_shape_check CHECK (
|
||||
(require_prepaid AND form_id = 0 AND charge_stars = 0)
|
||||
OR
|
||||
(NOT require_prepaid AND form_id <> 0 AND charge_stars > 0)
|
||||
);
|
||||
|
||||
-- Private message boxes are the canonical durable snapshots used by history,
|
||||
-- live outbox delivery and updates.getDifference.
|
||||
UPDATE public.message_boxes
|
||||
SET media = CASE
|
||||
WHEN COALESCE((media #>> '{service_action,star_gift,prepaid_upgrade}')::boolean, false)
|
||||
THEN jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true)
|
||||
ELSE jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true)
|
||||
#- '{service_action,star_gift,upgrade_stars}'
|
||||
END
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND media #> '{service_action,star_gift,upgrade_stars}' IS NOT NULL;
|
||||
|
||||
-- Channel service-message and Recent Actions snapshots use exported Go field
|
||||
-- names for their outer objects and the same snake_case StarGift payload.
|
||||
UPDATE public.channel_messages
|
||||
SET action = CASE
|
||||
WHEN COALESCE((action #>> '{StarGift,prepaid_upgrade}')::boolean, false)
|
||||
THEN jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true)
|
||||
ELSE jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true)
|
||||
#- '{StarGift,upgrade_stars}'
|
||||
END
|
||||
WHERE action #>> '{Type}' = 'star_gift'
|
||||
AND action #> '{StarGift,upgrade_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET message = CASE
|
||||
WHEN COALESCE((message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
|
||||
THEN jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
ELSE jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_stars}'
|
||||
END
|
||||
WHERE message #>> '{Action,Type}' = 'star_gift'
|
||||
AND message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET prev_message = CASE
|
||||
WHEN COALESCE((prev_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
|
||||
THEN jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
ELSE jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_stars}'
|
||||
END
|
||||
WHERE prev_message #>> '{Action,Type}' = 'star_gift'
|
||||
AND prev_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;
|
||||
|
||||
UPDATE public.channel_admin_log_events
|
||||
SET new_message = CASE
|
||||
WHEN COALESCE((new_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
|
||||
THEN jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
ELSE jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true)
|
||||
#- '{Action,StarGift,upgrade_stars}'
|
||||
END
|
||||
WHERE new_message #>> '{Action,Type}' = 'star_gift'
|
||||
AND new_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- The up migration emits user-visible durable update facts. Rewinding pts or
|
||||
-- deleting events that may already have been delivered would create a hole in
|
||||
-- updates.getDifference, so rollback intentionally preserves those facts.
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
-- Migration 0100 corrected the durable Star Gift service-message JSON, but a
|
||||
-- TDesktop that had already cached the old message would otherwise keep using
|
||||
-- the conflated outer upgrade_stars field. Publish one durable edit event for
|
||||
-- every private message box whose paid-upgrade price was split by 0100.
|
||||
--
|
||||
-- This is deliberately a pts event, not a cache-only notification: history,
|
||||
-- updates.getDifference and online outbox delivery must all expose the same
|
||||
-- corrected message snapshot. The migration transaction keeps the message
|
||||
-- pts, user watermark, durable event and dispatch task atomic.
|
||||
DO $$
|
||||
DECLARE
|
||||
gift_box record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR gift_box IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id
|
||||
FROM public.message_boxes
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND jsonb_typeof(media #> '{service_action,star_gift,upgrade_price_stars}') = 'number'
|
||||
AND (media #>> '{service_action,star_gift,upgrade_price_stars}')::bigint > 0
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (gift_box.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = gift_box.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET pts = next_pts
|
||||
WHERE owner_user_id = gift_box.owner_user_id
|
||||
AND box_id = gift_box.box_id;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id,
|
||||
pts,
|
||||
pts_count,
|
||||
date,
|
||||
event_type,
|
||||
message_box_id,
|
||||
peer_type,
|
||||
peer_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id,
|
||||
next_pts,
|
||||
1,
|
||||
event_date,
|
||||
'edit_message',
|
||||
gift_box.box_id,
|
||||
gift_box.peer_type,
|
||||
gift_box.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id,
|
||||
pts,
|
||||
event_type,
|
||||
exclude_auth_key_id,
|
||||
exclude_session_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id,
|
||||
next_pts,
|
||||
'edit_message',
|
||||
0,
|
||||
0
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
1
deploy/migrations/0102_star_gift_purchase_forms.down.sql
Normal file
1
deploy/migrations/0102_star_gift_purchase_forms.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_purchase_forms;
|
||||
23
deploy/migrations/0102_star_gift_purchase_forms.up.sql
Normal file
23
deploy/migrations/0102_star_gift_purchase_forms.up.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
CREATE TABLE public.star_gift_purchase_forms (
|
||||
buyer_user_id bigint NOT NULL,
|
||||
form_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
|
||||
revision_id bigint NOT NULL REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT,
|
||||
recipient_peer_type text NOT NULL,
|
||||
recipient_peer_id bigint NOT NULL,
|
||||
include_upgrade boolean DEFAULT false NOT NULL,
|
||||
hide_name boolean DEFAULT false NOT NULL,
|
||||
message text DEFAULT '' NOT NULL,
|
||||
charge_stars bigint NOT NULL,
|
||||
issued_at integer NOT NULL,
|
||||
expires_at integer NOT NULL,
|
||||
CONSTRAINT star_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id),
|
||||
CONSTRAINT star_gift_purchase_form_shape_check CHECK (
|
||||
buyer_user_id > 0 AND form_id <> 0 AND gift_id > 0 AND revision_id > 0 AND
|
||||
recipient_peer_type IN ('user', 'channel') AND recipient_peer_id > 0 AND
|
||||
charge_stars > 0 AND issued_at > 0 AND expires_at = issued_at + 600 AND
|
||||
char_length(message) <= 128)
|
||||
);
|
||||
|
||||
CREATE INDEX star_gift_purchase_forms_expiry_idx
|
||||
ON public.star_gift_purchase_forms (expires_at, buyer_user_id, form_id);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- Projection repair events are durable account history and are intentionally
|
||||
-- not erased on rollback. Only remove the replay receipt column.
|
||||
ALTER TABLE public.star_gift_upgrade_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_source_edit_pts_check,
|
||||
DROP COLUMN IF EXISTS source_edit_pts;
|
||||
191
deploy/migrations/0103_star_gift_upgrade_message_links.up.sql
Normal file
191
deploy/migrations/0103_star_gift_upgrade_message_links.up.sql
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
-- A user-owned upgraded gift has one stable protocol identity: the original
|
||||
-- gift service-message id. The unique service message points back to it via
|
||||
-- saved_id, while the original message points forward to the box-local unique
|
||||
-- service message via upgrade_msg_id. Both projections must be durable pts
|
||||
-- edits so history, live delivery and updates.getDifference agree.
|
||||
|
||||
ALTER TABLE public.star_gift_upgrade_commands
|
||||
ADD COLUMN source_edit_pts integer DEFAULT 0 NOT NULL,
|
||||
ADD CONSTRAINT star_gift_upgrade_command_source_edit_pts_check CHECK (source_edit_pts >= 0);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
gift record;
|
||||
source_root record;
|
||||
upgrade_root record;
|
||||
pair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
repaired_source_media jsonb;
|
||||
repaired_unique_media jsonb;
|
||||
private_media jsonb;
|
||||
BEGIN
|
||||
FOR gift IN
|
||||
SELECT id, owner_peer_id, msg_id, upgrade_msg_id
|
||||
FROM public.peer_star_gifts
|
||||
WHERE owner_peer_type = 'user'
|
||||
AND unique_gift_id IS NOT NULL
|
||||
AND lifecycle_status = 'active'
|
||||
AND msg_id > 0
|
||||
AND upgrade_msg_id > 0
|
||||
ORDER BY id
|
||||
LOOP
|
||||
SELECT private_message_id, message_sender_id
|
||||
INTO STRICT source_root
|
||||
FROM public.message_boxes
|
||||
WHERE owner_user_id = gift.owner_peer_id
|
||||
AND box_id = gift.msg_id
|
||||
AND NOT deleted;
|
||||
|
||||
SELECT private_message_id, message_sender_id
|
||||
INTO STRICT upgrade_root
|
||||
FROM public.message_boxes
|
||||
WHERE owner_user_id = gift.owner_peer_id
|
||||
AND box_id = gift.upgrade_msg_id
|
||||
AND NOT deleted;
|
||||
|
||||
FOR pair IN
|
||||
SELECT source_box.owner_user_id,
|
||||
source_box.box_id AS source_box_id,
|
||||
source_box.peer_type AS source_peer_type,
|
||||
source_box.peer_id AS source_peer_id,
|
||||
source_box.media AS source_media,
|
||||
unique_box.box_id AS unique_box_id,
|
||||
unique_box.peer_type AS unique_peer_type,
|
||||
unique_box.peer_id AS unique_peer_id,
|
||||
unique_box.media AS unique_media
|
||||
FROM public.message_boxes source_box
|
||||
JOIN public.message_boxes unique_box
|
||||
ON unique_box.owner_user_id = source_box.owner_user_id
|
||||
AND unique_box.message_sender_id = upgrade_root.message_sender_id
|
||||
AND unique_box.private_message_id = upgrade_root.private_message_id
|
||||
AND NOT unique_box.deleted
|
||||
WHERE source_box.message_sender_id = source_root.message_sender_id
|
||||
AND source_box.private_message_id = source_root.private_message_id
|
||||
AND NOT source_box.deleted
|
||||
ORDER BY source_box.owner_user_id
|
||||
LOOP
|
||||
IF pair.source_media #>> '{service_action,kind}' <> 'star_gift' THEN
|
||||
RAISE EXCEPTION 'saved gift % source box % has invalid service action', gift.id, pair.source_box_id;
|
||||
END IF;
|
||||
IF pair.unique_media #>> '{service_action,kind}' <> 'star_gift_unique' THEN
|
||||
RAISE EXCEPTION 'saved gift % unique box % has invalid service action', gift.id, pair.unique_box_id;
|
||||
END IF;
|
||||
|
||||
repaired_source_media := jsonb_set(
|
||||
pair.source_media,
|
||||
'{service_action,star_gift,upgrade_msg_id}',
|
||||
to_jsonb(pair.unique_box_id::bigint),
|
||||
true
|
||||
) #- '{service_action,star_gift,can_upgrade}';
|
||||
repaired_unique_media := jsonb_set(
|
||||
pair.unique_media,
|
||||
'{service_action,star_gift_unique,saved_id}',
|
||||
to_jsonb(pair.source_box_id::bigint),
|
||||
true
|
||||
);
|
||||
|
||||
IF pair.source_media IS DISTINCT FROM repaired_source_media THEN
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (pair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = pair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_source_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = pair.owner_user_id
|
||||
AND box_id = pair.source_box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
pair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
pair.source_box_id, pair.source_peer_type, pair.source_peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
|
||||
IF pair.owner_user_id = gift.owner_peer_id THEN
|
||||
UPDATE public.star_gift_upgrade_commands
|
||||
SET source_edit_pts = next_pts
|
||||
WHERE source_saved_gift_id = gift.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF pair.unique_media IS DISTINCT FROM repaired_unique_media THEN
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (pair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = pair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_unique_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = pair.owner_user_id
|
||||
AND box_id = pair.unique_box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
pair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
pair.unique_box_id, pair.unique_peer_type, pair.unique_peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
SELECT media INTO STRICT private_media
|
||||
FROM public.message_boxes
|
||||
WHERE message_sender_id = source_root.message_sender_id
|
||||
AND private_message_id = source_root.private_message_id
|
||||
AND NOT deleted
|
||||
ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id
|
||||
LIMIT 1;
|
||||
UPDATE public.private_messages
|
||||
SET media = private_media
|
||||
WHERE sender_user_id = source_root.message_sender_id
|
||||
AND id = source_root.private_message_id;
|
||||
|
||||
SELECT media INTO STRICT private_media
|
||||
FROM public.message_boxes
|
||||
WHERE message_sender_id = upgrade_root.message_sender_id
|
||||
AND private_message_id = upgrade_root.private_message_id
|
||||
AND NOT deleted
|
||||
ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id
|
||||
LIMIT 1;
|
||||
UPDATE public.private_messages
|
||||
SET media = private_media
|
||||
WHERE sender_user_id = upgrade_root.message_sender_id
|
||||
AND id = upgrade_root.private_message_id;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.star_gift_upgrade_commands
|
||||
WHERE source_saved_gift_id = gift.id AND source_edit_pts <= 0
|
||||
) THEN
|
||||
RAISE EXCEPTION 'saved gift % is missing its owner source edit receipt', gift.id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- This migration repairs invalid persisted capabilities and intentionally does
|
||||
-- not restore them on downgrade: advertising Craft without an official crafted
|
||||
-- model would reintroduce a user-visible operation that can never succeed.
|
||||
SELECT 1;
|
||||
109
deploy/migrations/0104_star_gift_craft_capability.up.sql
Normal file
109
deploy/migrations/0104_star_gift_craft_capability.up.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
-- Craft is a protocol capability, not a generic property of every collectible.
|
||||
-- A non-zero craft_chance_permille is valid only when the immutable official
|
||||
-- collectible revision contains at least one crafted model. Earlier versions
|
||||
-- advertised the default chance for every upgrade, including official sets
|
||||
-- such as Fresh Socks whose snapshot has no crafted model at all.
|
||||
--
|
||||
-- Repair the aggregate and every durable private-message projection together.
|
||||
-- Each visible message edit advances pts and is recoverable through both live
|
||||
-- outbox delivery and updates.getDifference.
|
||||
DO $$
|
||||
DECLARE
|
||||
gift_box record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
repaired_media jsonb;
|
||||
BEGIN
|
||||
UPDATE public.unique_star_gifts unique_gift
|
||||
SET craft_chance_permille = 0,
|
||||
updated_at = now()
|
||||
WHERE unique_gift.craft_chance_permille > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
);
|
||||
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = 0
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
);
|
||||
|
||||
FOR gift_box IN
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
box.media
|
||||
FROM public.message_boxes box
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') = 'number'
|
||||
AND (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
)
|
||||
ORDER BY box.owner_user_id, box.box_id
|
||||
LOOP
|
||||
repaired_media := gift_box.media
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{service_action,star_gift_unique,can_craft_at}';
|
||||
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (gift_box.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = gift_box.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = gift_box.owner_user_id
|
||||
AND box_id = gift_box.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
UPDATE public.private_messages
|
||||
SET media = media
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{service_action,star_gift_unique,can_craft_at}',
|
||||
sender_snapshot = sender_snapshot
|
||||
#- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{message,Media,service_action,star_gift_unique,can_craft_at}'
|
||||
WHERE sender_user_id = gift_box.message_sender_id
|
||||
AND id = gift_box.private_message_id;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
gift_box.box_id, gift_box.peer_type, gift_box.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id, next_pts, 'edit_message', 0, 0
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
ALTER TABLE public.star_gift_craft_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_craft_source_edit_shape_check,
|
||||
DROP COLUMN IF EXISTS source_edit_pts;
|
||||
|
||||
-- Burned/crafted lifecycle facts and emitted edit events are authoritative
|
||||
-- business history. Downgrade intentionally does not resurrect consumed gifts.
|
||||
SELECT 1;
|
||||
195
deploy/migrations/0105_star_gift_craft_projection.up.sql
Normal file
195
deploy/migrations/0105_star_gift_craft_projection.up.sql
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
-- Craft outcomes consume their inputs permanently. Keep the aggregate, every
|
||||
-- visible messageActionStarGiftUnique snapshot, pts/outbox delivery and command
|
||||
-- replay receipt in one state model.
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD COLUMN source_edit_pts integer[] DEFAULT ARRAY[]::integer[] NOT NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
gift_box record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
repaired_media jsonb;
|
||||
BEGIN
|
||||
-- A successful legacy command would require reconstructing the crafted
|
||||
-- model snapshot, not merely flipping flags. Fail fast instead of silently
|
||||
-- fabricating that projection; the development database must be rebuilt or
|
||||
-- repaired explicitly if such a row ever exists.
|
||||
IF EXISTS (SELECT 1 FROM public.star_gift_craft_commands WHERE success) THEN
|
||||
RAISE EXCEPTION 'cannot migrate legacy successful craft command without an exact crafted message projection';
|
||||
END IF;
|
||||
|
||||
-- upgrade_msg_id means the current owner's unique service-message
|
||||
-- projection, not permanently the first upgrade message. Ownership moves
|
||||
-- replace msg_id with the new transfer/resale/offer message; repair rows
|
||||
-- written before that invariant was enforced.
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET upgrade_msg_id = saved_gift.msg_id
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.unique_gift_id IS NOT NULL
|
||||
AND saved_gift.msg_id > 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND box.box_id = saved_gift.msg_id
|
||||
AND NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint = saved_gift.unique_gift_id
|
||||
);
|
||||
|
||||
UPDATE public.peer_star_gifts
|
||||
SET upgrade_msg_id = 0
|
||||
WHERE owner_peer_type = 'channel'
|
||||
AND unique_gift_id IS NOT NULL
|
||||
AND upgrade_msg_id <> 0;
|
||||
|
||||
UPDATE public.unique_star_gifts
|
||||
SET burned = true,
|
||||
craft_chance_permille = 0,
|
||||
offer_min_stars = 0,
|
||||
updated_at = now()
|
||||
WHERE burned;
|
||||
|
||||
UPDATE public.peer_star_gifts
|
||||
SET lifecycle_status = 'burned',
|
||||
unsaved = true,
|
||||
pinned_order = 0,
|
||||
transfer_stars = 0,
|
||||
can_export_at = 0,
|
||||
can_transfer_at = 0,
|
||||
can_resell_at = 0,
|
||||
drop_original_details_stars = 0,
|
||||
can_craft_at = 0
|
||||
WHERE lifecycle_status = 'burned';
|
||||
|
||||
FOR gift_box IN
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
box.media
|
||||
FROM public.message_boxes box
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND unique_gift.burned
|
||||
AND (
|
||||
COALESCE((box.media #>> '{service_action,star_gift_unique,gift,Burned}')::boolean, false) = false
|
||||
OR COALESCE((box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer, 0) <> 0
|
||||
OR box.media #> '{service_action,star_gift_unique,saved}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
)
|
||||
ORDER BY box.owner_user_id, box.box_id
|
||||
LOOP
|
||||
repaired_media := jsonb_set(
|
||||
jsonb_set(
|
||||
gift_box.media
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{service_action,star_gift_unique,saved}'
|
||||
#- '{service_action,star_gift_unique,can_export_at}'
|
||||
#- '{service_action,star_gift_unique,transfer_stars}'
|
||||
#- '{service_action,star_gift_unique,resale_amount}'
|
||||
#- '{service_action,star_gift_unique,can_transfer_at}'
|
||||
#- '{service_action,star_gift_unique,can_resell_at}'
|
||||
#- '{service_action,star_gift_unique,drop_original_details_stars}'
|
||||
#- '{service_action,star_gift_unique,can_craft_at}',
|
||||
'{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
|
||||
'{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true);
|
||||
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (gift_box.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = gift_box.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = gift_box.owner_user_id
|
||||
AND box_id = gift_box.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
UPDATE public.private_messages
|
||||
SET media = jsonb_set(
|
||||
jsonb_set(
|
||||
media
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{service_action,star_gift_unique,saved}'
|
||||
#- '{service_action,star_gift_unique,can_export_at}'
|
||||
#- '{service_action,star_gift_unique,transfer_stars}'
|
||||
#- '{service_action,star_gift_unique,resale_amount}'
|
||||
#- '{service_action,star_gift_unique,can_transfer_at}'
|
||||
#- '{service_action,star_gift_unique,can_resell_at}'
|
||||
#- '{service_action,star_gift_unique,drop_original_details_stars}'
|
||||
#- '{service_action,star_gift_unique,can_craft_at}',
|
||||
'{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
|
||||
'{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true),
|
||||
sender_snapshot = jsonb_set(
|
||||
jsonb_set(
|
||||
sender_snapshot
|
||||
#- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
#- '{message,Media,service_action,star_gift_unique,saved}'
|
||||
#- '{message,Media,service_action,star_gift_unique,can_export_at}'
|
||||
#- '{message,Media,service_action,star_gift_unique,transfer_stars}'
|
||||
#- '{message,Media,service_action,star_gift_unique,resale_amount}'
|
||||
#- '{message,Media,service_action,star_gift_unique,can_transfer_at}'
|
||||
#- '{message,Media,service_action,star_gift_unique,can_resell_at}'
|
||||
#- '{message,Media,service_action,star_gift_unique,drop_original_details_stars}'
|
||||
#- '{message,Media,service_action,star_gift_unique,can_craft_at}',
|
||||
'{message,Media,service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
|
||||
'{message,Media,service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true)
|
||||
WHERE sender_user_id = gift_box.message_sender_id
|
||||
AND id = gift_box.private_message_id;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
gift_box.box_id, gift_box.peer_type, gift_box.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (
|
||||
gift_box.owner_user_id, next_pts, 'edit_message', 0, 0
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
UPDATE public.star_gift_craft_commands command
|
||||
SET source_edit_pts = (
|
||||
SELECT array_agg(box.pts ORDER BY input.ordinality)::integer[] AS pts
|
||||
FROM unnest(command.input_unique_gift_ids) WITH ORDINALITY AS input(unique_gift_id, ordinality)
|
||||
JOIN public.unique_star_gifts unique_gift ON unique_gift.id = input.unique_gift_id
|
||||
JOIN public.peer_star_gifts saved_gift ON saved_gift.id = unique_gift.source_saved_gift_id
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = command.user_id
|
||||
AND box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT box.deleted
|
||||
)
|
||||
WHERE NOT command.success;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_craft_commands
|
||||
WHERE cardinality(source_edit_pts) <> cardinality(input_unique_gift_ids)
|
||||
OR array_position(source_edit_pts, 0) IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft command is missing an exact source message edit receipt';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD CONSTRAINT star_gift_craft_source_edit_shape_check CHECK (
|
||||
cardinality(source_edit_pts) = cardinality(input_unique_gift_ids)
|
||||
AND array_position(source_edit_pts, 0) IS NULL
|
||||
);
|
||||
|
|
@ -4,15 +4,18 @@ import (
|
|||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -25,6 +28,7 @@ const (
|
|||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
ActionImportStarGift = "gifts.import"
|
||||
ActionImportOfficialStarGift = "gifts.official.import"
|
||||
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
|
||||
ActionSetStarGiftEnabled = "gifts.set_enabled"
|
||||
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
|
||||
|
|
@ -94,7 +98,9 @@ type MessagesService interface {
|
|||
|
||||
type GiftsService interface {
|
||||
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
|
||||
PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
|
||||
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
|
||||
CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error)
|
||||
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
|
||||
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
|
||||
AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||
|
|
@ -103,6 +109,11 @@ type GiftsService interface {
|
|||
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
}
|
||||
|
||||
type OfficialGiftsSource interface {
|
||||
List(ctx context.Context) ([]officialgifts.GiftSummary, error)
|
||||
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
Commands CommandRepository
|
||||
Restrictions RestrictionStore
|
||||
|
|
@ -116,6 +127,7 @@ type Dependencies struct {
|
|||
ChannelNotifier ChannelNotifier
|
||||
Messages MessagesService
|
||||
Gifts GiftsService
|
||||
OfficialGifts OfficialGiftsSource
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +144,7 @@ type Service struct {
|
|||
channelNotifier ChannelNotifier
|
||||
messages MessagesService
|
||||
gifts GiftsService
|
||||
officialGifts OfficialGiftsSource
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +190,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Gifts != nil {
|
||||
s.gifts = deps.Gifts
|
||||
}
|
||||
if deps.OfficialGifts != nil {
|
||||
s.officialGifts = deps.OfficialGifts
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
|
|
@ -219,6 +235,23 @@ type ImportStarGiftRequest struct {
|
|||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
type ImportOfficialStarGiftRequest struct {
|
||||
CommandMeta
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
||||
SupplyTotal int `json:"supply_total,omitempty"`
|
||||
SlugPrefix string `json:"slug_prefix,omitempty"`
|
||||
ManifestSHA256 string `json:"manifest_sha256,omitempty"`
|
||||
AssetSHA256 []string `json:"asset_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type SetStarGiftEnabledRequest struct {
|
||||
CommandMeta
|
||||
GiftID int64 `json:"gift_id"`
|
||||
|
|
@ -796,8 +829,9 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
|
|||
req.ContentSHA = hex.EncodeToString(animation.SHA256)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars,
|
||||
"convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder,
|
||||
"gift_id": strconv.FormatInt(req.GiftID, 10), "title": strings.TrimSpace(req.Title),
|
||||
"stars": strconv.FormatInt(req.Stars, 10), "convert_stars": strconv.FormatInt(req.ConvertStars, 10),
|
||||
"enabled": req.Enabled, "sort_order": req.SortOrder,
|
||||
"source_format": animation.SourceFormat, "source_name": animation.SourceName,
|
||||
"sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height,
|
||||
"frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON),
|
||||
|
|
@ -813,13 +847,232 @@ func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest)
|
|||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["gift_id"] = entry.Gift.ID
|
||||
details["revision_id"] = entry.Gift.RevisionID
|
||||
details["gift_id"] = strconv.FormatInt(entry.Gift.ID, 10)
|
||||
details["revision_id"] = strconv.FormatInt(entry.Gift.RevisionID, 10)
|
||||
details["revision"] = entry.Revision
|
||||
return CommandResult{Message: "star gift imported", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
if s == nil || s.officialGifts == nil {
|
||||
return nil, officialgifts.ErrUnavailable
|
||||
}
|
||||
return s.officialGifts.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
|
||||
if s == nil || s.officialGifts == nil || s.gifts == nil {
|
||||
return nil, false, officialgifts.ErrUnavailable
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(sourceGiftID), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, false, officialgifts.ErrNotFound
|
||||
}
|
||||
bundle, err := s.officialGifts.Bundle(ctx, id, false)
|
||||
if errors.Is(err, officialgifts.ErrNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return animation.JSON, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportOfficialStarGift(ctx context.Context, req ImportOfficialStarGiftRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifts == nil || s.officialGifts == nil {
|
||||
return CommandResult{}, fmt.Errorf("official star gift importer is not configured")
|
||||
}
|
||||
sourceID, err := strconv.ParseInt(strings.TrimSpace(req.SourceGiftID), 10, 64)
|
||||
if err != nil || sourceID <= 0 || req.GiftID < 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
||||
return CommandResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
bundle, err := s.officialGifts.Bundle(ctx, sourceID, req.IncludeCollectible)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if req.Title = strings.TrimSpace(req.Title); req.Title == "" {
|
||||
req.Title = strings.TrimSpace(bundle.Gift.Title)
|
||||
if req.Title == "" {
|
||||
req.Title = "Official gift " + req.SourceGiftID
|
||||
}
|
||||
}
|
||||
if req.Stars <= 0 {
|
||||
req.Stars = bundle.Gift.Stars
|
||||
}
|
||||
if req.ConvertStars < 0 || req.ConvertStars > req.Stars || len([]rune(req.Title)) > domain.MaxStarGiftTitleRunes {
|
||||
return CommandResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if req.UpgradeStars <= 0 {
|
||||
req.UpgradeStars = bundle.Gift.UpgradeStars
|
||||
}
|
||||
if req.SupplyTotal <= 0 {
|
||||
req.SupplyTotal = bundle.Gift.AvailabilityTotal
|
||||
}
|
||||
if req.SlugPrefix = strings.ToLower(strings.TrimSpace(req.SlugPrefix)); req.SlugPrefix == "" {
|
||||
req.SlugPrefix = "official-" + req.SourceGiftID
|
||||
}
|
||||
|
||||
baseAnimation, err := s.gifts.PrepareOfficialAnimation(bundle.BaseDocument.FileName, bundle.BaseDocument.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official gift animation: %w", err)
|
||||
}
|
||||
assetHashes := []string{bundle.BaseDocument.SHA256}
|
||||
rarityCounts := map[string]int{}
|
||||
var background *domain.StarGiftBackground
|
||||
if bundle.Gift.Background != nil {
|
||||
background = &domain.StarGiftBackground{
|
||||
CenterColor: bundle.Gift.Background.CenterColor,
|
||||
EdgeColor: bundle.Gift.Background.EdgeColor,
|
||||
TextColor: bundle.Gift.Background.TextColor,
|
||||
}
|
||||
}
|
||||
var collectible *domain.StarGiftCollectibleWrite
|
||||
if req.IncludeCollectible {
|
||||
if bundle.Collectible == nil {
|
||||
return CommandResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
models := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Models))
|
||||
for index, value := range bundle.Collectible.Models {
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official model %q: %w", value.Name, err)
|
||||
}
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
models = append(models, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleModel,
|
||||
Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille,
|
||||
Crafted: value.Crafted, OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
|
||||
assetHashes = append(assetHashes, value.Document.SHA256)
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
patterns := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Patterns))
|
||||
for index, value := range bundle.Collectible.Patterns {
|
||||
animation, err := s.gifts.PrepareOfficialAnimation(value.Document.FileName, value.Document.Data)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("prepare official pattern %q: %w", value.Name, err)
|
||||
}
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
patterns = append(patterns, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectiblePattern,
|
||||
Name: strings.TrimSpace(value.Name), RarityKind: rarityKind, RarityPermille: permille,
|
||||
OfficialDocumentID: value.DocumentID, SortOrder: index, Animation: &animation})
|
||||
assetHashes = append(assetHashes, value.Document.SHA256)
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(bundle.Collectible.Backdrops))
|
||||
for index, value := range bundle.Collectible.Backdrops {
|
||||
rarityKind, permille, err := officialRarity(value.Rarity)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop,
|
||||
Name: strings.TrimSpace(value.Name), BackdropID: value.BackdropID, CenterColor: value.CenterColor,
|
||||
EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, TextColor: value.TextColor,
|
||||
RarityKind: rarityKind, RarityPermille: permille, SortOrder: index})
|
||||
rarityCounts[string(rarityKind)]++
|
||||
}
|
||||
collectible = &domain.StarGiftCollectibleWrite{GiftID: req.GiftID, UpgradeStars: req.UpgradeStars,
|
||||
SupplyTotal: req.SupplyTotal, SlugPrefix: req.SlugPrefix, Models: models, Patterns: patterns, Backdrops: backdrops,
|
||||
Actor: req.Actor, CommandID: req.CommandID, OfficialGiftID: sourceID,
|
||||
SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...)}
|
||||
validation := *collectible
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = 1
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
}
|
||||
req.ManifestSHA256 = hex.EncodeToString(bundle.ManifestSHA256)
|
||||
sort.Strings(assetHashes)
|
||||
req.AssetSHA256 = assetHashes
|
||||
write := domain.StarGiftCatalogBundleWrite{Catalog: domain.StarGiftCatalogWrite{
|
||||
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
|
||||
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: baseAnimation, Actor: req.Actor, CommandID: req.CommandID,
|
||||
OfficialGiftID: sourceID, SourceManifestSHA256: append([]byte(nil), bundle.ManifestSHA256...),
|
||||
OfficialSourceJSON: append([]byte(nil), bundle.SourceJSON...),
|
||||
// The snapshot describes Telegram's global market, not this deployment's
|
||||
// inventory. Keep the complete source JSON as provenance, while publishing
|
||||
// regular official imports as a fresh, locally purchasable catalog entry.
|
||||
// Local resale counters and sale dates are derived by lifecycle writes.
|
||||
Limited: false, SoldOut: false, Birthday: bundle.Gift.Birthday,
|
||||
RequirePremium: bundle.Gift.RequirePremium, LimitedPerUser: bundle.Gift.LimitedPerUser,
|
||||
PeerColorAvailable: bundle.Gift.PeerColorAvailable, Auction: bundle.Gift.Auction,
|
||||
AvailabilityRemains: 0, AvailabilityTotal: 0,
|
||||
AvailabilityResale: 0, FirstSaleDate: 0,
|
||||
LastSaleDate: 0, ResellMinStars: 0,
|
||||
PerUserTotal: bundle.Gift.PerUserTotal, LockedUntilDate: bundle.Gift.LockedUntilDate,
|
||||
AuctionSlug: bundle.Gift.AuctionSlug, GiftsPerRound: bundle.Gift.GiftsPerRound,
|
||||
AuctionStartDate: bundle.Gift.AuctionStartDate, UpgradeVariants: bundle.Gift.UpgradeVariants,
|
||||
Background: background,
|
||||
}, Collectible: collectible}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionImportOfficialStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"source_gift_id": req.SourceGiftID, "gift_id": strconv.FormatInt(req.GiftID, 10),
|
||||
"manifest_sha256": req.ManifestSHA256, "title": req.Title, "stars": strconv.FormatInt(req.Stars, 10),
|
||||
"convert_stars": strconv.FormatInt(req.ConvertStars, 10), "include_collectible": req.IncludeCollectible,
|
||||
"verified_asset_count": len(assetHashes), "rarity_counts": rarityCounts,
|
||||
"official_limited": bundle.Gift.Limited, "official_sold_out": bundle.Gift.SoldOut,
|
||||
"official_auction": bundle.Gift.Auction, "official_birthday": bundle.Gift.Birthday,
|
||||
"official_require_premium": bundle.Gift.RequirePremium,
|
||||
"official_availability_remains": bundle.Gift.AvailabilityRemains,
|
||||
"official_availability_total": bundle.Gift.AvailabilityTotal,
|
||||
"official_availability_resale": bundle.Gift.AvailabilityResale,
|
||||
}
|
||||
if bundle.Collectible != nil {
|
||||
details["models"] = len(bundle.Collectible.Models)
|
||||
details["patterns"] = len(bundle.Collectible.Patterns)
|
||||
details["backdrops"] = len(bundle.Collectible.Backdrops)
|
||||
crafted := 0
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
if model.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
details["crafted_models"] = crafted
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "official star gift bundle validated", Details: details}, nil
|
||||
}
|
||||
result, err := s.gifts.CreateCatalogBundle(ctx, write)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["gift_id"] = strconv.FormatInt(result.Catalog.Gift.ID, 10)
|
||||
details["catalog_revision_id"] = strconv.FormatInt(result.Catalog.Gift.RevisionID, 10)
|
||||
if result.Collectible != nil {
|
||||
details["collectible_revision_id"] = strconv.FormatInt(result.Collectible.ID, 10)
|
||||
details["collectible_revision"] = result.Collectible.Revision
|
||||
}
|
||||
return CommandResult{Message: "official star gift bundle imported", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func officialRarity(value officialgifts.Rarity) (domain.StarGiftAttributeRarityKind, int, error) {
|
||||
kind := domain.StarGiftAttributeRarityKind(strings.ToLower(strings.TrimSpace(value.Kind)))
|
||||
if !kind.Valid() {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if kind == domain.StarGiftRarityPermille {
|
||||
if value.Permille == nil || *value.Permille <= 0 || *value.Permille > 1000 {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return kind, *value.Permille, nil
|
||||
}
|
||||
if value.Permille != nil {
|
||||
return "", 0, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return kind, 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifts == nil {
|
||||
return CommandResult{}, fmt.Errorf("star gift service is not configured")
|
||||
|
|
@ -833,7 +1086,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
|
|||
}
|
||||
uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256)
|
||||
attributes[i] = domain.StarGiftCollectibleAttribute{
|
||||
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille,
|
||||
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: uploads[i].RarityPermille,
|
||||
SortOrder: uploads[i].SortOrder, Animation: &animation,
|
||||
}
|
||||
}
|
||||
|
|
@ -852,7 +1106,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
|
|||
backdrops[i] = domain.StarGiftCollectibleAttribute{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID,
|
||||
CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor,
|
||||
TextColor: backdrop.TextColor, RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder,
|
||||
TextColor: backdrop.TextColor, RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder,
|
||||
}
|
||||
}
|
||||
write := domain.StarGiftCollectibleWrite{
|
||||
|
|
@ -873,7 +1128,8 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
|
|||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal,
|
||||
"gift_id": strconv.FormatInt(req.GiftID, 10), "upgrade_stars": strconv.FormatInt(req.UpgradeStars, 10),
|
||||
"supply_total": req.SupplyTotal,
|
||||
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models),
|
||||
"patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops),
|
||||
}
|
||||
|
|
@ -884,7 +1140,7 @@ func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishSt
|
|||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["revision_id"] = revision.ID
|
||||
details["revision_id"] = strconv.FormatInt(revision.ID, 10)
|
||||
details["revision"] = revision.Revision
|
||||
details["published"] = revision.Published
|
||||
return CommandResult{Message: "star gift collectible pool published", Details: details}, nil
|
||||
|
|
@ -907,7 +1163,7 @@ func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabled
|
|||
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"gift_id": req.GiftID, "enabled": req.Enabled}
|
||||
details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "enabled": req.Enabled}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "star gift state change validated", Details: details}, nil
|
||||
}
|
||||
|
|
@ -922,7 +1178,7 @@ func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortO
|
|||
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"gift_id": req.GiftID, "sort_order": req.SortOrder}
|
||||
details := map[string]any{"gift_id": strconv.FormatInt(req.GiftID, 10), "sort_order": req.SortOrder}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "star gift order change validated", Details: details}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
|
|
@ -710,7 +712,7 @@ func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
|
|||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
|
||||
result, err := svc.ImportStarGift(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(22) {
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
|
@ -747,12 +749,88 @@ func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
|||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
|
||||
result, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(33) || result.Details["published"] != true {
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGiftsService struct{ createCalls int }
|
||||
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
|
||||
permille := 922
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x42, 32),
|
||||
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
|
||||
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
|
||||
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
|
||||
},
|
||||
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
|
||||
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}}},
|
||||
Backdrops: []officialgifts.Backdrop{{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}}},
|
||||
},
|
||||
}}
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
|
||||
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
|
||||
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
|
||||
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
|
||||
result, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
models := gifts.lastBundle.Collectible.Models
|
||||
if len(models) != 2 || !models[1].Crafted || models[1].RarityKind != domain.StarGiftRarityLegendary || models[1].RarityPermille != 0 ||
|
||||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
|
||||
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
|
||||
}
|
||||
catalog := gifts.lastBundle.Catalog
|
||||
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
|
||||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
|
||||
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
|
||||
}
|
||||
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
|
||||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
|
||||
t.Fatalf("official provenance was not preserved: %+v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesOf(value byte, count int) []byte {
|
||||
out := make([]byte, count)
|
||||
for i := range out {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
|
||||
|
||||
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
|
||||
if giftID != f.bundle.Gift.ID {
|
||||
return officialgifts.Bundle{}, officialgifts.ErrNotFound
|
||||
}
|
||||
out := f.bundle
|
||||
if !include {
|
||||
out.Collectible = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeGiftsService struct {
|
||||
createCalls int
|
||||
lastBundle domain.StarGiftCatalogBundleWrite
|
||||
}
|
||||
|
||||
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
|
|
@ -761,10 +839,24 @@ func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.St
|
|||
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return f.PrepareAnimation(name, data)
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
f.createCalls++
|
||||
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
|
||||
f.createCalls++
|
||||
f.lastBundle = write
|
||||
entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1}
|
||||
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
|
||||
if write.Collectible != nil {
|
||||
revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true}
|
||||
result.Collectible = &revision
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -32,6 +34,9 @@ type Service interface {
|
|||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
|
||||
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
|
||||
OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error)
|
||||
OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error)
|
||||
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
|
||||
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
|
||||
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
|
||||
|
|
@ -95,6 +100,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
|
||||
mux.HandleFunc("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts))
|
||||
mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift))
|
||||
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
|
||||
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
|
||||
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
|
||||
|
|
@ -221,6 +229,60 @@ func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.svc.OfficialStarGifts(r.Context())
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, officialgifts.ErrUnavailable) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, officialStarGiftListItem(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gifts": result})
|
||||
}
|
||||
|
||||
func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any {
|
||||
return map[string]any{
|
||||
"source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title,
|
||||
"stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10),
|
||||
"upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10),
|
||||
"availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut,
|
||||
"model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount,
|
||||
"crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(),
|
||||
"document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "official gift animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportOfficialStarGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportOfficialStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
|
|
@ -338,7 +400,7 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
|
||||
|
|
@ -347,7 +409,9 @@ func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Reque
|
|||
func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
|
||||
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
|
||||
result := map[string]any{
|
||||
"id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille,
|
||||
"id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind,
|
||||
"rarity_permille": value.RarityPermille, "crafted": value.Crafted,
|
||||
"official_document_id": strconv.FormatInt(value.OfficialDocumentID, 10),
|
||||
"sort_order": value.SortOrder, "kind": value.Kind,
|
||||
}
|
||||
if value.Animation != nil {
|
||||
|
|
@ -371,7 +435,8 @@ func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[strin
|
|||
return result
|
||||
}
|
||||
return map[string]any{
|
||||
"found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars,
|
||||
"found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision,
|
||||
"upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10),
|
||||
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
|
||||
"slug_prefix": preview.SlugPrefix,
|
||||
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
func TestAdminAPIRequiresBearerToken(t *testing.T) {
|
||||
|
|
@ -151,6 +152,49 @@ func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
got := collectiblePreviewResponse(domain.StarGiftUpgradePreview{
|
||||
GiftID: maxInt64,
|
||||
UpgradeStars: maxInt64,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
ID: maxInt64,
|
||||
Kind: domain.StarGiftCollectibleModel,
|
||||
Name: "Exact",
|
||||
RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000,
|
||||
OfficialDocumentID: maxInt64,
|
||||
}},
|
||||
})
|
||||
if got["gift_id"] != "9223372036854775807" || got["upgrade_stars"] != "9223372036854775807" {
|
||||
t.Fatalf("preview ids = %#v", got)
|
||||
}
|
||||
models, ok := got["models"].([]map[string]any)
|
||||
if !ok || len(models) != 1 {
|
||||
t.Fatalf("preview models = %#v", got["models"])
|
||||
}
|
||||
if models[0]["id"] != "9223372036854775807" || models[0]["official_document_id"] != "9223372036854775807" {
|
||||
t.Fatalf("preview model ids = %#v", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialStarGiftListItemExposesExplicitCapabilities(t *testing.T) {
|
||||
item := officialStarGiftListItem(officialgifts.GiftSummary{
|
||||
ID: 9223372036854775807, Title: "Fresh Socks", Stars: 25, ConvertStars: 10, UpgradeStars: 50,
|
||||
ModelCount: 10, PatternCount: 20, BackdropCount: 30, CraftedModelCount: 2,
|
||||
})
|
||||
if item["source_gift_id"] != "9223372036854775807" || item["title"] != "Fresh Socks" ||
|
||||
item["can_upgrade"] != true || item["can_craft"] != true {
|
||||
t.Fatalf("official gift item = %#v", item)
|
||||
}
|
||||
item = officialStarGiftListItem(officialgifts.GiftSummary{
|
||||
ID: 1, UpgradeStars: 0, ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1,
|
||||
})
|
||||
if item["can_upgrade"] != false || item["can_craft"] != false {
|
||||
t.Fatalf("unavailable official gift capabilities = %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
type captureFreezeService struct {
|
||||
|
|
@ -219,6 +263,18 @@ func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftReq
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -24,6 +27,8 @@ type BlobBackend interface {
|
|||
type Service struct {
|
||||
store store.StarGiftStore
|
||||
upgrades store.StarGiftUpgradeStore
|
||||
lifecycle store.StarGiftLifecycleStore
|
||||
withdrawal StarGiftWithdrawalProvider
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
|
||||
|
|
@ -32,16 +37,53 @@ type Service struct {
|
|||
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")
|
||||
}
|
||||
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 entry, nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
|
|
@ -225,7 +333,16 @@ 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 {
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
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 {
|
||||
|
|
@ -264,14 +381,6 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St
|
|||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := materialize(write.Models); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if err := materialize(write.Patterns); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
return s.PublishCollectibleRevision(ctx, write)
|
||||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,11 @@ type Config struct {
|
|||
WebPagePreviewRatePerMin int
|
||||
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
|
||||
LangPackSeedDir string
|
||||
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
|
||||
OfficialGiftsDir string
|
||||
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
|
||||
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
|
||||
StarGiftTONStartingGrant int64
|
||||
// BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。
|
||||
BlobDir string
|
||||
// StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。
|
||||
|
|
@ -311,6 +316,21 @@ type Config struct {
|
|||
PremiumSweepInterval time.Duration
|
||||
// PremiumSweepBatch 是单次到期清理的最大行数。
|
||||
PremiumSweepBatch int
|
||||
// StarGiftSweepInterval drives offer expiry/refunds, auction rounds and their
|
||||
// durable notification/delivery outboxes. It is entirely server-local.
|
||||
StarGiftSweepInterval time.Duration
|
||||
// StarGiftSweepBatch bounds rows/aggregates claimed by one sweep.
|
||||
StarGiftSweepBatch int
|
||||
StarGiftTransferStars int64
|
||||
StarGiftDropOriginalDetailsStars int64
|
||||
StarGiftOfferMinStars int
|
||||
StarGiftStarsProceedsPermille int
|
||||
StarGiftTONProceedsPermille int
|
||||
StarGiftExportDelay time.Duration
|
||||
StarGiftTransferDelay time.Duration
|
||||
StarGiftResellDelay time.Duration
|
||||
StarGiftCraftDelay time.Duration
|
||||
StarGiftCraftChancePermille int
|
||||
|
||||
// GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
|
||||
// 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。
|
||||
|
|
@ -491,6 +511,8 @@ func Load() (Config, error) {
|
|||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"),
|
||||
StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
|
||||
|
|
@ -561,6 +583,18 @@ func Load() (Config, error) {
|
|||
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
|
||||
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
|
||||
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
|
||||
StarGiftSweepInterval: envDurationOr("TELESRV_STARGIFT_SWEEP_INTERVAL", 15*time.Second),
|
||||
StarGiftSweepBatch: envIntOr("TELESRV_STARGIFT_SWEEP_BATCH", 1000),
|
||||
StarGiftTransferStars: int64(envIntOr("TELESRV_STARGIFT_TRANSFER_STARS", 25)),
|
||||
StarGiftDropOriginalDetailsStars: int64(envIntOr("TELESRV_STARGIFT_DROP_DETAILS_STARS", 25)),
|
||||
StarGiftOfferMinStars: envIntOr("TELESRV_STARGIFT_OFFER_MIN_STARS", 1),
|
||||
StarGiftStarsProceedsPermille: envIntOr("TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE", 1000),
|
||||
StarGiftTONProceedsPermille: envIntOr("TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE", 1000),
|
||||
StarGiftExportDelay: envDurationOr("TELESRV_STARGIFT_EXPORT_DELAY", 0),
|
||||
StarGiftTransferDelay: envDurationOr("TELESRV_STARGIFT_TRANSFER_DELAY", 0),
|
||||
StarGiftResellDelay: envDurationOr("TELESRV_STARGIFT_RESELL_DELAY", 0),
|
||||
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
|
||||
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
|
||||
|
||||
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
|
||||
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second),
|
||||
|
|
@ -592,9 +626,40 @@ func Load() (Config, error) {
|
|||
if err := validateRPCResultCacheConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateStarGiftConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateStarGiftConfig(cfg Config) error {
|
||||
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")
|
||||
}
|
||||
if cfg.StarGiftTONStartingGrant < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_TON_STARTING_GRANT must be non-negative")
|
||||
}
|
||||
if cfg.StarGiftTransferStars < 0 || cfg.StarGiftDropOriginalDetailsStars < 0 || cfg.StarGiftOfferMinStars < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_TRANSFER_STARS, TELESRV_STARGIFT_DROP_DETAILS_STARS and TELESRV_STARGIFT_OFFER_MIN_STARS must be non-negative")
|
||||
}
|
||||
if cfg.StarGiftExportDelay < 0 || cfg.StarGiftTransferDelay < 0 || cfg.StarGiftResellDelay < 0 || cfg.StarGiftCraftDelay < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays must be non-negative")
|
||||
}
|
||||
const maxProtocolDelay = time.Duration(1<<31-1) * time.Second
|
||||
if cfg.StarGiftExportDelay > maxProtocolDelay || cfg.StarGiftTransferDelay > maxProtocolDelay ||
|
||||
cfg.StarGiftResellDelay > maxProtocolDelay || cfg.StarGiftCraftDelay > maxProtocolDelay {
|
||||
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays exceed the protocol int32 date range")
|
||||
}
|
||||
if cfg.StarGiftCraftChancePermille < 0 || cfg.StarGiftCraftChancePermille > 1000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE must be 0..1000")
|
||||
}
|
||||
if cfg.StarGiftStarsProceedsPermille < 0 || cfg.StarGiftStarsProceedsPermille > 1000 ||
|
||||
cfg.StarGiftTONProceedsPermille < 0 || cfg.StarGiftTONProceedsPermille > 1000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_*_PROCEEDS_PERMILLE must be 0..1000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
|
||||
|
||||
func validateRPCResultCacheConfig(cfg Config) error {
|
||||
|
|
|
|||
|
|
@ -519,6 +519,19 @@ func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) {
|
||||
cfg := Config{
|
||||
StarGiftSweepInterval: time.Second,
|
||||
StarGiftSweepBatch: 1,
|
||||
StarGiftTONStartingGrant: -1,
|
||||
StarGiftStarsProceedsPermille: 1000,
|
||||
StarGiftTONProceedsPermille: 1000,
|
||||
}
|
||||
if err := validateStarGiftConfig(cfg); err == nil {
|
||||
t.Fatal("negative internal TON starting grant was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
|
|
|
|||
|
|
@ -550,6 +550,9 @@ const (
|
|||
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
|
||||
// ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。
|
||||
ChannelActionStarGift ChannelMessageActionType = "star_gift"
|
||||
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUnique:collectible
|
||||
// 升级、转赠等所有权变更只进入 Recent Actions,不伪造频道历史/pts。
|
||||
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
|
||||
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。
|
||||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
)
|
||||
|
|
@ -584,6 +587,7 @@ type ChannelMessageAction struct {
|
|||
TodoItems []MessageTodoItem
|
||||
// StarGift 仅 star_gift 服务消息使用。
|
||||
StarGift *MessageStarGiftAction
|
||||
StarGiftUnique *MessageStarGiftUniqueAction
|
||||
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
|
||||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
|
|
|
|||
|
|
@ -565,6 +565,8 @@ const (
|
|||
// immutable collectible snapshot is carried by the service message so an
|
||||
// exact replay/difference never depends on mutable catalog state.
|
||||
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
|
||||
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
|
||||
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
|
||||
)
|
||||
|
||||
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
|
||||
|
|
@ -622,6 +624,8 @@ type MessageServiceAction struct {
|
|||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
|
|
@ -642,13 +646,21 @@ type MessageStarGiftAction struct {
|
|||
Converted bool `json:"converted,omitempty"`
|
||||
CanUpgrade bool `json:"can_upgrade,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
|
||||
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
|
||||
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
|
||||
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
|
||||
// outer messageActionStarGift and is only the amount already prepaid by the
|
||||
// sender. TDesktop uses these two fields to choose the paid vs free flow.
|
||||
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
||||
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
|
||||
GiftMsgID int `json:"gift_msg_id,omitempty"`
|
||||
GiftNum int `json:"gift_num,omitempty"`
|
||||
AuctionAcquired bool `json:"auction_acquired,omitempty"`
|
||||
To Peer `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade
|
||||
// service message. Commercial transfer/resale/export fields are intentionally
|
||||
// absent from the collectibles mainline.
|
||||
type MessageStarGiftUniqueAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
|
|
@ -657,6 +669,32 @@ type MessageStarGiftUniqueAction struct {
|
|||
Upgrade bool `json:"upgrade,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
Transferred bool `json:"transferred,omitempty"`
|
||||
Refunded bool `json:"refunded,omitempty"`
|
||||
Assigned bool `json:"assigned,omitempty"`
|
||||
FromOffer bool `json:"from_offer,omitempty"`
|
||||
Craft bool `json:"craft,omitempty"`
|
||||
CanExportAt int `json:"can_export_at,omitempty"`
|
||||
TransferStars int64 `json:"transfer_stars,omitempty"`
|
||||
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
|
||||
CanTransferAt int `json:"can_transfer_at,omitempty"`
|
||||
CanResellAt int `json:"can_resell_at,omitempty"`
|
||||
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
|
||||
CanCraftAt int `json:"can_craft_at,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
ExpiresAt int `json:"expires_at"`
|
||||
Accepted bool `json:"accepted,omitempty"`
|
||||
Declined bool `json:"declined,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferDeclinedAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
|
|
|
|||
|
|
@ -24,6 +24,40 @@ type StarGift struct {
|
|||
UpgradeIssued int // 当前已发行数量
|
||||
Title string // 可选标题
|
||||
Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃)
|
||||
|
||||
// Layer 228 regular-gift shape. Static release facts live in the immutable
|
||||
// catalog revision; AvailabilityRemains/AvailabilityResale are the current
|
||||
// inventory projection maintained on the catalog aggregate.
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
AvailabilityRemains int
|
||||
AvailabilityTotal int
|
||||
AvailabilityResale int64
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
ReleasedBy Peer
|
||||
PerUserTotal int
|
||||
PerUserRemains int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *StarGiftBackground
|
||||
}
|
||||
|
||||
// StarGiftBackground is the release-level palette used by auction cards and
|
||||
// gift previews before a collectible backdrop is selected.
|
||||
type StarGiftBackground struct {
|
||||
CenterColor int
|
||||
EdgeColor int
|
||||
TextColor int
|
||||
}
|
||||
|
||||
// SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。
|
||||
|
|
@ -39,16 +73,38 @@ type SavedStarGift struct {
|
|||
NameHidden bool // 送礼人请求隐藏姓名
|
||||
Unsaved bool // 未展示在个人资料(saveStarGift 切换)
|
||||
Converted bool // 已转换回 Stars(终态,从列表排除)
|
||||
LifecycleStatus StarGiftLifecycleStatus
|
||||
ConvertStars int64 // 转换可退回的 Stars
|
||||
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
|
||||
PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement
|
||||
GiftNum int // auction-acquired release number for regular gifts
|
||||
Message string // 附言(可选)
|
||||
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
|
||||
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id
|
||||
TransferStars int64
|
||||
CanExportAt int
|
||||
CanTransferAt int
|
||||
CanResellAt int
|
||||
DropOriginalDetailsStars int64
|
||||
CanCraftAt int
|
||||
UpgradeMsgID int // 当前 owner 侧承载 messageActionStarGiftUnique 的消息 id;所有权转移时随新消息更新
|
||||
PinnedOrder int // >0 表示资料页置顶顺序
|
||||
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
|
||||
Unique *UniqueStarGift
|
||||
}
|
||||
|
||||
type StarGiftLifecycleStatus string
|
||||
|
||||
const (
|
||||
StarGiftLifecycleActive StarGiftLifecycleStatus = "active"
|
||||
StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted"
|
||||
StarGiftLifecycleBurned StarGiftLifecycleStatus = "burned"
|
||||
StarGiftLifecycleExported StarGiftLifecycleStatus = "exported"
|
||||
)
|
||||
|
||||
func (s StarGiftLifecycleStatus) Live() bool {
|
||||
return s == StarGiftLifecycleActive
|
||||
}
|
||||
|
||||
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
|
||||
type StarGiftCollectibleAttributeKind string
|
||||
|
||||
|
|
@ -58,8 +114,31 @@ const (
|
|||
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
|
||||
)
|
||||
|
||||
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
|
||||
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
|
||||
// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only
|
||||
// kind eligible for a regular upgrade draw; named rarities are currently used by
|
||||
// craft-only models and must still be preserved in the published attribute directory.
|
||||
type StarGiftAttributeRarityKind string
|
||||
|
||||
const (
|
||||
StarGiftRarityPermille StarGiftAttributeRarityKind = "permille"
|
||||
StarGiftRarityUncommon StarGiftAttributeRarityKind = "uncommon"
|
||||
StarGiftRarityRare StarGiftAttributeRarityKind = "rare"
|
||||
StarGiftRarityEpic StarGiftAttributeRarityKind = "epic"
|
||||
StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary"
|
||||
)
|
||||
|
||||
func (k StarGiftAttributeRarityKind) Valid() bool {
|
||||
switch k {
|
||||
case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare,
|
||||
StarGiftRarityEpic, StarGiftRarityLegendary:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille
|
||||
// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。
|
||||
type StarGiftCollectibleAttribute struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
|
|
@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct {
|
|||
EdgeColor int
|
||||
PatternColor int
|
||||
TextColor int
|
||||
RarityKind StarGiftAttributeRarityKind
|
||||
RarityPermille int
|
||||
Crafted bool
|
||||
OfficialDocumentID int64
|
||||
SortOrder int
|
||||
Animation *StarGiftAnimation
|
||||
Blob *FileBlob
|
||||
|
|
@ -93,6 +175,8 @@ type StarGiftCollectibleRevision struct {
|
|||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
PublishedAt time.Time
|
||||
OfficialGiftID int64
|
||||
SourceManifestSHA256 []byte
|
||||
}
|
||||
|
||||
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
|
||||
|
|
@ -106,6 +190,8 @@ type StarGiftCollectibleWrite struct {
|
|||
Backdrops []StarGiftCollectibleAttribute
|
||||
Actor string
|
||||
CommandID string
|
||||
OfficialGiftID int64
|
||||
SourceManifestSHA256 []byte
|
||||
}
|
||||
|
||||
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
|
||||
|
|
@ -118,6 +204,26 @@ type UniqueStarGift struct {
|
|||
Slug string
|
||||
Num int
|
||||
Owner Peer
|
||||
RequirePremium bool
|
||||
ResaleTonOnly bool
|
||||
ThemeAvailable bool
|
||||
Burned bool
|
||||
Crafted bool
|
||||
OwnerName string
|
||||
OwnerAddress string
|
||||
GiftAddress string
|
||||
ResellAmount *StarGiftAmount
|
||||
ResellVersion int64
|
||||
ReleasedBy Peer
|
||||
ValueAmount int64
|
||||
ValueCurrency string
|
||||
ValueUSD int64
|
||||
ThemePeer Peer
|
||||
Host Peer
|
||||
OfferMinStars int
|
||||
CraftChancePermille int
|
||||
LastSaleDate int
|
||||
LastSaleAmount *StarGiftAmount
|
||||
Model StarGiftCollectibleAttribute
|
||||
Pattern StarGiftCollectibleAttribute
|
||||
Backdrop StarGiftCollectibleAttribute
|
||||
|
|
@ -132,6 +238,33 @@ type UniqueStarGift struct {
|
|||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type StarGiftCurrency string
|
||||
|
||||
const (
|
||||
StarGiftCurrencyStars StarGiftCurrency = "XTR"
|
||||
StarGiftCurrencyTON StarGiftCurrency = "TON"
|
||||
)
|
||||
|
||||
type StarGiftAmount struct {
|
||||
Currency StarGiftCurrency
|
||||
Amount int64
|
||||
Nanos int
|
||||
}
|
||||
|
||||
func (a StarGiftAmount) Valid() bool {
|
||||
if a.Amount <= 0 {
|
||||
return false
|
||||
}
|
||||
switch a.Currency {
|
||||
case StarGiftCurrencyStars:
|
||||
return a.Nanos >= -999999999 && a.Nanos <= 999999999
|
||||
case StarGiftCurrencyTON:
|
||||
return a.Nanos == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
|
||||
type StarGiftUpgradePreview struct {
|
||||
GiftID int64
|
||||
|
|
@ -171,14 +304,367 @@ type StarGiftUpgradeRequest struct {
|
|||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftPurchaseRequest struct {
|
||||
BuyerUserID int64
|
||||
BuyerPremium bool
|
||||
To Peer
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
IncludeUpgrade bool
|
||||
HideName bool
|
||||
Message string
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// StarGiftPurchaseForm is the server-issued, short-lived payment intent that
|
||||
// binds payments.getPaymentForm to one later payments.sendStarsForm call. A
|
||||
// fresh form represents a fresh purchase even when every invoice field is the
|
||||
// same; retrying one form represents the same purchase command.
|
||||
type StarGiftPurchaseForm struct {
|
||||
FormID int64
|
||||
BuyerUserID int64
|
||||
To Peer
|
||||
GiftID int64
|
||||
RevisionID int64
|
||||
IncludeUpgrade bool
|
||||
HideName bool
|
||||
Message string
|
||||
ChargeStars int64
|
||||
IssuedAt int
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
type StarGiftPurchaseResult struct {
|
||||
Gift StarGift
|
||||
Saved SavedStarGift
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarGiftConvertRequest identifies one owner-scoped regular gift conversion.
|
||||
// ActorUserID is the authenticated user who owns the user gift or administers
|
||||
// the channel gift; authorization is checked again at the RPC boundary.
|
||||
type StarGiftConvertRequest struct {
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
Date int
|
||||
}
|
||||
|
||||
// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is
|
||||
// the post-credit balance of either the user or the channel internal Stars
|
||||
// ledger selected by Saved.Owner.
|
||||
type StarGiftConvertResult struct {
|
||||
Saved SavedStarGift
|
||||
OwnerBalance int64
|
||||
}
|
||||
|
||||
type StarGiftUpgradeResult struct {
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
SourceEdits []EditedMessageForUser
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a
|
||||
// committed upgrade after the saved gift has entered its unique terminal state.
|
||||
// In particular, a paid replay must not be rebound to a later catalog price.
|
||||
type StarGiftUpgradeReceipt struct {
|
||||
UserID int64
|
||||
SourceSavedGiftID int64
|
||||
FormID int64
|
||||
UniqueGiftID int64
|
||||
ChargeStars int64
|
||||
BalanceAfter int64
|
||||
SourceEditPts int
|
||||
RequirePrepaid bool
|
||||
KeepOriginalDetails bool
|
||||
}
|
||||
|
||||
type StarGiftPrepaidUpgradeRequest struct {
|
||||
PayerUserID int64
|
||||
Owner Peer
|
||||
Hash string
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftPrepaidUpgradeResult struct {
|
||||
Saved SavedStarGift
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftDropOriginalDetailsRequest struct {
|
||||
UserID int64
|
||||
Ref SavedStarGiftRef
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
}
|
||||
|
||||
type StarGiftDropOriginalDetailsResult struct {
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Balance StarsBalance
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular
|
||||
// gift becomes collectible. It deliberately contains no wallet/node/provider
|
||||
// configuration: TON remains only a currency unit in the local ledger.
|
||||
type StarGiftLifecyclePolicy struct {
|
||||
TransferStars int64
|
||||
DropOriginalDetailsStars int64
|
||||
OfferMinStars int
|
||||
ExportDelaySeconds int
|
||||
TransferDelaySeconds int
|
||||
ResellDelaySeconds int
|
||||
CraftDelaySeconds int
|
||||
CraftChancePermille int
|
||||
}
|
||||
|
||||
func (p StarGiftLifecyclePolicy) Valid() bool {
|
||||
return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 &&
|
||||
p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 &&
|
||||
p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000
|
||||
}
|
||||
|
||||
// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator.
|
||||
// Proceeds permille is the seller share; the remainder is recorded as platform
|
||||
// commission. TON is still only a unit in the local ledger.
|
||||
type StarGiftMarketPolicy struct {
|
||||
StarsProceedsPermille int
|
||||
TONProceedsPermille int
|
||||
}
|
||||
|
||||
func (p StarGiftMarketPolicy) Valid() bool {
|
||||
return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 &&
|
||||
p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000
|
||||
}
|
||||
|
||||
type StarGiftResaleFilter struct {
|
||||
GiftID int64
|
||||
SortByPrice bool
|
||||
SortByNum bool
|
||||
ForCraft bool
|
||||
StarsOnly bool
|
||||
ModelIDs []int64
|
||||
PatternIDs []int64
|
||||
BackdropIDs []int64
|
||||
Offset string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type StarGiftResalePage struct {
|
||||
Gifts []UniqueStarGift
|
||||
Count int
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
type StarGiftValueInfo struct {
|
||||
Currency string
|
||||
Value int64
|
||||
ValueIsAverage bool
|
||||
InitialSaleDate int
|
||||
InitialSaleStars int64
|
||||
InitialSalePrice int64
|
||||
LastSaleDate int
|
||||
LastSalePrice int64
|
||||
FloorPrice int64
|
||||
AveragePrice int64
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
type StarGiftTransferRequest struct {
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
To Peer
|
||||
ChargeStars int64
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftTransferResult struct {
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftListingRequest struct {
|
||||
ActorUserID int64
|
||||
Ref SavedStarGiftRef
|
||||
Amount *StarGiftAmount
|
||||
Date int
|
||||
}
|
||||
|
||||
type StarGiftResalePurchaseRequest struct {
|
||||
BuyerUserID int64
|
||||
Slug string
|
||||
To Peer
|
||||
Amount StarGiftAmount
|
||||
FormID int64
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftOfferRequest struct {
|
||||
BuyerUserID int64
|
||||
Owner Peer
|
||||
Slug string
|
||||
Price StarGiftAmount
|
||||
Duration int
|
||||
RandomID int64
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftOffer struct {
|
||||
ID int64
|
||||
BuyerUserID int64
|
||||
Owner Peer
|
||||
UniqueGiftID int64
|
||||
Price StarGiftAmount
|
||||
RandomID int64
|
||||
OfferMsgID int
|
||||
BuyerMsgID int
|
||||
Status string
|
||||
CreatedAt int
|
||||
ExpiresAt int
|
||||
ResolvedAt int
|
||||
Gift UniqueStarGift
|
||||
}
|
||||
|
||||
type StarGiftOfferResult struct {
|
||||
Offer StarGiftOffer
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftResolveOfferRequest struct {
|
||||
OwnerUserID int64
|
||||
OfferMsgID int
|
||||
Decline bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftCraftRequest struct {
|
||||
UserID int64
|
||||
Refs []SavedStarGiftRef
|
||||
CommandKey string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
type StarGiftCraftResult struct {
|
||||
Success bool
|
||||
Chance int
|
||||
Gift *UniqueStarGift
|
||||
Send SendPrivateTextResult
|
||||
SourceEdits []EditedMessageForUser
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftAuction struct {
|
||||
Gift StarGift
|
||||
Version int
|
||||
StartDate int
|
||||
EndDate int
|
||||
MinBidAmount int64
|
||||
NextRoundAt int
|
||||
LastGiftNum int
|
||||
GiftsLeft int
|
||||
CurrentRound int
|
||||
TotalRounds int
|
||||
RoundDuration int
|
||||
BidLevels []StarGiftAuctionBidLevel
|
||||
TopBidders []int64
|
||||
UserState StarGiftAuctionUserState
|
||||
Finished bool
|
||||
AveragePrice int64
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
type StarGiftAuctionBidLevel struct {
|
||||
Pos int
|
||||
Amount int64
|
||||
Date int
|
||||
}
|
||||
|
||||
type StarGiftAuctionUserState struct {
|
||||
Returned bool
|
||||
BidAmount int64
|
||||
BidDate int
|
||||
MinBidAmount int64
|
||||
BidPeer Peer
|
||||
AcquiredCount int
|
||||
}
|
||||
|
||||
type StarGiftAuctionBidRequest struct {
|
||||
UserID int64
|
||||
GiftID int64
|
||||
Peer Peer
|
||||
BidAmount int64
|
||||
HideName bool
|
||||
Message string
|
||||
UpdateBid bool
|
||||
FormID int64
|
||||
Date int
|
||||
}
|
||||
|
||||
type StarGiftAuctionAcquired struct {
|
||||
Peer Peer
|
||||
Date int
|
||||
BidAmount int64
|
||||
Round int
|
||||
Pos int
|
||||
Message string
|
||||
GiftNum int
|
||||
NameHidden bool
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalRequest struct {
|
||||
UserID int64
|
||||
Ref SavedStarGiftRef
|
||||
Date int
|
||||
}
|
||||
|
||||
type StarGiftWithdrawal struct {
|
||||
ProviderRequestID string
|
||||
URL string
|
||||
ExpiresAt int
|
||||
Status string
|
||||
Gift UniqueStarGift
|
||||
}
|
||||
|
||||
// StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
|
||||
type StarGiftCollection struct {
|
||||
Owner Peer
|
||||
|
|
@ -234,6 +720,42 @@ type StarGiftCatalogWrite struct {
|
|||
Animation StarGiftAnimation
|
||||
Actor string
|
||||
CommandID string
|
||||
OfficialGiftID int64
|
||||
SourceManifestSHA256 []byte
|
||||
OfficialSourceJSON []byte
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
AvailabilityRemains int
|
||||
AvailabilityTotal int
|
||||
AvailabilityResale int64
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
ReleasedBy Peer
|
||||
PerUserTotal int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *StarGiftBackground
|
||||
}
|
||||
|
||||
// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional
|
||||
// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID.
|
||||
type StarGiftCatalogBundleWrite struct {
|
||||
Catalog StarGiftCatalogWrite
|
||||
Collectible *StarGiftCollectibleWrite
|
||||
}
|
||||
|
||||
type StarGiftCatalogBundleResult struct {
|
||||
Catalog StarGiftCatalogEntry
|
||||
Collectible *StarGiftCollectibleRevision
|
||||
}
|
||||
|
||||
// StarGiftCatalogEntry 是管理后台目录视图。
|
||||
|
|
@ -255,20 +777,24 @@ type StarGiftCatalogEntry struct {
|
|||
}
|
||||
|
||||
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
|
||||
// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
|
||||
// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id;
|
||||
// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。
|
||||
type SavedStarGiftRef struct {
|
||||
Owner Peer
|
||||
MsgID int
|
||||
SavedID int64
|
||||
Slug string
|
||||
}
|
||||
|
||||
// Valid reports whether the reference has the identity required by its owner kind.
|
||||
func (r SavedStarGiftRef) Valid() bool {
|
||||
slug := strings.TrimSpace(r.Slug)
|
||||
validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0
|
||||
switch r.Owner.Type {
|
||||
case PeerTypeUser:
|
||||
return r.Owner.ID != 0 && r.MsgID > 0
|
||||
return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "")
|
||||
case PeerTypeChannel:
|
||||
return r.Owner.ID != 0 && r.SavedID > 0
|
||||
return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -317,7 +843,8 @@ const (
|
|||
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
|
||||
MaxStarGiftCatalogSize = 500
|
||||
MaxStarGiftTitleRunes = 128
|
||||
MaxStarGiftCollectibleAttributesPerKind = 256
|
||||
MaxStarGiftSlugBytes = 255
|
||||
MaxStarGiftCollectibleAttributesPerKind = 512
|
||||
MaxStarGiftCollectionTitleRunes = 12
|
||||
MaxStarGiftCollectionsPerPeer = 100
|
||||
MaxStarGiftCollectionItems = 1000
|
||||
|
|
@ -339,6 +866,18 @@ var (
|
|||
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
|
||||
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
|
||||
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
|
||||
ErrStarGiftUnavailable = errors.New("stargift: unavailable")
|
||||
ErrStarGiftOwnerInvalid = errors.New("stargift: owner invalid")
|
||||
ErrStarGiftTransferUnavailable = errors.New("stargift: transfer unavailable")
|
||||
ErrStarGiftResaleUnavailable = errors.New("stargift: resale unavailable")
|
||||
ErrStarGiftOfferInvalid = errors.New("stargift: offer invalid")
|
||||
ErrStarGiftOfferExpired = errors.New("stargift: offer expired")
|
||||
ErrStarGiftCraftUnavailable = errors.New("stargift: craft unavailable")
|
||||
ErrStarGiftAuctionUnavailable = errors.New("stargift: auction unavailable")
|
||||
ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable")
|
||||
ErrStarGiftFormExpired = errors.New("stargift: payment form expired")
|
||||
ErrStarGiftFormPurposeInvalid = errors.New("stargift: payment form purpose invalid")
|
||||
ErrStarGiftFormAmountMismatch = errors.New("stargift: payment form amount mismatch")
|
||||
)
|
||||
|
||||
var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
|
||||
|
|
@ -351,6 +890,11 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
|
|||
!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if write.OfficialGiftID < 0 ||
|
||||
(write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) ||
|
||||
(write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -380,11 +924,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
|
|||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seen := make(map[string]struct{}, len(attributes))
|
||||
total := 0
|
||||
selectable := 0
|
||||
for _, attribute := range attributes {
|
||||
name := strings.TrimSpace(attribute.Name)
|
||||
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes ||
|
||||
attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 {
|
||||
rarityKind := attribute.RarityKind
|
||||
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if rarityKind == StarGiftRarityPermille {
|
||||
if attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 || attribute.Crafted {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
selectable++
|
||||
} else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
|
|
@ -392,7 +944,6 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
|
|||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
total += attribute.RarityPermille
|
||||
switch kind {
|
||||
case StarGiftCollectibleModel, StarGiftCollectiblePattern:
|
||||
if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
|
||||
|
|
@ -404,7 +955,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
|
|||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
case StarGiftCollectibleBackdrop:
|
||||
if attribute.BackdropID <= 0 || attribute.Document != nil ||
|
||||
if attribute.BackdropID < 0 || attribute.Document != nil ||
|
||||
attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
|
||||
attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
|
||||
attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
|
||||
|
|
@ -415,7 +966,7 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
|
|||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
}
|
||||
if total != 1000 {
|
||||
if selectable == 0 {
|
||||
return ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
95
internal/domain/star_gift_collectible_test.go
Normal file
95
internal/domain/star_gift_collectible_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
|
||||
user := Peer{Type: PeerTypeUser, ID: 42}
|
||||
channel := Peer{Type: PeerTypeChannel, ID: 84}
|
||||
tests := []struct {
|
||||
name string
|
||||
ref SavedStarGiftRef
|
||||
want bool
|
||||
}{
|
||||
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
|
||||
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
|
||||
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
|
||||
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
|
||||
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
|
||||
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
|
||||
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
|
||||
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.ref.Valid(); got != tt.want {
|
||||
t.Fatalf("Valid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
|
||||
if StarGiftLifecycleStatus("").Live() {
|
||||
t.Fatal("empty lifecycle status must not be treated as active")
|
||||
}
|
||||
if !StarGiftLifecycleActive.Live() {
|
||||
t.Fatal("active lifecycle status must be live")
|
||||
}
|
||||
}
|
||||
|
||||
func validCollectibleDraft() StarGiftCollectibleWrite {
|
||||
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
|
||||
return StarGiftCollectibleWrite{
|
||||
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
|
||||
Models: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.OfficialGiftID = 10
|
||||
write.SourceManifestSHA256 = make([]byte, sha256.Size)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
t.Fatalf("valid official draft: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]StarGiftCollectibleWrite{}
|
||||
withoutHash := write
|
||||
withoutHash.SourceManifestSHA256 = nil
|
||||
tests["official ID without hash"] = withoutHash
|
||||
withoutID := write
|
||||
withoutID.OfficialGiftID = 0
|
||||
tests["hash without official ID"] = withoutID
|
||||
negativeID := write
|
||||
negativeID.OfficialGiftID = -1
|
||||
tests["negative official ID"] = negativeID
|
||||
for name, invalid := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.Models[0].RarityKind = ""
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,12 @@ const (
|
|||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
|
@ -52,6 +58,28 @@ type StarsTransactionPage struct {
|
|||
Users []User // History 中提到的对手方用户,供 tg Users 富化
|
||||
}
|
||||
|
||||
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
|
||||
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
|
||||
// a TON node, or any blockchain service.
|
||||
type TonTransaction struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
GiftID int64
|
||||
Amount int64 // signed nanoton amount
|
||||
Date int
|
||||
Reason StarsTransactionReason
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type TonTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []TonTransaction
|
||||
NextOffset string
|
||||
Users []User
|
||||
}
|
||||
|
||||
// Stars 账本边界常量。
|
||||
const (
|
||||
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
|
||||
|
|
|
|||
531
internal/officialgifts/catalog.go
Normal file
531
internal/officialgifts/catalog.go
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
// Package officialgifts reads the local, immutable snapshot produced by cmd/giftfetch.
|
||||
// It never performs network I/O. Selected document bytes are accepted only after their
|
||||
// manifest size and SHA-256 have been verified beneath the configured snapshot root.
|
||||
package officialgifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const manifestSchema = 2
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("official gifts snapshot is unavailable")
|
||||
ErrInvalid = errors.New("official gifts snapshot is invalid")
|
||||
ErrNotFound = errors.New("official gift not found")
|
||||
)
|
||||
|
||||
type Catalog struct {
|
||||
root string
|
||||
once sync.Once
|
||||
snap *snapshot
|
||||
err error
|
||||
}
|
||||
|
||||
type GiftSummary struct {
|
||||
ID int64
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
UpgradeStars int64
|
||||
AvailabilityTotal int
|
||||
AvailabilityRemains int
|
||||
AvailabilityResale int64
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
PerUserTotal int
|
||||
PerUserRemains int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *Background
|
||||
ModelCount int
|
||||
PatternCount int
|
||||
BackdropCount int
|
||||
CraftedModelCount int
|
||||
DocumentID int64
|
||||
AnimationValidated bool
|
||||
}
|
||||
|
||||
// CanUpgrade reports whether this snapshot contains the complete immutable
|
||||
// attribute pool and a positive official upgrade price required to mint a
|
||||
// collectible from the regular gift.
|
||||
func (g GiftSummary) CanUpgrade() bool {
|
||||
return g.UpgradeStars > 0 && g.ModelCount > 0 && g.PatternCount > 0 && g.BackdropCount > 0
|
||||
}
|
||||
|
||||
// CanCraft reports whether upgraded collectibles from this gift can reach an
|
||||
// official craft-only model. Craft is not advertised without a valid upgrade
|
||||
// path even if a malformed snapshot were to contain a crafted model.
|
||||
func (g GiftSummary) CanCraft() bool {
|
||||
return g.CanUpgrade() && g.CraftedModelCount > 0
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
ManifestSHA256 []byte
|
||||
SourceJSON []byte
|
||||
Gift Gift
|
||||
BaseDocument Document
|
||||
Collectible *CollectibleSet
|
||||
}
|
||||
|
||||
type Gift struct {
|
||||
ID int64
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
UpgradeStars int64
|
||||
AvailabilityTotal int
|
||||
AvailabilityRemains int
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
AvailabilityResale int64
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
PerUserTotal int
|
||||
PerUserRemains int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *Background
|
||||
DocumentID int64
|
||||
}
|
||||
|
||||
type Background struct {
|
||||
CenterColor int `json:"center_color"`
|
||||
EdgeColor int `json:"edge_color"`
|
||||
TextColor int `json:"text_color"`
|
||||
}
|
||||
|
||||
type CollectibleSet struct {
|
||||
Models []Model
|
||||
Patterns []Pattern
|
||||
Backdrops []Backdrop
|
||||
}
|
||||
|
||||
type Rarity struct {
|
||||
Kind string `json:"kind"`
|
||||
Permille *int `json:"permille,omitempty"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string
|
||||
DocumentID int64
|
||||
Crafted bool
|
||||
Rarity Rarity
|
||||
Document Document
|
||||
}
|
||||
|
||||
type Pattern struct {
|
||||
Name string
|
||||
DocumentID int64
|
||||
Rarity Rarity
|
||||
Document Document
|
||||
}
|
||||
|
||||
type Backdrop struct {
|
||||
Name string
|
||||
BackdropID int
|
||||
CenterColor int
|
||||
EdgeColor int
|
||||
PatternColor int
|
||||
TextColor int
|
||||
Rarity Rarity
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID int64
|
||||
FileName string
|
||||
Path string
|
||||
Size int64
|
||||
SHA256 string
|
||||
AnimationValidated bool
|
||||
ValidationError string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Schema int `json:"schema"`
|
||||
GiftCount int `json:"gift_count"`
|
||||
Gifts []giftManifest `json:"gifts"`
|
||||
UpgradeAttributeSets []collectibleManifest `json:"upgrade_attribute_sets"`
|
||||
Documents []documentManifest `json:"documents"`
|
||||
}
|
||||
|
||||
type giftManifest struct {
|
||||
Index int `json:"index"`
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
Limited bool `json:"limited"`
|
||||
SoldOut bool `json:"sold_out"`
|
||||
Birthday bool `json:"birthday"`
|
||||
RequirePremium bool `json:"require_premium"`
|
||||
LimitedPerUser bool `json:"limited_per_user"`
|
||||
PeerColorAvailable bool `json:"peer_color_available"`
|
||||
Auction bool `json:"auction"`
|
||||
AvailabilityRemains int `json:"availability_remains"`
|
||||
AvailabilityTotal int `json:"availability_total"`
|
||||
AvailabilityResale int64 `json:"availability_resale"`
|
||||
FirstSaleDate int `json:"first_sale_date"`
|
||||
LastSaleDate int `json:"last_sale_date"`
|
||||
ResellMinStars int64 `json:"resell_min_stars"`
|
||||
PerUserTotal int `json:"per_user_total"`
|
||||
PerUserRemains int `json:"per_user_remains"`
|
||||
LockedUntilDate int `json:"locked_until_date"`
|
||||
AuctionSlug string `json:"auction_slug"`
|
||||
GiftsPerRound int `json:"gifts_per_round"`
|
||||
AuctionStartDate int `json:"auction_start_date"`
|
||||
UpgradeVariants int `json:"upgrade_variants"`
|
||||
Background *Background `json:"background"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
SourceJSON []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (g *giftManifest) UnmarshalJSON(data []byte) error {
|
||||
type plain giftManifest
|
||||
var value plain
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*g = giftManifest(value)
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil {
|
||||
return err
|
||||
}
|
||||
g.SourceJSON = append([]byte(nil), compact.Bytes()...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type collectibleManifest struct {
|
||||
GiftID64 int64 `json:"gift_id"`
|
||||
AttributeCount int `json:"attribute_count"`
|
||||
Models []modelManifest `json:"models"`
|
||||
Patterns []patternManifest `json:"patterns"`
|
||||
Backdrops []backdropManifest `json:"backdrops"`
|
||||
}
|
||||
|
||||
type modelManifest struct {
|
||||
Name string `json:"name"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Crafted bool `json:"crafted"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type patternManifest struct {
|
||||
Name string `json:"name"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type backdropManifest struct {
|
||||
Name string `json:"name"`
|
||||
BackdropID int `json:"backdrop_id"`
|
||||
CenterColor int `json:"center_color"`
|
||||
EdgeColor int `json:"edge_color"`
|
||||
PatternColor int `json:"pattern_color"`
|
||||
TextColor int `json:"text_color"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type documentManifest struct {
|
||||
ID64 int64 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
File fileArtifact `json:"file"`
|
||||
AnimationValidated bool `json:"animation_validated"`
|
||||
ValidationError string `json:"validation_error"`
|
||||
}
|
||||
|
||||
type fileArtifact struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
manifestSHA []byte
|
||||
gifts map[int64]giftManifest
|
||||
sets map[int64]collectibleManifest
|
||||
documents map[int64]documentManifest
|
||||
ordered []int64
|
||||
}
|
||||
|
||||
func New(root string) *Catalog {
|
||||
return &Catalog{root: strings.TrimSpace(root)}
|
||||
}
|
||||
|
||||
func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) {
|
||||
snap, err := c.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]GiftSummary, 0, len(snap.ordered))
|
||||
for _, id := range snap.ordered {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gift := snap.gifts[id]
|
||||
doc := snap.documents[gift.DocumentIDs[0]]
|
||||
summary := GiftSummary{
|
||||
ID: id, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
|
||||
AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale,
|
||||
Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday,
|
||||
RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
|
||||
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
|
||||
FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate,
|
||||
ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal,
|
||||
PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate,
|
||||
AuctionSlug: gift.AuctionSlug, GiftsPerRound: gift.GiftsPerRound,
|
||||
AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants,
|
||||
Background: cloneBackground(gift.Background), DocumentID: doc.ID64,
|
||||
AnimationValidated: doc.AnimationValidated,
|
||||
}
|
||||
if set, ok := snap.sets[id]; ok {
|
||||
summary.ModelCount, summary.PatternCount, summary.BackdropCount = len(set.Models), len(set.Patterns), len(set.Backdrops)
|
||||
for _, model := range set.Models {
|
||||
if model.Crafted {
|
||||
summary.CraftedModelCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, summary)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (Bundle, error) {
|
||||
snap, err := c.load()
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
gift, ok := snap.gifts[giftID]
|
||||
if !ok {
|
||||
return Bundle{}, ErrNotFound
|
||||
}
|
||||
base, err := c.readDocument(ctx, snap.documents[gift.DocumentIDs[0]])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("base document %d: %w", gift.DocumentIDs[0], err)
|
||||
}
|
||||
out := Bundle{
|
||||
ManifestSHA256: append([]byte(nil), snap.manifestSHA...),
|
||||
SourceJSON: append([]byte(nil), gift.SourceJSON...),
|
||||
Gift: Gift{ID: gift.ID, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
|
||||
AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut,
|
||||
Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
|
||||
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
|
||||
AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate,
|
||||
LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars,
|
||||
PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains,
|
||||
LockedUntilDate: gift.LockedUntilDate, AuctionSlug: gift.AuctionSlug,
|
||||
GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate,
|
||||
UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background),
|
||||
DocumentID: gift.DocumentIDs[0]},
|
||||
BaseDocument: base,
|
||||
}
|
||||
if !includeCollectible {
|
||||
return out, nil
|
||||
}
|
||||
set, ok := snap.sets[giftID]
|
||||
if !ok {
|
||||
return Bundle{}, fmt.Errorf("%w: gift %d has no collectible attribute set", ErrInvalid, giftID)
|
||||
}
|
||||
collectible := &CollectibleSet{
|
||||
Models: make([]Model, 0, len(set.Models)), Patterns: make([]Pattern, 0, len(set.Patterns)),
|
||||
Backdrops: make([]Backdrop, 0, len(set.Backdrops)),
|
||||
}
|
||||
for _, value := range set.Models {
|
||||
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err)
|
||||
}
|
||||
collectible.Models = append(collectible.Models, Model{Name: value.Name, DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc})
|
||||
}
|
||||
for _, value := range set.Patterns {
|
||||
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err)
|
||||
}
|
||||
collectible.Patterns = append(collectible.Patterns, Pattern{Name: value.Name, DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc})
|
||||
}
|
||||
for _, value := range set.Backdrops {
|
||||
collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: value.Name, BackdropID: value.BackdropID,
|
||||
CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor,
|
||||
TextColor: value.TextColor, Rarity: value.Rarity})
|
||||
}
|
||||
out.Collectible = collectible
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneBackground(value *Background) *Background {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (c *Catalog) load() (*snapshot, error) {
|
||||
c.once.Do(func() {
|
||||
if c.root == "" {
|
||||
c.err = ErrUnavailable
|
||||
return
|
||||
}
|
||||
manifestPath := filepath.Join(c.root, "manifest.json")
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
c.err = fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
return
|
||||
}
|
||||
var value manifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
c.err = fmt.Errorf("%w: decode manifest: %v", ErrInvalid, err)
|
||||
return
|
||||
}
|
||||
if value.Schema != manifestSchema || value.GiftCount != len(value.Gifts) || len(value.Gifts) == 0 {
|
||||
c.err = fmt.Errorf("%w: unexpected schema or gift count", ErrInvalid)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
snap := &snapshot{manifestSHA: append([]byte(nil), sum[:]...), gifts: make(map[int64]giftManifest, len(value.Gifts)),
|
||||
sets: make(map[int64]collectibleManifest, len(value.UpgradeAttributeSets)), documents: make(map[int64]documentManifest, len(value.Documents))}
|
||||
for _, doc := range value.Documents {
|
||||
if doc.ID64 <= 0 || doc.File.Size <= 0 || len(doc.File.SHA256) != 64 || strings.TrimSpace(doc.File.Path) == "" {
|
||||
c.err = fmt.Errorf("%w: invalid document %d", ErrInvalid, doc.ID64)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.documents[doc.ID64]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate document %d", ErrInvalid, doc.ID64)
|
||||
return
|
||||
}
|
||||
snap.documents[doc.ID64] = doc
|
||||
}
|
||||
for _, gift := range value.Gifts {
|
||||
if gift.Kind != "regular" || gift.ID <= 0 || gift.Stars <= 0 || len(gift.DocumentIDs) != 1 {
|
||||
c.err = fmt.Errorf("%w: invalid gift %d", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
if _, ok := snap.documents[gift.DocumentIDs[0]]; !ok {
|
||||
c.err = fmt.Errorf("%w: gift %d document missing", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.gifts[gift.ID]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate gift %d", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
snap.gifts[gift.ID] = gift
|
||||
snap.ordered = append(snap.ordered, gift.ID)
|
||||
}
|
||||
for _, set := range value.UpgradeAttributeSets {
|
||||
if _, ok := snap.gifts[set.GiftID64]; !ok || set.AttributeCount != len(set.Models)+len(set.Patterns)+len(set.Backdrops) ||
|
||||
len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 {
|
||||
c.err = fmt.Errorf("%w: invalid collectible set %d", ErrInvalid, set.GiftID64)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.sets[set.GiftID64]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate collectible set %d", ErrInvalid, set.GiftID64)
|
||||
return
|
||||
}
|
||||
for _, model := range set.Models {
|
||||
if _, ok := snap.documents[model.DocumentID]; !ok || !validRarity(model.Rarity, model.Crafted) {
|
||||
c.err = fmt.Errorf("%w: invalid model %q", ErrInvalid, model.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, pattern := range set.Patterns {
|
||||
if _, ok := snap.documents[pattern.DocumentID]; !ok || !validRarity(pattern.Rarity, false) {
|
||||
c.err = fmt.Errorf("%w: invalid pattern %q", ErrInvalid, pattern.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, backdrop := range set.Backdrops {
|
||||
if backdrop.BackdropID < 0 || !validRarity(backdrop.Rarity, false) {
|
||||
c.err = fmt.Errorf("%w: invalid backdrop %q", ErrInvalid, backdrop.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
snap.sets[set.GiftID64] = set
|
||||
}
|
||||
sort.SliceStable(snap.ordered, func(i, j int) bool { return snap.gifts[snap.ordered[i]].Index < snap.gifts[snap.ordered[j]].Index })
|
||||
c.snap = snap
|
||||
})
|
||||
return c.snap, c.err
|
||||
}
|
||||
|
||||
func validRarity(rarity Rarity, crafted bool) bool {
|
||||
if rarity.Kind == "permille" {
|
||||
return !crafted && rarity.Permille != nil && *rarity.Permille > 0 && *rarity.Permille <= 1000
|
||||
}
|
||||
return crafted && rarity.Permille == nil && (rarity.Kind == "uncommon" || rarity.Kind == "rare" || rarity.Kind == "epic" || rarity.Kind == "legendary")
|
||||
}
|
||||
|
||||
func (c *Catalog) readDocument(ctx context.Context, doc documentManifest) (Document, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
root, err := filepath.Abs(c.root)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(doc.File.Path))
|
||||
if filepath.IsAbs(clean) {
|
||||
return Document{}, ErrInvalid
|
||||
}
|
||||
full := filepath.Join(root, clean)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return Document{}, ErrInvalid
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if int64(len(data)) != doc.File.Size {
|
||||
return Document{}, fmt.Errorf("%w: size mismatch", ErrInvalid)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if !strings.EqualFold(hex.EncodeToString(sum[:]), doc.File.SHA256) {
|
||||
return Document{}, fmt.Errorf("%w: sha256 mismatch", ErrInvalid)
|
||||
}
|
||||
name := strings.TrimSpace(doc.FileName)
|
||||
if name == "" {
|
||||
name = filepath.Base(clean)
|
||||
}
|
||||
return Document{ID: doc.ID64, FileName: name, Path: doc.File.Path, Size: doc.File.Size,
|
||||
SHA256: strings.ToLower(doc.File.SHA256), AnimationValidated: doc.AnimationValidated,
|
||||
ValidationError: doc.ValidationError, Data: data}, nil
|
||||
}
|
||||
119
internal/officialgifts/catalog_test.go
Normal file
119
internal/officialgifts/catalog_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package officialgifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogVerifiesSelectedDocument(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
data := []byte("official-tgs")
|
||||
sum := sha256.Sum256(data)
|
||||
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := manifest{Schema: manifestSchema, GiftCount: 1,
|
||||
Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}},
|
||||
Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}},
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].ID != 1 {
|
||||
t.Fatalf("items=%+v err=%v", items, err)
|
||||
}
|
||||
bundle, err := catalog.Bundle(context.Background(), 1, false)
|
||||
if err != nil || string(bundle.BaseDocument.Data) != string(data) {
|
||||
t.Fatalf("bundle=%+v err=%v", bundle, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := catalog.Bundle(context.Background(), 1, false); err == nil {
|
||||
t.Fatal("tampered document was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredOfficialSnapshotIsComplete(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
|
||||
}
|
||||
catalog := New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sets, attributes, crafted, upgradable, craftable int
|
||||
verifiedDocuments := map[int64]struct{}{}
|
||||
for _, item := range items {
|
||||
if item.CanUpgrade() {
|
||||
upgradable++
|
||||
}
|
||||
if item.CanCraft() {
|
||||
craftable++
|
||||
}
|
||||
include := item.ModelCount+item.PatternCount+item.BackdropCount > 0
|
||||
bundle, err := catalog.Bundle(context.Background(), item.ID, include)
|
||||
if err != nil {
|
||||
t.Fatalf("gift %d: %v", item.ID, err)
|
||||
}
|
||||
verifiedDocuments[bundle.BaseDocument.ID] = struct{}{}
|
||||
if bundle.Collectible == nil {
|
||||
continue
|
||||
}
|
||||
sets++
|
||||
attributes += len(bundle.Collectible.Models) + len(bundle.Collectible.Patterns) + len(bundle.Collectible.Backdrops)
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
verifiedDocuments[model.Document.ID] = struct{}{}
|
||||
if model.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
for _, pattern := range bundle.Collectible.Patterns {
|
||||
verifiedDocuments[pattern.Document.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(items) != 149 || sets != 116 || attributes != 40332 || crafted != 108 || upgradable != 114 || craftable != 2 || len(verifiedDocuments) != 8333 {
|
||||
t.Fatalf("gifts=%d sets=%d attributes=%d crafted=%d upgradable=%d craftable=%d documents=%d",
|
||||
len(items), sets, attributes, crafted, upgradable, craftable, len(verifiedDocuments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftSummaryCapabilitiesRequireCompleteOfficialFacts(t *testing.T) {
|
||||
complete := GiftSummary{UpgradeStars: 25, ModelCount: 1, PatternCount: 1, BackdropCount: 1}
|
||||
if !complete.CanUpgrade() || complete.CanCraft() {
|
||||
t.Fatalf("complete regular pool capabilities = upgrade:%v craft:%v", complete.CanUpgrade(), complete.CanCraft())
|
||||
}
|
||||
craftable := complete
|
||||
craftable.CraftedModelCount = 1
|
||||
if !craftable.CanUpgrade() || !craftable.CanCraft() {
|
||||
t.Fatalf("crafted pool capabilities = upgrade:%v craft:%v", craftable.CanUpgrade(), craftable.CanCraft())
|
||||
}
|
||||
for name, invalid := range map[string]GiftSummary{
|
||||
"zero upgrade price": {ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing model": {UpgradeStars: 25, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing pattern": {UpgradeStars: 25, ModelCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing backdrop": {UpgradeStars: 25, ModelCount: 1, PatternCount: 1, CraftedModelCount: 1},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if invalid.CanUpgrade() || invalid.CanCraft() {
|
||||
t.Fatalf("invalid facts advertised capabilities: %+v", invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -284,6 +284,8 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
}
|
||||
case domain.ChannelActionStarGift:
|
||||
return tgMessageActionStarGift(action.StarGift)
|
||||
case domain.ChannelActionStarGiftUnique:
|
||||
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
|
||||
case domain.ChannelActionSetChatWallpaper:
|
||||
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
|
||||
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
|
||||
|
|
|
|||
|
|
@ -221,14 +221,57 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
case domain.MessageServiceActionStarGift:
|
||||
return tgMessageActionStarGift(m.ServiceAction.StarGift)
|
||||
case domain.MessageServiceActionStarGiftUnique:
|
||||
action := m.ServiceAction.StarGiftUnique
|
||||
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
||||
case domain.MessageServiceActionStarGiftOffer:
|
||||
action := m.ServiceAction.StarGiftOffer
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
|
||||
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
|
||||
case domain.MessageServiceActionStarGiftOfferDeclined:
|
||||
action := m.ServiceAction.StarGiftOfferDeclined
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
|
||||
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
|
||||
default:
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
}
|
||||
|
||||
func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass {
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
out := &tg.MessageActionStarGiftUnique{
|
||||
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
|
||||
Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned,
|
||||
FromOffer: action.FromOffer, Craft: action.Craft,
|
||||
Gift: tgUniqueStarGift(action.Gift),
|
||||
}
|
||||
if action.CanExportAt > 0 {
|
||||
out.SetCanExportAt(action.CanExportAt)
|
||||
}
|
||||
if action.TransferStars > 0 {
|
||||
out.SetTransferStars(action.TransferStars)
|
||||
}
|
||||
if action.ResaleAmount != nil {
|
||||
out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount))
|
||||
}
|
||||
if action.CanTransferAt > 0 {
|
||||
out.SetCanTransferAt(action.CanTransferAt)
|
||||
}
|
||||
if action.CanResellAt > 0 {
|
||||
out.SetCanResellAt(action.CanResellAt)
|
||||
}
|
||||
if action.DropOriginalDetailsStars > 0 {
|
||||
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
|
||||
}
|
||||
if action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
|
||||
}
|
||||
|
|
@ -239,9 +282,6 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
out.SetSavedID(action.SavedID)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
}
|
||||
|
||||
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
|
||||
|
|
|
|||
|
|
@ -872,6 +872,7 @@ type GiftsService interface {
|
|||
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
|
||||
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
|
||||
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
|
||||
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
|
||||
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
|
||||
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
|
||||
|
|
@ -879,13 +880,36 @@ type GiftsService interface {
|
|||
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
|
||||
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
|
||||
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
|
||||
Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error)
|
||||
ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error)
|
||||
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
|
||||
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
|
||||
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
|
||||
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
|
||||
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
|
||||
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
|
||||
ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
|
||||
ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
|
||||
SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
|
||||
Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
|
||||
PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
|
||||
SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
|
||||
Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
|
||||
AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
|
||||
ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
|
||||
AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
|
||||
BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
|
||||
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
|
||||
PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
|
||||
DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
|
||||
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
|
||||
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
|
||||
TonBalance(ctx context.Context, userID int64) (int64, error)
|
||||
TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error)
|
||||
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
|
||||
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
|
||||
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
|
||||
}
|
||||
|
||||
// StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。
|
||||
|
|
|
|||
|
|
@ -137,6 +137,10 @@ func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOU
|
|||
|
||||
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
|
||||
|
||||
func formExpiredErr() error { return tgerr.New(400, "FORM_EXPIRED") }
|
||||
|
||||
func purposeInvalidErr() error { return tgerr.New(400, "PURPOSE_INVALID") }
|
||||
|
||||
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
|
||||
|
||||
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }
|
||||
|
|
|
|||
|
|
@ -33,12 +33,11 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) {
|
||||
return r.onPaymentsCheckCanSendGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
Hash
|
||||
_ = hash
|
||||
|
||||
return tdesktop.StarGiftActiveAuctions(), nil
|
||||
return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGifts(ctx, layerRequest.
|
||||
|
|
@ -48,10 +47,19 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
|
||||
GiftID)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
|
||||
Slug)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) {
|
||||
return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetResaleStarGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
|
||||
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
|
||||
})
|
||||
|
|
@ -75,6 +83,36 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsUpgradeStarGift(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) {
|
||||
return r.onPaymentsUpdateStarGiftPrice(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsTransferStarGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) {
|
||||
return r.onPaymentsSendStarGiftOffer(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) {
|
||||
return r.onPaymentsResolveStarGiftOffer(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetCraftStarGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsCraftStarGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftAuctionState(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) {
|
||||
return r.onPaymentsToggleChatStarGiftNotifications(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest)
|
||||
})
|
||||
|
|
@ -108,6 +146,16 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsRevenueStats(ctx, req)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
|
||||
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
|
||||
// and bot revenue remain the bounded compatibility response because their
|
||||
// revenue bucket is distinct from the general Stars balance and is not modeled.
|
||||
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -115,29 +163,99 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
if req == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tdesktop.StarsRevenueStats(req.GetTon()), nil
|
||||
})
|
||||
|
||||
ton := req.GetTon()
|
||||
if owner.Type != domain.PeerTypeChannel {
|
||||
return tdesktop.StarsRevenueStats(ton), nil
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
return tdesktop.StarsRevenueStats(ton), nil
|
||||
}
|
||||
var balance int64
|
||||
if ton {
|
||||
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
|
||||
} else {
|
||||
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
stats := tdesktop.StarsRevenueStats(ton)
|
||||
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
|
||||
if ton {
|
||||
amount = &tg.StarsTonAmount{Amount: balance}
|
||||
}
|
||||
// Channel ledgers currently only receive collectible conversion/marketplace
|
||||
// proceeds and have no withdrawal/debit path, so balance equals lifetime
|
||||
// revenue. Withdrawal stays disabled because no external payout exists.
|
||||
stats.Status.CurrentBalance = amount
|
||||
stats.Status.AvailableBalance = amount
|
||||
stats.Status.OverallRevenue = amount
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。
|
||||
type channelGiftLedgerReader interface {
|
||||
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error)
|
||||
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error)
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本
|
||||
// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。
|
||||
// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
|
||||
// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。
|
||||
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if req != nil && req.GetTon() {
|
||||
// TON 余额未建模:返回 0 nanoton 的合法响应。
|
||||
userID, owner, err := r.starGiftLedgerOwner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ton := req != nil && req.GetTon()
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
if ton {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
var balance int64
|
||||
if ton {
|
||||
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
|
||||
} else {
|
||||
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
|
||||
if ton {
|
||||
amount = &tg.StarsTonAmount{Amount: balance}
|
||||
}
|
||||
out := emptyStarsStatus(amount)
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID})
|
||||
return out, nil
|
||||
}
|
||||
if ton {
|
||||
if r.deps.Gifts == nil {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
balance, err := r.deps.Gifts.TonBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
bal, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
|
|
@ -148,24 +266,82 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
|
|||
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
|
||||
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
|
||||
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if req != nil && req.GetTon() {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
return nil, err
|
||||
}
|
||||
offset := ""
|
||||
limit := domain.MaxStarsTransactionsLimit
|
||||
offset, limit := "", domain.MaxStarsTransactionsLimit
|
||||
if req != nil {
|
||||
offset = req.Offset
|
||||
if req.Limit > 0 {
|
||||
limit = req.Limit
|
||||
}
|
||||
}
|
||||
ton := req != nil && req.GetTon()
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
if ton {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
if ton {
|
||||
page, err := ledger.ChannelTonTransactions(ctx, owner.ID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
|
||||
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
|
||||
return out, nil
|
||||
}
|
||||
page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
|
||||
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
|
||||
return out, nil
|
||||
}
|
||||
if ton {
|
||||
if r.deps.Gifts == nil {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
page, err := r.deps.Gifts.TonTransactions(ctx, userID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
|
||||
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
ids := make([]int64, 0)
|
||||
for _, txn := range page.Transactions {
|
||||
if txn.Peer.Type == domain.PeerTypeUser {
|
||||
ids = append(ids, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids)))
|
||||
return out, nil
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
|
|
@ -184,6 +360,71 @@ func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.Pay
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) {
|
||||
if req == nil {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
}
|
||||
|
||||
func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) {
|
||||
if req == nil {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
}
|
||||
|
||||
func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return 0, domain.Peer{}, internalErr()
|
||||
}
|
||||
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
if err != nil {
|
||||
return 0, domain.Peer{}, err
|
||||
}
|
||||
if owner.Type == domain.PeerTypeUser {
|
||||
if owner.ID != userID {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return userID, owner, nil
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
|
||||
return 0, domain.Peer{}, err
|
||||
}
|
||||
return userID, owner, nil
|
||||
}
|
||||
|
||||
func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) {
|
||||
userIDs := make([]int64, 0, len(txns))
|
||||
channelIDs := []int64{ownerChannelID}
|
||||
for _, txn := range txns {
|
||||
switch txn.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userIDs = append(userIDs, txn.Peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
|
||||
}
|
||||
|
||||
func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) {
|
||||
userIDs := make([]int64, 0, len(txns))
|
||||
channelIDs := []int64{ownerChannelID}
|
||||
for _, txn := range txns {
|
||||
switch txn.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userIDs = append(userIDs, txn.Peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
|
||||
}
|
||||
|
||||
// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。
|
||||
func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
|
||||
return &tg.PaymentsStarsStatus{
|
||||
|
|
@ -214,6 +455,49 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
|
|||
item.Reaction = true
|
||||
case domain.StarsReasonGift:
|
||||
item.Gift = true
|
||||
case domain.StarsReasonGiftUpgrade:
|
||||
item.StargiftUpgrade = true
|
||||
case domain.StarsReasonGiftResale:
|
||||
item.StargiftResale = true
|
||||
case domain.StarsReasonGiftPrepaid:
|
||||
item.StargiftPrepaidUpgrade = true
|
||||
case domain.StarsReasonGiftDrop:
|
||||
item.StargiftDropOriginalDetails = true
|
||||
case domain.StarsReasonGiftAuction:
|
||||
item.StargiftAuctionBid = true
|
||||
case domain.StarsReasonGiftOffer:
|
||||
item.Offer = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction {
|
||||
out := make([]tg.StarsTransaction, 0, len(in))
|
||||
for _, t := range in {
|
||||
item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount},
|
||||
Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})}
|
||||
if t.Amount > 0 {
|
||||
item.Refund = true
|
||||
}
|
||||
if t.Title != "" {
|
||||
item.SetTitle(t.Title)
|
||||
}
|
||||
if t.Description != "" {
|
||||
item.SetDescription(t.Description)
|
||||
}
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonGiftResale:
|
||||
item.StargiftResale = true
|
||||
case domain.StarsReasonGiftOffer:
|
||||
item.Offer = true
|
||||
case domain.StarsReasonGiftAuction:
|
||||
item.StargiftAuctionBid = true
|
||||
case domain.StarsReasonGiftPrepaid:
|
||||
item.StargiftPrepaidUpgrade = true
|
||||
case domain.StarsReasonGiftDrop:
|
||||
item.StargiftDropOriginalDetails = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
|
|
|
|||
98
internal/rpc/payments_star_gift_catalog_projection_test.go
Normal file
98
internal/rpc/payments_star_gift_catalog_projection_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) {
|
||||
base := domain.StarGift{
|
||||
ID: 8001,
|
||||
RevisionID: 9001,
|
||||
Stars: 100,
|
||||
ConvertStars: 85,
|
||||
Title: "Fresh Socks",
|
||||
FirstSaleDate: 100,
|
||||
LastSaleDate: 200,
|
||||
Sticker: domain.Document{
|
||||
ID: 700,
|
||||
AccessHash: 7,
|
||||
DCID: 2,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
gift domain.StarGift
|
||||
wantSoldOut bool
|
||||
wantSaleDate bool
|
||||
}{
|
||||
{name: "unlimited live gift with operational sale history", gift: base},
|
||||
{name: "limited live gift", gift: func() domain.StarGift {
|
||||
gift := base
|
||||
gift.Limited = true
|
||||
gift.AvailabilityRemains = 9
|
||||
gift.AvailabilityTotal = 10
|
||||
return gift
|
||||
}()},
|
||||
{name: "sold out gift", gift: func() domain.StarGift {
|
||||
gift := base
|
||||
gift.Limited = true
|
||||
gift.SoldOut = true
|
||||
gift.AvailabilityTotal = 10
|
||||
return gift
|
||||
}(), wantSoldOut: true, wantSaleDate: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for _, profile := range []tlprofile.Profile{
|
||||
tlprofile.Profile225,
|
||||
tlprofile.Profile226,
|
||||
tlprofile.Profile227,
|
||||
tlprofile.Profile228,
|
||||
} {
|
||||
response := &tg.PaymentsStarGifts{
|
||||
Hash: 1,
|
||||
Gifts: []tg.StarGiftClass{tgStarGift(test.gift)},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, response, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d catalog: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d catalog: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.PaymentsStarGifts)
|
||||
if !ok || len(decoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject)
|
||||
}
|
||||
gift, ok := decoded.Gifts[0].(*tg.StarGift)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0])
|
||||
}
|
||||
if gift.SoldOut != test.wantSoldOut {
|
||||
t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut)
|
||||
}
|
||||
first, firstSet := gift.GetFirstSaleDate()
|
||||
last, lastSet := gift.GetLastSaleDate()
|
||||
if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate {
|
||||
t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate)
|
||||
}
|
||||
if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) {
|
||||
t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1019
internal/rpc/payments_star_gift_lifecycle.go
Normal file
1019
internal/rpc/payments_star_gift_lifecycle.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -26,7 +26,24 @@ func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, i
|
|||
}
|
||||
|
||||
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
|
||||
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
|
||||
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
chargeStars := int64(0)
|
||||
if replay {
|
||||
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid ||
|
||||
receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
chargeStars = receipt.ChargeStars
|
||||
} else {
|
||||
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -34,10 +51,12 @@ func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int
|
|||
if formID == 0 || formID != wantFormID {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
chargeStars = preview.UpgradeStars
|
||||
}
|
||||
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
|
||||
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars,
|
||||
FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails),
|
||||
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
|
||||
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
|
||||
FormID: formID, CommandKey: commandKey,
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
|
|
@ -57,17 +76,32 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift)
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
|
||||
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if replay {
|
||||
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid ||
|
||||
receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
} else {
|
||||
if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if saved.PrepaidUpgradeStars <= 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
}
|
||||
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
|
||||
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
|
||||
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
|
||||
CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails),
|
||||
CommandKey: commandKey,
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
|
|
@ -79,33 +113,60 @@ func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.Payments
|
|||
}
|
||||
|
||||
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
|
||||
if r.deps.Gifts == nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr()
|
||||
}
|
||||
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
|
||||
}
|
||||
if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID {
|
||||
// Channel gift upgrades require a channel pts aggregate and are not silently
|
||||
// routed through the private-message transaction.
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
|
||||
return saved, preview, err
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) {
|
||||
if r.deps.Gifts == nil {
|
||||
return domain.SavedStarGift{}, notImplementedErr()
|
||||
}
|
||||
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.SavedStarGift{}, starGiftInvalidErr()
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil {
|
||||
return domain.SavedStarGift{}, err
|
||||
}
|
||||
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
|
||||
return domain.SavedStarGift{}, internalErr()
|
||||
}
|
||||
if !found || saved.Converted || saved.UniqueGiftID != 0 {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
if !found {
|
||||
return domain.SavedStarGift{}, starGiftInvalidErr()
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) {
|
||||
if saved.Converted || saved.UniqueGiftID != 0 {
|
||||
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
|
||||
return domain.StarGiftUpgradePreview{}, internalErr()
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
}
|
||||
return saved, preview, nil
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef {
|
||||
ref := domain.SavedStarGiftRef{Owner: saved.Owner}
|
||||
if saved.Owner.Type == domain.PeerTypeChannel {
|
||||
ref.SavedID = saved.SavedID
|
||||
} else {
|
||||
ref.MsgID = saved.MsgID
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
|
||||
|
|
@ -116,6 +177,17 @@ func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64
|
|||
updates := tgPrivateMessageUpdates(event, message, 0, false,
|
||||
r.usersForMessageUpdate(ctx, ownerUserID, message),
|
||||
r.chatsForMessageUpdate(ctx, ownerUserID, message))
|
||||
for _, edit := range result.SourceEdits {
|
||||
if edit.UserID != ownerUserID {
|
||||
continue
|
||||
}
|
||||
if update := tgOtherUpdateFromEvent(edit.Event); update != nil {
|
||||
updates.Updates = append(updates.Updates, update)
|
||||
if edit.Event.Date > updates.Date {
|
||||
updates.Date = edit.Event.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeBalance {
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
|
||||
}
|
||||
|
|
@ -175,6 +247,20 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) {
|
||||
if giftID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
|
||||
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
|
||||
return nil, starGiftInvalidErr()
|
||||
|
|
@ -211,6 +297,9 @@ func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (
|
|||
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
|
||||
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
|
||||
for _, attribute := range preview.Models {
|
||||
if attribute.Crafted {
|
||||
continue
|
||||
}
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
for _, attribute := range preview.Patterns {
|
||||
|
|
@ -222,15 +311,25 @@ func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.Sta
|
|||
return out
|
||||
}
|
||||
|
||||
func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
|
||||
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
|
||||
for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} {
|
||||
for _, attribute := range attributes {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
|
||||
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
|
||||
rarity := tgStarGiftAttributeRarity(attribute)
|
||||
switch attribute.Kind {
|
||||
case domain.StarGiftCollectibleModel:
|
||||
document := tg.DocumentClass(&tg.DocumentEmpty{})
|
||||
if attribute.Document != nil {
|
||||
document = tgDocument(*attribute.Document)
|
||||
}
|
||||
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity}
|
||||
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted}
|
||||
case domain.StarGiftCollectiblePattern:
|
||||
document := tg.DocumentClass(&tg.DocumentEmpty{})
|
||||
if attribute.Document != nil {
|
||||
|
|
@ -248,6 +347,21 @@ func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarG
|
|||
}
|
||||
}
|
||||
|
||||
func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass {
|
||||
switch attribute.RarityKind {
|
||||
case domain.StarGiftRarityUncommon:
|
||||
return &tg.StarGiftAttributeRarityUncommon{}
|
||||
case domain.StarGiftRarityRare:
|
||||
return &tg.StarGiftAttributeRarityRare{}
|
||||
case domain.StarGiftRarityEpic:
|
||||
return &tg.StarGiftAttributeRarityEpic{}
|
||||
case domain.StarGiftRarityLegendary:
|
||||
return &tg.StarGiftAttributeRarityLegendary{}
|
||||
default:
|
||||
return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
|
||||
}
|
||||
}
|
||||
|
||||
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
|
||||
attributes := []tg.StarGiftAttributeClass{
|
||||
tgStarGiftAttribute(unique.Model),
|
||||
|
|
@ -268,11 +382,73 @@ func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
|
|||
attributes = append(attributes, original)
|
||||
}
|
||||
out := &tg.StarGiftUnique{
|
||||
RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly,
|
||||
ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted,
|
||||
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
|
||||
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
|
||||
}
|
||||
if owner := tgPeer(unique.Owner); owner != nil {
|
||||
if unique.OwnerAddress != "" {
|
||||
out.SetOwnerAddress(unique.OwnerAddress)
|
||||
} else if owner := tgPeer(unique.Owner); owner != nil {
|
||||
out.SetOwnerID(owner)
|
||||
} else if unique.OwnerName != "" {
|
||||
out.SetOwnerName(unique.OwnerName)
|
||||
}
|
||||
if unique.GiftAddress != "" {
|
||||
out.SetGiftAddress(unique.GiftAddress)
|
||||
}
|
||||
if unique.ResellAmount != nil {
|
||||
out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)})
|
||||
}
|
||||
if peer := tgPeer(unique.ReleasedBy); peer != nil {
|
||||
out.SetReleasedBy(peer)
|
||||
}
|
||||
if unique.ValueAmount > 0 {
|
||||
out.SetValueAmount(unique.ValueAmount)
|
||||
}
|
||||
if unique.ValueCurrency != "" {
|
||||
out.SetValueCurrency(unique.ValueCurrency)
|
||||
}
|
||||
if unique.ValueUSD > 0 {
|
||||
out.SetValueUsdAmount(unique.ValueUSD)
|
||||
}
|
||||
if peer := tgPeer(unique.ThemePeer); peer != nil {
|
||||
out.SetThemePeer(peer)
|
||||
}
|
||||
if peer := tgPeer(unique.Host); peer != nil {
|
||||
out.SetHostID(peer)
|
||||
}
|
||||
if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser {
|
||||
out.SetOfferMinStars(unique.OfferMinStars)
|
||||
}
|
||||
if unique.CraftChancePermille > 0 {
|
||||
out.SetCraftChancePermille(unique.CraftChancePermille)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass {
|
||||
if amount.Currency == domain.StarGiftCurrencyTON {
|
||||
return &tg.StarsTonAmount{Amount: amount.Amount}
|
||||
}
|
||||
return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos}
|
||||
}
|
||||
|
||||
func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) {
|
||||
switch value := amount.(type) {
|
||||
case *tg.StarsAmount:
|
||||
if value == nil {
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos}
|
||||
return out, out.Valid()
|
||||
case *tg.StarsTonAmount:
|
||||
if value == nil {
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount}
|
||||
return out, out.Valid()
|
||||
default:
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
|
@ -80,6 +84,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
|
||||
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
|
||||
return r.starGiftTransferPaymentForm(ctx, userID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
|
||||
return r.starGiftResalePaymentForm(ctx, userID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
|
||||
return r.starGiftAuctionBidPaymentForm(ctx, userID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
|
||||
return r.starGiftPrepaidUpgradePaymentForm(ctx, userID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
|
||||
return r.starGiftDropDetailsPaymentForm(ctx, userID, inv)
|
||||
}
|
||||
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||
if !ok {
|
||||
|
|
@ -92,16 +111,13 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
|
||||
// Channel upgrades remain blocked until they can advance channel pts and
|
||||
// publish a durable channel update. Never collect a prepaid upgrade that
|
||||
// the recipient cannot consume.
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
gift, err := r.starGiftFromCatalog(ctx, inv.GiftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gift.RequirePremium && !r.viewerPremium(ctx, userID) {
|
||||
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
}
|
||||
upgradeStars := int64(0)
|
||||
if inv.IncludeUpgrade {
|
||||
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
|
||||
|
|
@ -109,8 +125,21 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
}
|
||||
upgradeStars = gift.UpgradeStars
|
||||
}
|
||||
giftMessage := ""
|
||||
if m, ok := inv.GetMessage(); ok {
|
||||
giftMessage = clampGiftMessage(m.Text)
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
form, err := r.deps.Gifts.IssuePurchaseForm(ctx, domain.StarGiftPurchaseForm{
|
||||
BuyerUserID: userID, To: peer, GiftID: gift.ID, RevisionID: gift.RevisionID,
|
||||
IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
|
||||
ChargeStars: gift.Stars + upgradeStars, IssuedAt: now, ExpiresAt: now + 600,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starGiftLifecycleErr(err)
|
||||
}
|
||||
return &tg.PaymentsPaymentFormStarGift{
|
||||
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade),
|
||||
FormID: form.FormID,
|
||||
Invoice: tg.Invoice{
|
||||
Currency: "XTR",
|
||||
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}},
|
||||
|
|
@ -139,11 +168,29 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
|
||||
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftTransfer); ok {
|
||||
return r.sendStarGiftTransferForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftResale); ok {
|
||||
return r.sendStarGiftResaleForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftAuctionBid); ok {
|
||||
return r.sendStarGiftAuctionBidForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftPrepaidUpgrade); ok {
|
||||
return r.sendStarGiftPrepaidUpgradeForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftDropOriginalDetails); ok {
|
||||
return r.sendStarGiftDropDetailsForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if req.FormID == 0 {
|
||||
return nil, formIDEmptyErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -151,15 +198,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
if r.deps.Stars == nil || r.deps.Gifts == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
|
|
@ -167,6 +208,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buyerPremium := r.viewerPremium(ctx, userID)
|
||||
if gift.RequirePremium && !buyerPremium {
|
||||
return nil, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
}
|
||||
upgradeStars := int64(0)
|
||||
if inv.IncludeUpgrade {
|
||||
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
|
||||
|
|
@ -174,27 +219,58 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
}
|
||||
upgradeStars = gift.UpgradeStars
|
||||
}
|
||||
if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
giftMessage := ""
|
||||
if m, ok := inv.GetMessage(); ok {
|
||||
giftMessage = clampGiftMessage(m.Text)
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
purchaseReq := domain.StarGiftPurchaseRequest{BuyerUserID: userID, BuyerPremium: buyerPremium, To: peer,
|
||||
GiftID: gift.ID, RevisionID: gift.RevisionID, IncludeUpgrade: inv.IncludeUpgrade, HideName: inv.HideName, Message: giftMessage,
|
||||
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
|
||||
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
|
||||
recipientBlocked := false
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
}
|
||||
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
|
||||
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
|
||||
return nil, starGiftLifecycleErr(err)
|
||||
}
|
||||
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return r.sendStarGiftMemoryPurchase(ctx, userID, peer, gift, inv, giftMessage, upgradeStars)
|
||||
}
|
||||
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
purchaseReq.RecipientBlocked = recipientBlocked
|
||||
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
|
||||
if err != nil {
|
||||
return nil, starGiftLifecycleErr(err)
|
||||
}
|
||||
updates := r.starGiftSendUpdates(ctx, userID, result.Send)
|
||||
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, result.Balance.Balance)
|
||||
r.invalidateStarGiftOwner(peer)
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
// 1. Debit 送礼人(不足→BALANCE_TOO_LOW)。
|
||||
func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift,
|
||||
inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) {
|
||||
purchaseStars := gift.Stars + upgradeStars
|
||||
balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
|
||||
var updates *tg.Updates
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
|
||||
case domain.PeerTypeChannel:
|
||||
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
|
||||
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
|
||||
default:
|
||||
err = domain.ErrStarGiftInvalid
|
||||
}
|
||||
|
|
@ -202,18 +278,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
|
||||
return nil, internalErr()
|
||||
}
|
||||
|
||||
// 4. 构建送礼人 Updates(服务消息 + updateStarsBalance)。
|
||||
if updates != nil {
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
|
||||
} else {
|
||||
updates = &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
}
|
||||
if updates == nil {
|
||||
updates = emptyGiftUpdates(r.clock.Now().Unix())
|
||||
}
|
||||
appendStarGiftBalanceUpdate(updates, domain.StarGiftCurrencyStars, balance.Balance)
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
|
|
@ -295,8 +363,16 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
|
|||
}
|
||||
|
||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
|
||||
prepaidUpgradeHash := ""
|
||||
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
|
||||
var token [32]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
|
||||
}
|
||||
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
|
||||
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars)
|
||||
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -312,6 +388,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
|
|||
Unsaved: false,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
PrepaidUpgradeStars: prepaidUpgradeStars,
|
||||
PrepaidUpgradeHash: prepaidUpgradeHash,
|
||||
Message: message,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -324,7 +401,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
|
|||
return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
|
||||
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
|
||||
now := int(r.clock.Now().Unix())
|
||||
sticker := gift.Sticker
|
||||
action := domain.ChannelMessageAction{
|
||||
|
|
@ -339,9 +416,10 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
FromUserID: senderID,
|
||||
NameHidden: hideName,
|
||||
Saved: true,
|
||||
CanUpgrade: false,
|
||||
PrepaidUpgrade: false,
|
||||
UpgradeStars: 0,
|
||||
CanUpgrade: gift.UpgradeStars > 0,
|
||||
PrepaidUpgrade: prepaidUpgradeStars > 0,
|
||||
UpgradePriceStars: gift.UpgradeStars,
|
||||
UpgradeStars: prepaidUpgradeStars,
|
||||
},
|
||||
}
|
||||
savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
|
|
@ -355,6 +433,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
NameHidden: hideName,
|
||||
Unsaved: false,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
PrepaidUpgradeStars: prepaidUpgradeStars,
|
||||
Message: message,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -375,7 +454,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
}
|
||||
|
||||
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。
|
||||
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) {
|
||||
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64, prepaidUpgradeHash string) (domain.SendPrivateTextResult, error) {
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
|
|
@ -399,7 +478,9 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
|
|||
Saved: true,
|
||||
CanUpgrade: gift.UpgradeStars > 0,
|
||||
PrepaidUpgrade: prepaidUpgradeStars > 0,
|
||||
UpgradeStars: gift.UpgradeStars,
|
||||
PrepaidUpgradeHash: prepaidUpgradeHash,
|
||||
UpgradePriceStars: gift.UpgradeStars,
|
||||
UpgradeStars: prepaidUpgradeStars,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -529,13 +610,15 @@ func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSav
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// onPaymentsConvertStarGift 把收到的礼物转换回 Stars(Credit + 标记 converted)。
|
||||
// onPaymentsConvertStarGift atomically destroys the regular gift and credits
|
||||
// the owner-scoped internal Stars ledger. Channel proceeds never leak to the
|
||||
// acting administrator's personal balance.
|
||||
func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if r.deps.Gifts == nil || r.deps.Stars == nil {
|
||||
if r.deps.Gifts == nil {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
||||
|
|
@ -545,10 +628,42 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
|||
if !ok {
|
||||
return false, starGiftInvalidErr()
|
||||
}
|
||||
if dref.Owner.Type == domain.PeerTypeChannel {
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, dref.Owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// The isolated memory RPC adapter intentionally has no aggregate store. Keep
|
||||
// its conversion primitive usable for tests, but never use this split write
|
||||
// path when the production lifecycle coordinator is configured. Channel
|
||||
// balances have no memory adapter because crediting an administrator would
|
||||
// violate owner-scoped accounting.
|
||||
if converter, ok := r.deps.Gifts.(interface {
|
||||
AtomicPurchaseConfigured() bool
|
||||
Convert(context.Context, domain.SavedStarGiftRef) (domain.SavedStarGift, error)
|
||||
}); ok && !converter.AtomicPurchaseConfigured() {
|
||||
if dref.Owner.Type != domain.PeerTypeUser || dref.Owner.ID != userID {
|
||||
return false, notImplementedErr()
|
||||
}
|
||||
saved, err := r.deps.Gifts.Convert(ctx, dref)
|
||||
updated, convertErr := converter.Convert(ctx, dref)
|
||||
if convertErr != nil {
|
||||
if errors.Is(convertErr, domain.ErrStarGiftNotFound) || errors.Is(convertErr, domain.ErrStarGiftAlreadyConverted) {
|
||||
return false, starGiftInvalidErr()
|
||||
}
|
||||
return false, internalErr()
|
||||
}
|
||||
if updated.ConvertStars > 0 {
|
||||
if _, creditErr := r.deps.Stars.Credit(ctx, userID, updated.ConvertStars, domain.StarsReasonGift,
|
||||
dref.Owner, "Star gift conversion", "Converted Star Gift"); creditErr != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(dref.Owner)
|
||||
return true, nil
|
||||
}
|
||||
result, err := r.deps.Gifts.ConvertAggregate(ctx, domain.StarGiftConvertRequest{
|
||||
ActorUserID: userID,
|
||||
Ref: dref,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound):
|
||||
|
|
@ -559,15 +674,8 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
|||
return false, internalErr()
|
||||
}
|
||||
}
|
||||
if saved.ConvertStars > 0 {
|
||||
fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID}
|
||||
if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil {
|
||||
r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err))
|
||||
return false, internalErr()
|
||||
}
|
||||
}
|
||||
// 转换移除一份展示礼物 → 失效 owner full 投影。
|
||||
r.invalidateStarGiftOwnerProjection(dref.Owner)
|
||||
r.invalidateStarGiftOwnerProjection(result.Saved.Owner)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +730,24 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
|
|||
return domain.SavedStarGiftRef{}, false, peerIDInvalidErr()
|
||||
}
|
||||
return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil
|
||||
case *tg.InputSavedStarGiftSlug:
|
||||
if v == nil || r.deps.Gifts == nil {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
slug := strings.ToLower(strings.TrimSpace(v.Slug))
|
||||
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return domain.SavedStarGiftRef{}, false, internalErr()
|
||||
}
|
||||
if !found || unique.Slug == "" || unique.Owner.ID == 0 ||
|
||||
(unique.Owner.Type != domain.PeerTypeUser && unique.Owner.Type != domain.PeerTypeChannel) {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
resolved := domain.SavedStarGiftRef{Owner: unique.Owner, Slug: strings.ToLower(strings.TrimSpace(unique.Slug))}
|
||||
return resolved, resolved.Valid(), nil
|
||||
default:
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
|
|
@ -718,11 +844,6 @@ func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gif
|
|||
if gift.UniqueGiftID != 0 {
|
||||
continue
|
||||
}
|
||||
if gift.Owner.Type != domain.PeerTypeUser {
|
||||
// Channel upgrade RPCs are deliberately blocked until the channel pts
|
||||
// aggregate exists, so do not advertise a dead-end action.
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[gift.GiftID]; ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -755,10 +876,24 @@ func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass {
|
|||
// tgStarGift 把目录项投影为 tg.StarGift(Sticker 须为带 sticker 属性的有效 Document)。
|
||||
func tgStarGift(g domain.StarGift) *tg.StarGift {
|
||||
gift := &tg.StarGift{
|
||||
ID: g.ID,
|
||||
Sticker: tgDocument(g.Sticker),
|
||||
Stars: g.Stars,
|
||||
ConvertStars: g.ConvertStars,
|
||||
Limited: g.Limited, SoldOut: g.SoldOut, Birthday: g.Birthday,
|
||||
RequirePremium: g.RequirePremium, LimitedPerUser: g.LimitedPerUser,
|
||||
PeerColorAvailable: g.PeerColorAvailable, Auction: g.Auction,
|
||||
ID: g.ID, Sticker: tgDocument(g.Sticker), Stars: g.Stars, ConvertStars: g.ConvertStars,
|
||||
}
|
||||
if g.Limited {
|
||||
gift.SetAvailabilityRemains(g.AvailabilityRemains)
|
||||
gift.SetAvailabilityTotal(g.AvailabilityTotal)
|
||||
}
|
||||
if g.AvailabilityResale > 0 {
|
||||
gift.SetAvailabilityResale(g.AvailabilityResale)
|
||||
}
|
||||
// sold_out, first_sale_date and last_sale_date share TL flags.1. The store
|
||||
// retains sale timestamps for live gifts as operational facts, but exposing
|
||||
// either timestamp would make every client decode the gift as sold out.
|
||||
if g.SoldOut {
|
||||
gift.SetFirstSaleDate(g.FirstSaleDate)
|
||||
gift.SetLastSaleDate(g.LastSaleDate)
|
||||
}
|
||||
if g.Title != "" {
|
||||
gift.SetTitle(g.Title)
|
||||
|
|
@ -766,6 +901,31 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
|
|||
if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
|
||||
gift.SetUpgradeStars(g.UpgradeStars)
|
||||
}
|
||||
if g.ResellMinStars > 0 {
|
||||
gift.SetResellMinStars(g.ResellMinStars)
|
||||
}
|
||||
if releasedBy := tgPeer(g.ReleasedBy); releasedBy != nil {
|
||||
gift.SetReleasedBy(releasedBy)
|
||||
}
|
||||
if g.LimitedPerUser {
|
||||
gift.SetPerUserTotal(g.PerUserTotal)
|
||||
gift.SetPerUserRemains(g.PerUserRemains)
|
||||
}
|
||||
if g.LockedUntilDate > 0 {
|
||||
gift.SetLockedUntilDate(g.LockedUntilDate)
|
||||
}
|
||||
if g.Auction {
|
||||
gift.SetAuctionSlug(g.AuctionSlug)
|
||||
gift.SetGiftsPerRound(g.GiftsPerRound)
|
||||
gift.SetAuctionStartDate(g.AuctionStartDate)
|
||||
}
|
||||
if g.UpgradeVariants > 0 {
|
||||
gift.SetUpgradeVariants(g.UpgradeVariants)
|
||||
}
|
||||
if g.Background != nil {
|
||||
gift.SetBackground(tg.StarGiftBackground{CenterColor: g.Background.CenterColor,
|
||||
EdgeColor: g.Background.EdgeColor, TextColor: g.Background.TextColor})
|
||||
}
|
||||
return gift
|
||||
}
|
||||
|
||||
|
|
@ -787,6 +947,9 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
|
|||
if in.Title != "" {
|
||||
gift.SetTitle(in.Title)
|
||||
}
|
||||
if in.UpgradePriceStars > 0 {
|
||||
gift.SetUpgradeStars(in.UpgradePriceStars)
|
||||
}
|
||||
action := &tg.MessageActionStarGift{Gift: gift}
|
||||
if in.NameHidden {
|
||||
action.NameHidden = true
|
||||
|
|
@ -799,12 +962,26 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
|
|||
}
|
||||
action.CanUpgrade = in.CanUpgrade
|
||||
action.PrepaidUpgrade = in.PrepaidUpgrade
|
||||
action.UpgradeSeparate = in.UpgradeSeparate
|
||||
action.AuctionAcquired = in.AuctionAcquired
|
||||
if in.UpgradeStars > 0 {
|
||||
action.SetUpgradeStars(in.UpgradeStars)
|
||||
}
|
||||
if in.UpgradeMsgID > 0 {
|
||||
action.SetUpgradeMsgID(in.UpgradeMsgID)
|
||||
}
|
||||
if in.PrepaidUpgradeHash != "" {
|
||||
action.SetPrepaidUpgradeHash(in.PrepaidUpgradeHash)
|
||||
}
|
||||
if in.GiftMsgID > 0 {
|
||||
action.SetGiftMsgID(in.GiftMsgID)
|
||||
}
|
||||
if in.GiftNum > 0 {
|
||||
action.SetGiftNum(in.GiftNum)
|
||||
}
|
||||
if to := tgPeer(in.To); to != nil {
|
||||
action.SetToID(to)
|
||||
}
|
||||
if in.ConvertStars > 0 {
|
||||
action.SetConvertStars(in.ConvertStars)
|
||||
}
|
||||
|
|
@ -889,6 +1066,9 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
|||
item.SetUpgradeStars(g.PrepaidUpgradeStars)
|
||||
item.CanUpgrade = true
|
||||
}
|
||||
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
|
||||
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
|
||||
}
|
||||
}
|
||||
if g.PinnedOrder > 0 {
|
||||
item.PinnedToTop = true
|
||||
|
|
@ -898,6 +1078,8 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
|||
}
|
||||
if g.Unique != nil {
|
||||
item.SetGiftNum(g.Unique.Num)
|
||||
} else if g.GiftNum > 0 {
|
||||
item.SetGiftNum(g.GiftNum)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
|
|
@ -944,24 +1126,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
|
|||
return ids
|
||||
}
|
||||
|
||||
func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 {
|
||||
return starGiftFormIDWithUpgrade(userID, peer, gift, false)
|
||||
}
|
||||
|
||||
func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 {
|
||||
id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654
|
||||
if includeUpgrade {
|
||||
id ^= gift.UpgradeStars<<29 ^ 0x55504752414445
|
||||
}
|
||||
for _, ch := range string(peer.Type) {
|
||||
id = id*131 + int64(ch)
|
||||
}
|
||||
if id == 0 {
|
||||
id = 0x5347
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
|
||||
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
|
||||
for _, ch := range currency {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import (
|
|||
)
|
||||
|
||||
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
|
||||
return starGiftTestRouterWithPremium(t, false)
|
||||
}
|
||||
|
||||
func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router, domain.User, domain.User, domain.StarGift) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -36,7 +40,7 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
|
|||
t.Fatalf("create recipient: %v", err)
|
||||
}
|
||||
gift := domain.StarGift{
|
||||
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
|
||||
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake", RequirePremium: requirePremium,
|
||||
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
|
||||
}
|
||||
giftStore := memory.NewStarGiftStore()
|
||||
|
|
@ -52,6 +56,33 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
|
|||
return r, sender, recipient, gift
|
||||
}
|
||||
|
||||
func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) {
|
||||
r, sender, recipient, gift := starGiftTestRouterWithPremium(t, true)
|
||||
ctx := WithUserID(context.Background(), sender.ID)
|
||||
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
|
||||
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}); !tgerr.Is(err, "PREMIUM_ACCOUNT_REQUIRED") {
|
||||
t.Fatalf("non-premium gift form err = %v, want PREMIUM_ACCOUNT_REQUIRED", err)
|
||||
}
|
||||
premium, ok := r.deps.Users.(UserPremiumService)
|
||||
if !ok {
|
||||
t.Fatalf("users service %T does not implement premium grants", r.deps.Users)
|
||||
}
|
||||
if _, err := premium.GrantPremium(context.Background(), sender.ID, 1); err != nil {
|
||||
t.Fatalf("grant premium: %v", err)
|
||||
}
|
||||
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("premium gift form: %v", err)
|
||||
}
|
||||
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if !ok {
|
||||
t.Fatalf("premium gift form = %T", formRes)
|
||||
}
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
|
||||
t.Fatalf("premium gift purchase: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type uniqueGiftRPCService struct {
|
||||
GiftsService
|
||||
unique domain.UniqueStarGift
|
||||
|
|
@ -61,8 +92,139 @@ func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (dom
|
|||
return s.unique, slug == s.unique.Slug, nil
|
||||
}
|
||||
|
||||
type craftStarGiftRPCService struct {
|
||||
GiftsService
|
||||
uniques map[string]domain.UniqueStarGift
|
||||
saved map[int64]domain.SavedStarGift
|
||||
result domain.StarGiftCraftResult
|
||||
craftReq domain.StarGiftCraftRequest
|
||||
craftCall int
|
||||
}
|
||||
|
||||
func (s *craftStarGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
unique, ok := s.uniques[slug]
|
||||
return unique, ok, nil
|
||||
}
|
||||
|
||||
func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
for _, saved := range s.saved {
|
||||
if saved.Owner != ref.Owner {
|
||||
continue
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
unique, ok := s.uniques[ref.Slug]
|
||||
if ok && unique.ID == saved.UniqueGiftID {
|
||||
return saved, true, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if saved.MsgID == ref.MsgID {
|
||||
return saved, true, nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
|
||||
func (s *craftStarGiftRPCService) Craft(_ context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
|
||||
s.craftCall++
|
||||
s.craftReq = req
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 7102}
|
||||
service := &craftStarGiftRPCService{
|
||||
uniques: map[string]domain.UniqueStarGift{
|
||||
"official-8001-2": {ID: 902, Slug: "official-8001-2", Owner: owner, SourceSavedGiftID: 52},
|
||||
},
|
||||
saved: map[int64]domain.SavedStarGift{
|
||||
50: {ID: 50, Owner: owner, MsgID: 115, UniqueGiftID: 901, UpgradeMsgID: 116},
|
||||
52: {ID: 52, Owner: owner, MsgID: 111, UniqueGiftID: 902, UpgradeMsgID: 112},
|
||||
},
|
||||
result: domain.StarGiftCraftResult{Chance: 500, SourceEdits: []domain.EditedMessageForUser{{
|
||||
UserID: owner.ID,
|
||||
Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100},
|
||||
Event: domain.UpdateEvent{UserID: owner.ID, Type: domain.UpdateEventEditMessage, Pts: 41, PtsCount: 1,
|
||||
Date: 100, Message: domain.Message{ID: 116, OwnerUserID: owner.ID, Peer: owner, From: owner, Date: 100}},
|
||||
}}},
|
||||
}
|
||||
r := New(Config{DC: 2}, Deps{Gifts: service}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
updates, err := r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
&tg.InputSavedStarGiftUser{MsgID: 115},
|
||||
&tg.InputSavedStarGiftSlug{Slug: "OFFICIAL-8001-2"},
|
||||
}})
|
||||
if err != nil || updates == nil {
|
||||
t.Fatalf("craft mixed official refs: updates=%T err=%v", updates, err)
|
||||
}
|
||||
if service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50,52" || len(service.craftReq.Refs) != 2 ||
|
||||
service.craftReq.Refs[1].Slug != "official-8001-2" {
|
||||
t.Fatalf("craft request = %+v calls=%d", service.craftReq, service.craftCall)
|
||||
}
|
||||
full, ok := updates.(*tg.Updates)
|
||||
if !ok || len(full.Updates) != 2 {
|
||||
t.Fatalf("craft failure updates = %T %#v", updates, updates)
|
||||
}
|
||||
if edit, ok := full.Updates[0].(*tg.UpdateEditMessage); !ok || edit.Pts != 41 || edit.PtsCount != 1 {
|
||||
t.Fatalf("craft failure source update = %T %#v", full.Updates[0], full.Updates[0])
|
||||
}
|
||||
if _, ok := full.Updates[1].(*tg.UpdateStarGiftCraftFail); !ok {
|
||||
t.Fatalf("craft terminal update = %T %#v", full.Updates[1], full.Updates[1])
|
||||
}
|
||||
|
||||
service.craftCall = 0
|
||||
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
&tg.InputSavedStarGiftUser{MsgID: 111},
|
||||
&tg.InputSavedStarGiftSlug{Slug: "official-8001-2"},
|
||||
}})
|
||||
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
|
||||
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
|
||||
}
|
||||
|
||||
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
|
||||
&tg.InputSavedStarGiftUser{MsgID: 116},
|
||||
}})
|
||||
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
|
||||
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall)
|
||||
}
|
||||
}
|
||||
|
||||
type upgradeReplayRPCService struct {
|
||||
GiftsService
|
||||
saved domain.SavedStarGift
|
||||
receipt domain.StarGiftUpgradeReceipt
|
||||
result domain.StarGiftUpgradeResult
|
||||
upgradeCalls int
|
||||
previewCalls int
|
||||
lastRequest domain.StarGiftUpgradeRequest
|
||||
}
|
||||
|
||||
func (s *upgradeReplayRPCService) GetSaved(_ context.Context, _ domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
return s.saved, true, nil
|
||||
}
|
||||
|
||||
func (s *upgradeReplayRPCService) UpgradeReceipt(_ context.Context, userID int64, _ string) (domain.StarGiftUpgradeReceipt, bool, error) {
|
||||
if userID != s.receipt.UserID {
|
||||
return domain.StarGiftUpgradeReceipt{}, false, nil
|
||||
}
|
||||
return s.receipt, true, nil
|
||||
}
|
||||
|
||||
func (s *upgradeReplayRPCService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
s.previewCalls++
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
|
||||
func (s *upgradeReplayRPCService) Upgrade(_ context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
s.upgradeCalls++
|
||||
s.lastRequest = req
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
|
||||
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
|
||||
attribute := domain.StarGiftCollectibleAttribute{
|
||||
Kind: kind, Name: name, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
}
|
||||
if kind == domain.StarGiftCollectibleBackdrop {
|
||||
attribute.BackdropID = int(id)
|
||||
attribute.CenterColor = 0x112233
|
||||
|
|
@ -148,6 +310,120 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
|
||||
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
|
||||
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,
|
||||
}).(*tg.MessageActionStarGift)
|
||||
if !ok {
|
||||
t.Fatalf("ordinary action = %T", ordinary)
|
||||
}
|
||||
ordinaryGift, ok := ordinary.Gift.(*tg.StarGift)
|
||||
if !ok {
|
||||
t.Fatalf("ordinary inner gift = %T", ordinary.Gift)
|
||||
}
|
||||
if price, set := ordinaryGift.GetUpgradeStars(); !set || price != 75 {
|
||||
t.Fatalf("ordinary inner upgrade_stars = %d set=%v, want paid price 75", price, set)
|
||||
}
|
||||
if amount, set := ordinary.GetUpgradeStars(); set || amount != 0 || ordinary.PrepaidUpgrade {
|
||||
t.Fatalf("ordinary outer upgrade_stars = %d set=%v prepaid=%v, want absent", amount, set, ordinary.PrepaidUpgrade)
|
||||
}
|
||||
|
||||
prepaid, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
|
||||
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, PrepaidUpgrade: true,
|
||||
UpgradePriceStars: 75, UpgradeStars: 75,
|
||||
}).(*tg.MessageActionStarGift)
|
||||
if !ok {
|
||||
t.Fatalf("prepaid action = %T", prepaid)
|
||||
}
|
||||
if amount, set := prepaid.GetUpgradeStars(); !set || amount != 75 || !prepaid.PrepaidUpgrade {
|
||||
t.Fatalf("prepaid outer upgrade_stars = %d set=%v prepaid=%v, want 75", amount, set, prepaid.PrepaidUpgrade)
|
||||
}
|
||||
upgraded, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
|
||||
GiftID: 8001, Stars: 50, ConvertStars: 25, UpgradeMsgID: 88,
|
||||
}).(*tg.MessageActionStarGift)
|
||||
if !ok {
|
||||
t.Fatalf("upgraded action = %T", upgraded)
|
||||
}
|
||||
if msgID, set := upgraded.GetUpgradeMsgID(); !set || msgID != 88 {
|
||||
t.Fatalf("upgrade_msg_id = %d set=%v, want 88", msgID, set)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, ordinary, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d ordinary action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d ordinary action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionStarGift)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d action = %T", profile, decodedObject)
|
||||
}
|
||||
inner, ok := decoded.Gift.(*tg.StarGift)
|
||||
if !ok || inner.UpgradeStars != 75 || decoded.UpgradeStars != 0 || decoded.PrepaidUpgrade {
|
||||
t.Fatalf("Layer %d ordinary action lost paid/prepaid split: %#v", profile, decoded)
|
||||
}
|
||||
|
||||
upgradedWire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, upgraded, upgradedWire); err != nil {
|
||||
t.Fatalf("encode Layer %d upgraded action: %v", profile, err)
|
||||
}
|
||||
decodedUpgradedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: upgradedWire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d upgraded action: %v", profile, err)
|
||||
}
|
||||
decodedUpgraded, ok := decodedUpgradedObject.(*tg.MessageActionStarGift)
|
||||
if !ok || !decodedUpgraded.Upgraded || decodedUpgraded.UpgradeMsgID != 88 || decodedUpgraded.CanUpgrade {
|
||||
t.Fatalf("Layer %d upgraded action lost transition flags: %#v", profile, decodedUpgradedObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
|
||||
r, sender, owner, gift := starGiftTestRouter(t)
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
saved := domain.SavedStarGift{
|
||||
ID: 47, Owner: ownerPeer, FromUserID: sender.ID, GiftID: gift.ID, RevisionID: gift.RevisionID,
|
||||
MsgID: 105, UniqueGiftID: 9200000000000004,
|
||||
}
|
||||
result := domain.StarGiftUpgradeResult{
|
||||
Saved: saved, Unique: domain.UniqueStarGift{ID: saved.UniqueGiftID, GiftID: gift.ID, Owner: ownerPeer},
|
||||
Balance: domain.StarsBalance{UserID: owner.ID, Balance: 1000}, Duplicate: true,
|
||||
Send: domain.SendPrivateTextResult{
|
||||
RecipientMessage: domain.Message{ID: 107, OwnerUserID: owner.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Date: 1700000001},
|
||||
RecipientEvent: domain.UpdateEvent{UserID: owner.ID, Pts: 42, PtsCount: 1, Date: 1700000001},
|
||||
},
|
||||
}
|
||||
service := &upgradeReplayRPCService{GiftsService: r.deps.Gifts, saved: saved, result: result,
|
||||
receipt: domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
|
||||
UniqueGiftID: saved.UniqueGiftID, RequirePrepaid: true, KeepOriginalDetails: true, BalanceAfter: 1000}}
|
||||
r.deps.Gifts = service
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
if _, err := r.onPaymentsUpgradeStarGift(ctx, &tg.PaymentsUpgradeStarGiftRequest{
|
||||
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
|
||||
}); err != nil {
|
||||
t.Fatalf("replay prepaid upgrade after terminal transition: %v", err)
|
||||
}
|
||||
if service.upgradeCalls != 1 || service.previewCalls != 0 || !service.lastRequest.RequirePrepaid || service.lastRequest.ChargeStars != 0 {
|
||||
t.Fatalf("prepaid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
|
||||
}
|
||||
|
||||
const paidFormID int64 = -7611777087885039132
|
||||
service.receipt = domain.StarGiftUpgradeReceipt{UserID: owner.ID, SourceSavedGiftID: saved.ID,
|
||||
FormID: paidFormID, UniqueGiftID: saved.UniqueGiftID, ChargeStars: 25,
|
||||
KeepOriginalDetails: true, BalanceAfter: 975}
|
||||
service.upgradeCalls, service.previewCalls = 0, 0
|
||||
if _, err := r.sendStarGiftUpgradeForm(ctx, owner.ID, paidFormID, &tg.InputInvoiceStarGiftUpgrade{
|
||||
KeepOriginalDetails: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: saved.MsgID},
|
||||
}); err != nil {
|
||||
t.Fatalf("replay paid upgrade after terminal transition: %v", err)
|
||||
}
|
||||
if service.upgradeCalls != 1 || service.previewCalls != 0 || service.lastRequest.ChargeStars != 25 || service.lastRequest.FormID != paidFormID {
|
||||
t.Fatalf("paid replay calls=%d preview=%d req=%+v", service.upgradeCalls, service.previewCalls, service.lastRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
|
||||
r, sender, owner, gift := starGiftTestRouter(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -157,11 +433,15 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
t.Fatalf("gift service = %T", r.deps.Gifts)
|
||||
}
|
||||
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
|
||||
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8103, "Crafted Aurora")
|
||||
crafted.Crafted = true
|
||||
crafted.RarityKind = domain.StarGiftRarityLegendary
|
||||
crafted.RarityPermille = 0
|
||||
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
|
||||
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
|
||||
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
|
||||
Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
|
||||
Models: []domain.StarGiftCollectibleAttribute{model, crafted}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish collectible pool: %v", err)
|
||||
|
|
@ -177,6 +457,17 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
if err != nil || len(preview.SampleAttributes) != 3 {
|
||||
t.Fatalf("upgrade preview = %#v err %v", preview, err)
|
||||
}
|
||||
attributes, err := r.onPaymentsGetStarGiftUpgradeAttributes(ownerCtx, gift.ID)
|
||||
if err != nil || len(attributes.Attributes) != 4 {
|
||||
t.Fatalf("upgrade attributes = %#v err %v", attributes, err)
|
||||
}
|
||||
craftedTG, ok := attributes.Attributes[1].(*tg.StarGiftAttributeModel)
|
||||
if !ok || !craftedTG.Crafted {
|
||||
t.Fatalf("crafted attribute = %T %#v", attributes.Attributes[1], attributes.Attributes[1])
|
||||
}
|
||||
if _, ok := craftedTG.Rarity.(*tg.StarGiftAttributeRarityLegendary); !ok {
|
||||
t.Fatalf("crafted rarity = %T", craftedTG.Rarity)
|
||||
}
|
||||
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
|
||||
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
|
||||
if err != nil {
|
||||
|
|
@ -208,7 +499,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true,
|
||||
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, SavedID: 444, Upgrade: true, Saved: true,
|
||||
},
|
||||
}}}
|
||||
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
|
||||
|
|
@ -224,6 +515,9 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
|
||||
t.Fatalf("unique service action peer = %#v", peer)
|
||||
}
|
||||
if savedID, ok := action.GetSavedID(); !ok || savedID != 444 {
|
||||
t.Fatalf("unique service action saved_id = %d set=%v, want 444", savedID, ok)
|
||||
}
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
|
||||
responseWire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil {
|
||||
|
|
@ -254,7 +548,7 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
if !ok {
|
||||
t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject)
|
||||
}
|
||||
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
|
||||
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedAction.SavedID != 444 || decodedActionGift.Slug != unique.Slug {
|
||||
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
|
||||
}
|
||||
}
|
||||
|
|
@ -556,10 +850,15 @@ func TestStarGiftChannelSaga(t *testing.T) {
|
|||
}); err != nil {
|
||||
t.Fatalf("publish channel collectible pool: %v", err)
|
||||
}
|
||||
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
|
||||
upgradeFormRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
|
||||
Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true,
|
||||
}}); err == nil {
|
||||
t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked")
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("getPaymentForm(channel include_upgrade): %v", err)
|
||||
}
|
||||
upgradeForm, ok := upgradeFormRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if !ok || len(upgradeForm.Invoice.Prices) != 1 || upgradeForm.Invoice.Prices[0].Amount != gift.Stars+75 {
|
||||
t.Fatalf("channel include_upgrade form = %T %+v, want total %d", upgradeFormRes, upgradeFormRes, gift.Stars+75)
|
||||
}
|
||||
inv := &tg.InputInvoiceStarGift{
|
||||
Peer: channelPeer,
|
||||
|
|
@ -612,8 +911,8 @@ func TestStarGiftChannelSaga(t *testing.T) {
|
|||
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
|
||||
t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
|
||||
}
|
||||
if savedRes.Gifts[0].CanUpgrade {
|
||||
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked")
|
||||
if !savedRes.Gifts[0].CanUpgrade {
|
||||
t.Fatal("channel saved gift must advertise upgrade when a collectible pool is available")
|
||||
}
|
||||
savedID, ok := savedRes.Gifts[0].GetSavedID()
|
||||
if !ok || savedID <= 0 {
|
||||
|
|
@ -728,8 +1027,12 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
|
|||
}, zaptest.NewLogger(t), clock.System)
|
||||
senderCtx := WithUserID(ctx, sender.ID)
|
||||
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
|
||||
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil {
|
||||
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("get expensive gift form: %v", err)
|
||||
}
|
||||
form := formRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "BALANCE_TOO_LOW") {
|
||||
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
|
||||
}
|
||||
// 余额未变。
|
||||
|
|
@ -738,22 +1041,60 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
|
||||
func TestStarGiftPurchaseFormsAreFreshAndBindPurpose(t *testing.T) {
|
||||
r, sender, recipient, gift := starGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), sender.ID)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
|
||||
base := starGiftFormID(sender.ID, peer, gift)
|
||||
changedRevision := gift
|
||||
changedRevision.RevisionID++
|
||||
changedPrice := gift
|
||||
changedPrice.Stars++
|
||||
changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1}
|
||||
if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) {
|
||||
t.Fatal("star gift form id must bind revision, price and recipient")
|
||||
}
|
||||
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("bad form err=%v", err)
|
||||
firstRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("first form: %v", err)
|
||||
}
|
||||
secondRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("second form: %v", err)
|
||||
}
|
||||
first := firstRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
second := secondRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if first.FormID == 0 || second.FormID == 0 || first.FormID == second.FormID {
|
||||
t.Fatalf("fresh form ids = %d/%d, want distinct non-zero TL longs", first.FormID, second.FormID)
|
||||
}
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID + second.FormID, Invoice: inv}); !tgerr.Is(err, "FORM_EXPIRED") {
|
||||
t.Fatalf("unknown form err=%v, want FORM_EXPIRED", err)
|
||||
}
|
||||
tampered := *inv
|
||||
tampered.HideName = true
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: first.FormID, Invoice: &tampered}); !tgerr.Is(err, "PURPOSE_INVALID") {
|
||||
t.Fatalf("tampered form err=%v, want PURPOSE_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCanPurchaseSameCatalogGiftTwice(t *testing.T) {
|
||||
r, sender, recipient, gift := starGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), sender.ID)
|
||||
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
|
||||
var formIDs []int64
|
||||
for i := 0; i < 2; i++ {
|
||||
formRes, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("get form %d: %v", i, err)
|
||||
}
|
||||
form := formRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
formIDs = append(formIDs, form.FormID)
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); err != nil {
|
||||
t.Fatalf("purchase %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if formIDs[0] == formIDs[1] {
|
||||
t.Fatalf("repeated purchase reused form id %d", formIDs[0])
|
||||
}
|
||||
saved, err := r.onPaymentsGetSavedStarGifts(WithUserID(context.Background(), recipient.ID), &tg.PaymentsGetSavedStarGiftsRequest{
|
||||
Peer: &tg.InputPeerSelf{}, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get recipient gifts: %v", err)
|
||||
}
|
||||
if saved.Count != 2 || len(saved.Gifts) != 2 {
|
||||
t.Fatalf("recipient gifts = count %d len %d, want two independent gifts", saved.Count, len(saved.Gifts))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,3 +98,113 @@ func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
|
|||
}
|
||||
_ = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
|
||||
type channelLedgerGifts struct {
|
||||
GiftsService
|
||||
starsBalance int64
|
||||
tonBalance int64
|
||||
starsPage domain.StarsTransactionPage
|
||||
tonPage domain.TonTransactionPage
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) {
|
||||
return s.starsBalance, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, string, int) (domain.StarsTransactionPage, error) {
|
||||
return s.starsPage, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) {
|
||||
return s.tonBalance, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, string, int) (domain.TonTransactionPage, error) {
|
||||
return s.tonPage, nil
|
||||
}
|
||||
|
||||
type channelLedgerChannels struct {
|
||||
ChannelsService
|
||||
view domain.ChannelView
|
||||
}
|
||||
|
||||
func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
|
||||
return s.view, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) {
|
||||
return []domain.ChannelView{s.view}, nil
|
||||
}
|
||||
|
||||
func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) {
|
||||
const viewerID, channelID int64 = 1000000001, 2000000001
|
||||
view := domain.ChannelView{
|
||||
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID},
|
||||
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive},
|
||||
}
|
||||
gifts := &channelLedgerGifts{
|
||||
starsBalance: 20,
|
||||
tonBalance: 900,
|
||||
starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{
|
||||
ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift,
|
||||
}}},
|
||||
tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{
|
||||
ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale,
|
||||
}}},
|
||||
}
|
||||
r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), viewerID)
|
||||
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}
|
||||
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel stars status: %v", err)
|
||||
}
|
||||
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 {
|
||||
t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats))
|
||||
}
|
||||
revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel stars revenue: %v", err)
|
||||
}
|
||||
if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 {
|
||||
t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance)
|
||||
}
|
||||
if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled {
|
||||
t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled)
|
||||
}
|
||||
|
||||
txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20}
|
||||
txnReq.SetTon(true)
|
||||
transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel ton transactions: %v", err)
|
||||
}
|
||||
history, ok := transactions.GetHistory()
|
||||
if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale {
|
||||
t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history)
|
||||
}
|
||||
revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}
|
||||
revenueReq.SetTon(true)
|
||||
tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel ton revenue: %v", err)
|
||||
}
|
||||
if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 {
|
||||
t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) {
|
||||
const viewerID, channelID int64 = 1000000001, 2000000001
|
||||
view := domain.ChannelView{
|
||||
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true},
|
||||
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
|
||||
}
|
||||
r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), viewerID)
|
||||
_, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}})
|
||||
if err == nil {
|
||||
t.Fatal("non-admin channel ledger read unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (do
|
|||
func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.createCatalogRevisionLocked(write)
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) createCatalogRevisionLocked(write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
giftID := write.GiftID
|
||||
if giftID == 0 {
|
||||
s.nextGiftID++
|
||||
|
|
@ -104,7 +108,21 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St
|
|||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.nextRevID++
|
||||
gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document}
|
||||
gift := domain.StarGift{
|
||||
ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars,
|
||||
Title: write.Title, Sticker: write.Document,
|
||||
Limited: write.Limited, SoldOut: write.SoldOut, Birthday: write.Birthday,
|
||||
RequirePremium: write.RequirePremium, LimitedPerUser: write.LimitedPerUser,
|
||||
PeerColorAvailable: write.PeerColorAvailable, Auction: write.Auction,
|
||||
AvailabilityRemains: write.AvailabilityRemains, AvailabilityTotal: write.AvailabilityTotal,
|
||||
AvailabilityResale: write.AvailabilityResale, FirstSaleDate: write.FirstSaleDate,
|
||||
LastSaleDate: write.LastSaleDate, ResellMinStars: write.ResellMinStars,
|
||||
ReleasedBy: write.ReleasedBy, PerUserTotal: write.PerUserTotal,
|
||||
PerUserRemains: write.PerUserTotal, LockedUntilDate: write.LockedUntilDate,
|
||||
AuctionSlug: write.AuctionSlug, GiftsPerRound: write.GiftsPerRound,
|
||||
AuctionStartDate: write.AuctionStartDate, UpgradeVariants: write.UpgradeVariants,
|
||||
Background: cloneStarGiftBackground(write.Background),
|
||||
}
|
||||
s.catalog[giftID] = gift
|
||||
s.revisions[gift.RevisionID] = gift
|
||||
s.enabled[giftID] = write.Enabled
|
||||
|
|
@ -113,6 +131,45 @@ func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.St
|
|||
return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil
|
||||
}
|
||||
|
||||
func cloneStarGiftBackground(value *domain.StarGiftBackground) *domain.StarGiftBackground {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if write.Collectible != nil {
|
||||
collectibleWrite := *write.Collectible
|
||||
collectibleWrite.GiftID = write.Catalog.GiftID
|
||||
if collectibleWrite.GiftID == 0 {
|
||||
collectibleWrite.GiftID = s.nextGiftID + 1
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleWrite(collectibleWrite); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
entry, err := s.createCatalogRevisionLocked(write.Catalog)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
|
||||
if write.Collectible != nil {
|
||||
collectibleWrite := *write.Collectible
|
||||
collectibleWrite.GiftID = entry.Gift.ID
|
||||
revision, err := s.publishCollectibleRevisionLocked(collectibleWrite)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
result.Collectible = &revision
|
||||
result.Catalog.Gift = s.catalog[entry.Gift.ID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -148,6 +205,10 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma
|
|||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.publishCollectibleRevisionLocked(write)
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) publishCollectibleRevisionLocked(write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if _, ok := s.catalog[write.GiftID]; !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
|
@ -157,6 +218,7 @@ func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write doma
|
|||
UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal,
|
||||
SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true,
|
||||
CreatedBy: write.Actor,
|
||||
OfficialGiftID: write.OfficialGiftID, SourceManifestSHA256: append([]byte(nil), write.SourceManifestSHA256...),
|
||||
}
|
||||
if revision.ID == 1 {
|
||||
revision.ID = write.GiftID*1000 + 1
|
||||
|
|
@ -275,6 +337,7 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in
|
|||
gift.SavedID = gift.ID
|
||||
}
|
||||
gift.Converted = false
|
||||
gift.LifecycleStatus = domain.StarGiftLifecycleActive
|
||||
s.gifts = append(s.gifts, gift)
|
||||
return gift.ID, nil
|
||||
}
|
||||
|
|
@ -297,7 +360,7 @@ func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.Sav
|
|||
defer s.mu.Unlock()
|
||||
matched := make([]domain.SavedStarGift, 0)
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner != owner || g.Converted {
|
||||
if g.Owner != owner || !g.LifecycleStatus.Live() {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnsaved && g.Unsaved {
|
||||
|
|
@ -370,7 +433,7 @@ func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, re
|
|||
}
|
||||
var id int64
|
||||
for _, gift := range s.gifts {
|
||||
if savedStarGiftMatchesRef(gift, ref) && !gift.Converted {
|
||||
if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() {
|
||||
id = gift.ID
|
||||
break
|
||||
}
|
||||
|
|
@ -394,7 +457,7 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, g := range s.gifts {
|
||||
if savedStarGiftMatchesRef(g, ref) {
|
||||
if s.savedStarGiftMatchesRef(g, ref) {
|
||||
return g, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -409,7 +472,7 @@ func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int,
|
|||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner == owner && !g.Converted && !g.Unsaved {
|
||||
if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
|
@ -423,7 +486,7 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -438,7 +501,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.gifts[i].UniqueGiftID != 0 {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
|
|
@ -446,6 +509,7 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
|
|||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
s.gifts[i].Converted = true
|
||||
s.gifts[i].LifecycleStatus = domain.StarGiftLifecycleConverted
|
||||
s.gifts[i].Unsaved = true
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for collectionIndex := range s.collections[ref.Owner] {
|
||||
|
|
@ -640,7 +704,7 @@ func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []in
|
|||
}
|
||||
valid := false
|
||||
for _, gift := range s.gifts {
|
||||
if gift.ID == id && gift.Owner == owner && !gift.Converted {
|
||||
if gift.ID == id && gift.Owner == owner && gift.LifecycleStatus.Live() {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
|
|
@ -707,6 +771,7 @@ func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.St
|
|||
|
||||
func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision {
|
||||
out := in
|
||||
out.SourceManifestSHA256 = append([]byte(nil), in.SourceManifestSHA256...)
|
||||
clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute {
|
||||
copy := make([]domain.StarGiftCollectibleAttribute, len(attributes))
|
||||
for i, attribute := range attributes {
|
||||
|
|
@ -747,10 +812,14 @@ func validStarGiftOwner(owner domain.Peer) bool {
|
|||
return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool {
|
||||
func (s *StarGiftStore) savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool {
|
||||
if g.Owner != ref.Owner {
|
||||
return false
|
||||
}
|
||||
if ref.Slug != "" {
|
||||
uniqueID, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(ref.Slug))]
|
||||
return ok && uniqueID != 0 && g.UniqueGiftID == uniqueID
|
||||
}
|
||||
switch ref.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return g.MsgID == ref.MsgID
|
||||
|
|
|
|||
41
internal/store/memory/star_gift_identity_test.go
Normal file
41
internal/store/memory/star_gift_identity_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSavedStarGiftIdentityDoesNotAcceptUpgradeMessageID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
store := NewStarGiftStore()
|
||||
id, err := store.Create(ctx, domain.SavedStarGift{
|
||||
Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 115,
|
||||
UniqueGiftID: 901, UpgradeMsgID: 116,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create saved gift: %v", err)
|
||||
}
|
||||
store.uniqueBySlug["official-8001-1"] = 901
|
||||
|
||||
canonical := domain.SavedStarGiftRef{Owner: owner, MsgID: 115}
|
||||
if saved, found, err := store.GetByRef(ctx, canonical); err != nil || !found || saved.ID != id {
|
||||
t.Fatalf("canonical identity: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
wrong := domain.SavedStarGiftRef{Owner: owner, MsgID: 116}
|
||||
if saved, found, err := store.GetByRef(ctx, wrong); err != nil || found {
|
||||
t.Fatalf("upgrade message id resolved gift: saved=%+v found=%v err=%v", saved, found, err)
|
||||
}
|
||||
if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{wrong}); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("upgrade message id resolve err=%v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
if _, err := store.ResolveSavedIDs(ctx, owner, []domain.SavedStarGiftRef{
|
||||
canonical,
|
||||
{Owner: owner, Slug: "official-8001-1"},
|
||||
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("duplicate official identities err=%v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
value := int64(ref.MsgID)
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
column = "saved_id"
|
||||
value = ref.SavedID
|
||||
}
|
||||
if _, duplicate := seenValues[value]; duplicate {
|
||||
if ref.Slug != "" {
|
||||
slug := strings.ToLower(strings.TrimSpace(ref.Slug))
|
||||
key := "slug:" + slug
|
||||
if _, duplicate := seenKeys[key]; duplicate {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seenValues[value] = struct{}{}
|
||||
seenKeys[key] = struct{}{}
|
||||
keys = append(keys, resolveKey{slug: slug})
|
||||
slugs = append(slugs, slug)
|
||||
continue
|
||||
}
|
||||
value := int64(ref.MsgID)
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
value = ref.SavedID
|
||||
}
|
||||
key := fmt.Sprintf("id:%d", value)
|
||||
if _, duplicate := seenKeys[key]; duplicate {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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,
|
||||
attribute.RarityPermille, attribute.SortOrder); err != nil {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
1094
internal/store/postgres/star_gift_craft_auction.go
Normal file
1094
internal/store/postgres/star_gift_craft_auction.go
Normal file
File diff suppressed because it is too large
Load diff
227
internal/store/postgres/star_gift_craft_projection.go
Normal file
227
internal/store/postgres/star_gift_craft_projection.go
Normal 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
|
||||
}
|
||||
282
internal/store/postgres/star_gift_entitlements.go
Normal file
282
internal/store/postgres/star_gift_entitlements.go
Normal 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
|
||||
}
|
||||
1693
internal/store/postgres/star_gift_lifecycle.go
Normal file
1693
internal/store/postgres/star_gift_lifecycle.go
Normal file
File diff suppressed because it is too large
Load diff
827
internal/store/postgres/star_gift_lifecycle_integration_test.go
Normal file
827
internal/store/postgres/star_gift_lifecycle_integration_test.go
Normal 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(®ularLogs); 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(®ularLogs); 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
344
internal/store/postgres/star_gift_purchase.go
Normal file
344
internal/store/postgres/star_gift_purchase.go
Normal 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)))
|
||||
}
|
||||
|
|
@ -23,16 +23,36 @@ import (
|
|||
type StarGiftUpgradeStore struct {
|
||||
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,
|
||||
},
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ type StarGiftStore interface {
|
|||
CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
|
||||
// CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。
|
||||
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
|
||||
// CreateCatalogBundle atomically switches the catalog revision and optional complete
|
||||
// collectible revision. It is the only write path used by official full imports.
|
||||
CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error)
|
||||
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
|
||||
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
|
||||
// AnimationJSON 返回当前版本的规范化 Lottie JSON,供管理后台安全预览。
|
||||
|
|
@ -60,4 +63,44 @@ type StarGiftStore interface {
|
|||
// private service-message updates.
|
||||
type StarGiftUpgradeStore interface {
|
||||
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
|
||||
StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
|
||||
}
|
||||
|
||||
// StarGiftLifecycleStore owns transactions that span collectible ownership, listings,
|
||||
// balances and service-message updates. Implementations must serialize on the saved/unique
|
||||
// aggregate and return exact replays for command-key/random-id retries.
|
||||
type StarGiftLifecycleStore interface {
|
||||
IssueStarGiftPurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
|
||||
ValidateStarGiftPurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
|
||||
PurchaseStarGift(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
|
||||
ConvertStarGift(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error)
|
||||
ListResaleStarGifts(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
|
||||
UniqueStarGiftValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
|
||||
SetStarGiftListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
|
||||
TransferStarGift(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
|
||||
PurchaseResaleStarGift(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
|
||||
SendStarGiftOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ResolveStarGiftOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ListCraftStarGifts(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
|
||||
CraftStarGift(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
|
||||
StarGiftAuctionState(ctx context.Context, userID int64, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
|
||||
ActiveStarGiftAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
|
||||
StarGiftAuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
|
||||
BidStarGiftAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
|
||||
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
|
||||
PrepayStarGiftUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
|
||||
DropStarGiftOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
|
||||
SetStarGiftNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
|
||||
RecordStarGiftWithdrawal(ctx context.Context, req domain.StarGiftWithdrawalRequest, provider, providerRequestID, url string, expiresAt int) (domain.StarGiftWithdrawal, error)
|
||||
ResolveStarGiftWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
|
||||
CompleteStarGiftWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
|
||||
TonBalance(ctx context.Context, userID int64) (int64, error)
|
||||
TonTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.TonTransactionPage, error)
|
||||
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelStarsTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.StarsTransactionPage, error)
|
||||
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelTonTransactions(ctx context.Context, channelID int64, offset string, limit int) (domain.TonTransactionPage, error)
|
||||
// SweepStarGiftLifecycle advances time-driven offer/auction aggregates and
|
||||
// drains their durable notification/delivery outboxes in bounded batches.
|
||||
SweepStarGiftLifecycle(ctx context.Context, now, limit int) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ type Config struct {
|
|||
Channels PublicChannelResolver
|
||||
Privacy AnonymousPrivacyResolver
|
||||
Photos ProfilePhotoResolver
|
||||
UniqueGifts UniqueStarGiftResolver
|
||||
GiftWithdrawals StarGiftWithdrawalResolver
|
||||
}
|
||||
|
||||
type StickerSetResolver interface {
|
||||
|
|
@ -57,6 +59,15 @@ type ProfilePhotoResolver interface {
|
|||
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
|
||||
}
|
||||
|
||||
type UniqueStarGiftResolver interface {
|
||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalResolver interface {
|
||||
ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
|
||||
CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
|
||||
addr := strings.TrimSpace(cfg.Addr)
|
||||
if addr == "" {
|
||||
|
|
@ -137,6 +148,8 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
|
|||
channels: cfg.Channels,
|
||||
privacy: cfg.Privacy,
|
||||
photos: cfg.Photos,
|
||||
uniqueGifts: cfg.UniqueGifts,
|
||||
giftWithdrawals: cfg.GiftWithdrawals,
|
||||
publicBaseURL: cfg.PublicBaseURL,
|
||||
appScheme: cfg.AppScheme,
|
||||
webBaseURL: cfg.WebBaseURL,
|
||||
|
|
@ -149,6 +162,10 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
|
|||
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
|
||||
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
|
||||
mux.HandleFunc("GET /addlist/{slug}", h.addList)
|
||||
mux.HandleFunc("GET /nft/{slug}", h.uniqueGift)
|
||||
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
|
||||
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
|
||||
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
|
||||
mux.HandleFunc("GET /{username}", h.usernameLink)
|
||||
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
|
||||
return publicSecurityHeaders(mux), nil
|
||||
|
|
@ -160,6 +177,8 @@ type handler struct {
|
|||
channels PublicChannelResolver
|
||||
privacy AnonymousPrivacyResolver
|
||||
photos ProfilePhotoResolver
|
||||
uniqueGifts UniqueStarGiftResolver
|
||||
giftWithdrawals StarGiftWithdrawalResolver
|
||||
publicBaseURL string
|
||||
appScheme string
|
||||
webBaseURL string
|
||||
|
|
@ -167,6 +186,65 @@ type handler struct {
|
|||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type starGiftWithdrawalPage struct {
|
||||
AppName string
|
||||
Title string
|
||||
Slug string
|
||||
Status string
|
||||
OwnerAddress string
|
||||
GiftAddress string
|
||||
ExpiresAt string
|
||||
CanComplete bool
|
||||
}
|
||||
|
||||
var starGiftWithdrawalTemplate = template.Must(template.New("star-gift-withdrawal").Parse(`<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}} · {{.AppName}}</title><style>
|
||||
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:32px}.card{max-width:560px;margin:8vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.meta{overflow-wrap:anywhere;color:#53606d}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.done{color:#18864b;font-weight:600}
|
||||
</style></head><body><main class="card"><h1>{{.Title}}</h1><p class="meta">Collectible: {{.Slug}}</p>
|
||||
{{if .CanComplete}}<p>This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.</p><form method="post"><button type="submit">Complete local export</button></form><p class="meta">Expires: {{.ExpiresAt}}</p>{{else}}<p class="done">Status: {{.Status}}</p>{{if .OwnerAddress}}<p class="meta">Owner address: {{.OwnerAddress}}</p><p class="meta">Gift address: {{.GiftAddress}}</p>{{end}}{{end}}
|
||||
</main></body></html>`))
|
||||
|
||||
func (h *handler) starGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderStarGiftWithdrawal(w, r, false)
|
||||
}
|
||||
|
||||
func (h *handler) completeStarGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderStarGiftWithdrawal(w, r, true)
|
||||
}
|
||||
|
||||
func (h *handler) renderStarGiftWithdrawal(w http.ResponseWriter, r *http.Request, complete bool) {
|
||||
requestID := strings.TrimSpace(r.PathValue("requestID"))
|
||||
if h.giftWithdrawals == nil || requestID == "" || len(requestID) > 256 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var withdrawal domain.StarGiftWithdrawal
|
||||
var found bool
|
||||
var err error
|
||||
if complete {
|
||||
withdrawal, err = h.giftWithdrawals.CompleteWithdrawal(r.Context(), requestID, int(time.Now().Unix()))
|
||||
found = err == nil
|
||||
} else {
|
||||
withdrawal, found, err = h.giftWithdrawals.ResolveWithdrawal(r.Context(), requestID)
|
||||
}
|
||||
if err != nil || !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
page := starGiftWithdrawalPage{AppName: h.appName, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug,
|
||||
Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress,
|
||||
ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339),
|
||||
CanComplete: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix())}
|
||||
if page.Title == "" {
|
||||
page.Title = "Collectible gift export"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := starGiftWithdrawalTemplate.Execute(w, page); err != nil {
|
||||
h.logger.Warn("render star gift withdrawal", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
|
|
@ -205,6 +283,63 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *handler) uniqueGift(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if h.uniqueGifts == nil || !validStarGiftSlugPath(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
unique, found, err := h.uniqueGifts.UniqueBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
h.logger.Error("Public unique star gift lookup failed", zap.String("slug", slug), zap.Error(err))
|
||||
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
canonicalSlug := unique.Slug
|
||||
if unique.ID <= 0 || unique.GiftID <= 0 || unique.Num <= 0 ||
|
||||
!validStarGiftSlugPath(canonicalSlug) || !strings.EqualFold(slug, canonicalSlug) ||
|
||||
!utf8.ValidString(unique.Title) || utf8.RuneCountInString(unique.Title) > domain.MaxStarGiftTitleRunes {
|
||||
h.logger.Error("Public unique star gift resolver returned invalid aggregate",
|
||||
zap.String("requested_slug", slug), zap.String("resolved_slug", canonicalSlug),
|
||||
zap.Int64("unique_id", unique.ID), zap.Int64("gift_id", unique.GiftID), zap.Int("num", unique.Num))
|
||||
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if slug != canonicalSlug || strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.Redirect(w, r, h.publicURL("nft", canonicalSlug), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(unique.Title)
|
||||
if title == "" {
|
||||
title = "Collectible gift"
|
||||
}
|
||||
subtitle := fmt.Sprintf("Collectible #%d", unique.Num)
|
||||
if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued {
|
||||
subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal))
|
||||
}
|
||||
app := h.appURL("nft", "slug", canonicalSlug)
|
||||
data := pageData{
|
||||
AppName: h.appName,
|
||||
Title: title,
|
||||
KindLabel: "collectible gift",
|
||||
Subtitle: subtitle,
|
||||
Description: "This collectible was created from a gift on " + h.appName + ". Open it in the app to view its current details.",
|
||||
CanonicalURL: h.publicURL("nft", canonicalSlug),
|
||||
AppURL: template.URL(app),
|
||||
LegacyTgURL: template.URL(legacyTgURL("nft", "slug", canonicalSlug)),
|
||||
}
|
||||
data.AppURLJS = template.JS(strconv.Quote(app))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate")
|
||||
if err := landingTemplate.Execute(w, data); err != nil {
|
||||
h.logger.Error("Render public unique star gift page failed", zap.String("slug", canonicalSlug), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PathValue("username"))
|
||||
if !validUsernamePath(username) {
|
||||
|
|
@ -734,7 +869,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string {
|
|||
|
||||
func publicSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
|
@ -764,6 +899,23 @@ func validSlugPath(slug string) bool {
|
|||
return links.ValidChatlistSlug(slug)
|
||||
}
|
||||
|
||||
func validStarGiftSlugPath(slug string) bool {
|
||||
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
|
||||
return false
|
||||
}
|
||||
for _, r := range slug {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '.' || r == '_' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUsernamePath(username string) bool {
|
||||
if username == "" || len(username) < 5 || len(username) > 32 {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -44,6 +45,158 @@ func newTestHandlerWithPublicPeers(
|
|||
return h
|
||||
}
|
||||
|
||||
type fakeGiftWithdrawals struct {
|
||||
value domain.StarGiftWithdrawal
|
||||
found bool
|
||||
completeCalls int
|
||||
}
|
||||
|
||||
type fakeUniqueGifts struct {
|
||||
bySlug map[string]domain.UniqueStarGift
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return domain.UniqueStarGift{}, false, f.err
|
||||
}
|
||||
value, ok := f.bySlug[strings.ToLower(slug)]
|
||||
return value, ok, nil
|
||||
}
|
||||
|
||||
func TestHandlerServesUniqueGiftLandingPage(t *testing.T) {
|
||||
const slug = "official-5895603153683874485-7"
|
||||
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
|
||||
slug: {
|
||||
ID: 7001, GiftID: 5895603153683874485, Title: "Official Gift", Slug: slug, Num: 7,
|
||||
AvailabilityIssued: 7, AvailabilityTotal: 1000,
|
||||
},
|
||||
}}
|
||||
handler, err := NewHandler(Config{
|
||||
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "http://127.0.0.1:2401",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/"+slug, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Official Gift", "Collectible #7", "7/1 000 issued",
|
||||
"http://127.0.0.1:2401/nft/" + slug,
|
||||
"telesrv://nft?slug=" + slug,
|
||||
"tg://nft?slug=" + slug,
|
||||
"Open it in the app to view its current details.",
|
||||
} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Fatalf("body missing %q:\n%s", want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), `window.location.href = "tg://`) {
|
||||
t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Cache-Control"); got != "public, max-age=60, must-revalidate" {
|
||||
t.Fatalf("Cache-Control = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCanonicalizesUniqueGiftSlug(t *testing.T) {
|
||||
const canonical = "Official-Gift-7"
|
||||
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
|
||||
strings.ToLower(canonical): {ID: 7, GiftID: 70, Slug: canonical, Num: 7},
|
||||
}}
|
||||
handler, err := NewHandler(Config{
|
||||
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
for _, path := range []string{"/nft/official-gift-7", "/nft/" + canonical + "/"} {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusPermanentRedirect || rr.Header().Get("Location") != "https://telesrv.net/nft/"+canonical {
|
||||
t.Fatalf("%s status=%d location=%q", path, rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsInvalidMissingAndBrokenUniqueGift(t *testing.T) {
|
||||
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
|
||||
"broken-1": {ID: 1, GiftID: 2, Slug: "other-1", Num: 1},
|
||||
}}
|
||||
handler, err := NewHandler(Config{
|
||||
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/nft/missing-1", "/nft/bad!slug", "/nft/%E4%B8%AD%E6%96%87", "/nft/" + strings.Repeat("x", domain.MaxStarGiftSlugBytes+1),
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status=%d, want 404", path, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/broken-1", nil))
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("broken aggregate status=%d, want 500", rr.Code)
|
||||
}
|
||||
|
||||
resolver.err = errors.New("lookup failed")
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/error-1", nil))
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("lookup error status=%d, want 500", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGiftWithdrawals) ResolveWithdrawal(context.Context, string) (domain.StarGiftWithdrawal, bool, error) {
|
||||
return f.value, f.found, nil
|
||||
}
|
||||
|
||||
func (f *fakeGiftWithdrawals) CompleteWithdrawal(_ context.Context, _ string, _ int) (domain.StarGiftWithdrawal, error) {
|
||||
f.completeCalls++
|
||||
f.value.Status = "completed"
|
||||
f.value.Gift.OwnerAddress = "telesrv-owner:test"
|
||||
f.value.Gift.GiftAddress = "telesrv-gift:test"
|
||||
return f.value, nil
|
||||
}
|
||||
|
||||
func TestHandlerCompletesLocalStarGiftWithdrawal(t *testing.T) {
|
||||
resolver := &fakeGiftWithdrawals{found: true, value: domain.StarGiftWithdrawal{
|
||||
ProviderRequestID: "safe-token", Status: "pending", ExpiresAt: int(time.Now().Add(time.Minute).Unix()),
|
||||
Gift: domain.UniqueStarGift{Title: `<script>alert("x")</script>`, Slug: "gift-1"},
|
||||
}}
|
||||
handler, err := NewHandler(Config{StickerSets: fakeResolver{}, GiftWithdrawals: resolver,
|
||||
PublicBaseURL: "https://telesrv.net", AppName: "telesrv"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/gift-withdrawal/safe-token", nil))
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Complete local export") ||
|
||||
strings.Contains(rr.Body.String(), `<script>alert("x")</script>`) {
|
||||
t.Fatalf("withdrawal GET status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self'") {
|
||||
t.Fatalf("withdrawal CSP does not allow its same-origin POST form: %q", csp)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/gift-withdrawal/safe-token", strings.NewReader("")))
|
||||
if rr.Code != http.StatusOK || resolver.completeCalls != 1 || !strings.Contains(rr.Body.String(), "Status: completed") ||
|
||||
!strings.Contains(rr.Body.String(), "telesrv-owner:test") || !strings.Contains(rr.Body.String(), "telesrv-gift:test") {
|
||||
t.Fatalf("withdrawal POST calls=%d status=%d body=%s", resolver.completeCalls, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesStickerSetLandingPage(t *testing.T) {
|
||||
resolver := fakeResolver{
|
||||
"fresh_pack": {
|
||||
|
|
@ -186,6 +339,9 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
|
|||
"emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true},
|
||||
},
|
||||
Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}},
|
||||
UniqueGifts: &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
|
||||
"gift-1": {ID: 1, GiftID: 10, Slug: "gift-1", Num: 1},
|
||||
}},
|
||||
PublicBaseURL: "https://links.example.test",
|
||||
AppScheme: "example-chat",
|
||||
WebBaseURL: "https://web.example.test/client/",
|
||||
|
|
@ -220,6 +376,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
|
|||
{path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"},
|
||||
{path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"},
|
||||
{path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"},
|
||||
{path: "/nft/gift-1", want: "example-chat://nft?slug=gift-1"},
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue