removed all "paid" features - no more stars, gifts, or grams
This commit is contained in:
parent
d4451d753c
commit
21d8e91756
165 changed files with 318 additions and 40948 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,6 @@ package admin
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
|
@ -12,21 +10,15 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
// cmd/telesrv wires *usernames.Service and *rating.Service into
|
||||
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
|
||||
// has to fail here rather than at integration time.
|
||||
// cmd/telesrv wires *usernames.Service into Dependencies.Usernames directly,
|
||||
// so a drifting method set has to fail here rather than at integration time.
|
||||
var (
|
||||
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
|
||||
_ AccountRatingService = (*ratingapp.Service)(nil)
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
|
|
@ -352,59 +344,6 @@ func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGrantStarsDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, Phone: "1001", Username: "alice", FirstName: "Alice"},
|
||||
}}
|
||||
stars := &fakeStarsService{balances: map[int64]domain.StarsBalance{
|
||||
1001: {UserID: 1001, Balance: 1000, Granted: true},
|
||||
}}
|
||||
notifier := &fakeStarsNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
Stars: stars,
|
||||
StarsNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.GrantStars(ctx, GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-stars", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run stars: %v", err)
|
||||
}
|
||||
if !dry.DryRun || stars.creditCalls != 0 || len(notifier.balances) != 0 {
|
||||
t.Fatalf("dry=%+v creditCalls=%d notified=%v, want no mutation", dry, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
|
||||
req := GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-stars", Actor: "ops", Reason: "ops grant"},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
}
|
||||
exec, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute stars: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || stars.creditCalls != 1 || stars.lastAmount != 250 || stars.lastReason != domain.StarsReasonAdjust || len(notifier.balances) != 1 {
|
||||
t.Fatalf("exec=%+v creditCalls=%d amount=%d reason=%s notified=%v", exec, stars.creditCalls, stars.lastAmount, stars.lastReason, notifier.balances)
|
||||
}
|
||||
if exec.Details["updated_balance"] != int64(1250) {
|
||||
t.Fatalf("updated_balance=%v, want 1250", exec.Details["updated_balance"])
|
||||
}
|
||||
again, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate stars: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || stars.creditCalls != 1 || len(notifier.balances) != 1 {
|
||||
t.Fatalf("again=%+v creditCalls=%d notified=%v, want idempotent replay", again, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
|
|
@ -897,47 +836,6 @@ func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, st
|
|||
return u, nil
|
||||
}
|
||||
|
||||
type fakeStarsService struct {
|
||||
balances map[int64]domain.StarsBalance
|
||||
creditCalls int
|
||||
lastUserID int64
|
||||
lastAmount int64
|
||||
lastReason domain.StarsTransactionReason
|
||||
lastPeer domain.Peer
|
||||
lastTitle string
|
||||
lastDesc string
|
||||
}
|
||||
|
||||
func (f *fakeStarsService) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
f.creditCalls++
|
||||
f.lastUserID = userID
|
||||
f.lastAmount = amount
|
||||
f.lastReason = reason
|
||||
f.lastPeer = peer
|
||||
f.lastTitle = title
|
||||
f.lastDesc = desc
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
if f.balances == nil {
|
||||
f.balances = map[int64]domain.StarsBalance{}
|
||||
}
|
||||
balance := f.balances[userID]
|
||||
balance.UserID = userID
|
||||
balance.Balance += amount
|
||||
f.balances[userID] = balance
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
type fakeStarsNotifier struct {
|
||||
balances []domain.StarsBalance
|
||||
}
|
||||
|
||||
func (f *fakeStarsNotifier) NotifyStarsBalanceChanged(_ context.Context, balance domain.StarsBalance) error {
|
||||
f.balances = append(f.balances, balance)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUserNotifier struct {
|
||||
users []int64
|
||||
}
|
||||
|
|
@ -1054,424 +952,6 @@ type fakeChannelNotifier struct {
|
|||
channels []int64
|
||||
}
|
||||
|
||||
func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := ImportStarGiftRequest{
|
||||
Title: "Cake", Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 3,
|
||||
FileName: "cake.lottie", Data: []byte(`{"v":"5.7"}`),
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-gift", Actor: "ops", Reason: "catalog", DryRun: true}
|
||||
preview, err := svc.ImportStarGift(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["source_format"] != domain.StarGiftAnimationLottie {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
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"] != "22" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandIDConflictRejectsDifferentGiftBytes(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
req := ImportStarGiftRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "same", Actor: "ops", Reason: "catalog", DryRun: true},
|
||||
Title: "Gift", Stars: 10, ConvertStars: 5, Enabled: true, FileName: "a.lottie", Data: []byte("one"),
|
||||
}
|
||||
if _, err := svc.ImportStarGift(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Data = []byte("two")
|
||||
if _, err := svc.ImportStarGift(context.Background(), req); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
|
||||
t.Fatalf("conflict err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := PublishStarGiftCollectiblesRequest{
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileKey: "model-1", FileName: "sapphire.lottie", Data: []byte("model-1")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")},
|
||||
{Name: "Moons", RarityPermille: 500, FileKey: "pattern-1", FileName: "moons.tgs", Data: []byte("pattern-1")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
|
||||
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["models"] == nil {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
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"] != "33" || result.Details["published"] != true {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesRejectsUnsafeClientPreviewPool(t *testing.T) {
|
||||
valid := func() PublishStarGiftCollectiblesRequest {
|
||||
return PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "unsafe-pool", Actor: "ops", Reason: "regression", DryRun: true},
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileName: "ruby.lottie", Data: []byte("ruby")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileName: "sapphire.lottie", Data: []byte("sapphire")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileName: "stars.lottie", Data: []byte("stars")},
|
||||
{Name: "Moons", RarityPermille: 500, FileName: "moons.lottie", Data: []byte("moons")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
}
|
||||
tests := map[string]func(*PublishStarGiftCollectiblesRequest){
|
||||
"single model": func(req *PublishStarGiftCollectiblesRequest) { req.Models = req.Models[:1] },
|
||||
"single pattern": func(req *PublishStarGiftCollectiblesRequest) { req.Patterns = req.Patterns[:1] },
|
||||
"single backdrop": func(req *PublishStarGiftCollectiblesRequest) { req.Backdrops = req.Backdrops[:1] },
|
||||
"duplicate backdrop id": func(req *PublishStarGiftCollectiblesRequest) {
|
||||
req.Backdrops[1].BackdropID = req.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := valid()
|
||||
mutate(&req)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: &fakeGiftsService{}, Now: fixedNow})
|
||||
if _, err := svc.PublishStarGiftCollectibles(context.Background(), req); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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: "Regular Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 5, FileName: "regular-two.tgs", SHA256: strings.Repeat("e", 64), Data: []byte("regular-two")}},
|
||||
{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")}},
|
||||
{Name: "Pattern Two", DocumentID: 6, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 6, FileName: "pattern-two.tgs", SHA256: strings.Repeat("f", 64), Data: []byte("pattern-two")}},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "White", BackdropID: 1, 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) != 3 || !models[2].Crafted || models[2].RarityKind != domain.StarGiftRarityLegendary || models[2].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 TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
if list := svc.DefaultStarGifts(); len(list) != 3 {
|
||||
t.Fatalf("default gifts = %d, want 3", len(list))
|
||||
}
|
||||
|
||||
// Dry-run must not write to the catalog.
|
||||
dry := ImportDefaultStarGiftRequest{ID: 3, CommandMeta: CommandMeta{CommandID: "dry-default-3", Actor: "ops", Reason: "demo", DryRun: true}}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, dry); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if cat, _ := giftService.Catalog(ctx); len(cat) != 0 {
|
||||
t.Fatalf("dry run wrote %d gifts", len(cat))
|
||||
}
|
||||
|
||||
// Confirmed import-all creates the whole demo set, enabled per the request flag.
|
||||
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
result, err := svc.ImportAllDefaultStarGifts(ctx, all)
|
||||
if err != nil || result.Details["imported"] != 3 {
|
||||
t.Fatalf("import all: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 3 {
|
||||
t.Fatalf("catalog=%d err=%v, want 3", len(catalog), err)
|
||||
}
|
||||
limited, premium, upgradeable := 0, 0, 0
|
||||
var craftGiftID int64
|
||||
for _, g := range catalog {
|
||||
if g.Limited {
|
||||
limited++
|
||||
}
|
||||
if g.RequirePremium {
|
||||
premium++
|
||||
}
|
||||
if g.UpgradeStars > 0 {
|
||||
upgradeable++
|
||||
}
|
||||
if g.Title == "OwpenGram Coin" {
|
||||
craftGiftID = g.ID
|
||||
}
|
||||
}
|
||||
if limited != 0 || premium != 0 || upgradeable != 2 {
|
||||
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
|
||||
}
|
||||
// The craftable gift must carry a craft-only (named-rarity) model.
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, craftGiftID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("coin preview ok=%v err=%v", ok, err)
|
||||
}
|
||||
crafted := 0
|
||||
for _, m := range preview.Models {
|
||||
if m.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
if crafted == 0 {
|
||||
t.Fatalf("coin has no craft-only model: %+v", preview.Models)
|
||||
}
|
||||
|
||||
// Re-running import-all is idempotent: everything already present -> skipped.
|
||||
// Enabled must match the first run's value (true) or the per-gift replay
|
||||
// hits COMMAND_ID_CONFLICT since the payload would differ from the cached one.
|
||||
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}, Enabled: true})
|
||||
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 3 {
|
||||
t.Fatalf("re-import: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportDefaultStarGiftRespectsEnabledFlag is a regression test: a single
|
||||
// default gift import must honor the request's Enabled flag rather than
|
||||
// always landing enabled (or, before this fix, always disabled regardless of
|
||||
// the admin console checkbox).
|
||||
func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
off := ImportDefaultStarGiftRequest{ID: 1, CommandMeta: CommandMeta{CommandID: "default-1-off", Actor: "ops", Reason: "demo"}, Enabled: false}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, off); err != nil {
|
||||
t.Fatalf("import disabled: %v", err)
|
||||
}
|
||||
on := ImportDefaultStarGiftRequest{ID: 2, CommandMeta: CommandMeta{CommandID: "default-2-on", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, on); err != nil {
|
||||
t.Fatalf("import enabled: %v", err)
|
||||
}
|
||||
|
||||
// Catalog() only ever returns enabled gifts, so presence/absence here
|
||||
// directly proves whether the Enabled flag was honored.
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%d err=%v, want 1 (only the enabled gift)", len(catalog), err)
|
||||
}
|
||||
if catalog[0].Title != "OwpenGram Star" {
|
||||
t.Fatalf("unexpected gift in catalog: %q, want %q", catalog[0].Title, "OwpenGram Star")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
|
||||
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
|
||||
document := func(id int64, name string) officialgifts.Document {
|
||||
raw := []byte(lottie)
|
||||
sum := sha256.Sum256(raw)
|
||||
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
|
||||
}
|
||||
permille := 1000
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x24, sha256.Size),
|
||||
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
|
||||
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
|
||||
},
|
||||
BaseDocument: document(1, "gift.json"),
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(2, "model.json")},
|
||||
{Name: "Model Two", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(4, "model-two.json")},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(3, "pattern.json")},
|
||||
{Name: "Pattern Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(5, "pattern-two.json")},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "Backdrop Two", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
|
||||
})
|
||||
req := ImportOfficialStarGiftRequest{
|
||||
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
|
||||
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
|
||||
}
|
||||
result, err := svc.ImportOfficialStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
|
||||
}
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
|
||||
if err != nil || !ok || len(preview.Models) != 2 || len(preview.Patterns) != 2 {
|
||||
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
|
||||
}
|
||||
model := preview.Models[0].Document
|
||||
pattern := preview.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
|
||||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
type adminGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *adminGiftBlob) Name() string { return "localfs" }
|
||||
func (b *adminGiftBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
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) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if id <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationLottie,
|
||||
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeGiftsService) Catalog(context.Context) ([]domain.StarGift, error) {
|
||||
return nil, 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
|
||||
}
|
||||
func (*fakeGiftsService) SetCatalogSortOrder(context.Context, int64, int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (*fakeGiftsService) AnimationJSON(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7"}`), true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
f.createCalls++
|
||||
return domain.StarGiftCollectibleRevision{ID: 33, GiftID: write.GiftID, Revision: 2, Published: true}, nil
|
||||
}
|
||||
func (*fakeGiftsService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
func (*fakeGiftsService) CollectibleAnimationJSON(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7"}`), true, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
|
||||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
|
|
@ -1627,82 +1107,6 @@ func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsern
|
|||
return domain.CollectibleUsername{}
|
||||
}
|
||||
|
||||
// fakeAccountRatingService recomputes from the manual component only, which
|
||||
// keeps the arithmetic in the domain and the fake focused on ledger replay.
|
||||
type fakeAccountRatingService struct {
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
commandKeys map[string]bool
|
||||
recomputeCalls int
|
||||
adjustCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountRating() *fakeAccountRatingService {
|
||||
return &fakeAccountRatingService{
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
commandKeys: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.recomputeCalls++
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
|
||||
}, domain.DefaultAccountRatingWeights(), fixedNow())
|
||||
rating.Version = f.ratings[userID].Version + 1
|
||||
f.ratings[userID] = rating
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
f.adjustCalls++
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
applied := true
|
||||
if f.commandKeys[req.CommandKey] {
|
||||
applied = false
|
||||
} else {
|
||||
f.commandKeys[req.CommandKey] = true
|
||||
f.manual[req.UserID] += req.Amount
|
||||
}
|
||||
rating, err := f.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
if f.manual[userID] == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
|
|
@ -1929,101 +1333,7 @@ func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
|
||||
UserID: 1001, Amount: -2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
|
||||
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
|
||||
}
|
||||
|
||||
execReq := AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
|
||||
UserID: 1001, Amount: -2500,
|
||||
}
|
||||
exec, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
|
||||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
|
||||
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
|
||||
}
|
||||
|
||||
again, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay adjust: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
|
||||
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
|
||||
}
|
||||
|
||||
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
|
||||
UserID: 1001, Amount: amount,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
|
||||
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
|
||||
}
|
||||
}
|
||||
if _, journalled := repo.items["bad-adjust"]; journalled {
|
||||
t.Fatal("journalled a rejected adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "user_id") {
|
||||
t.Fatalf("recompute without user err=%v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
|
||||
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
|
||||
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
stored, err := svc.AccountRating(ctx, 1001)
|
||||
if err != nil || stored.Stars != 5000 {
|
||||
t.Fatalf("AccountRating = %+v err=%v", stored, err)
|
||||
}
|
||||
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
|
||||
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
func TestCollectibleCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
|
|
@ -2032,18 +1342,9 @@ func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T)
|
|||
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
|
||||
t.Fatalf("mint without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
|
||||
UserID: 1001, Amount: 5,
|
||||
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
|
||||
t.Fatalf("adjust without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
|
||||
t.Fatal("listing without dependency succeeded")
|
||||
}
|
||||
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
|
||||
t.Fatal("rating listing without dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -44,7 +42,6 @@ type Service interface {
|
|||
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
|
||||
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
|
||||
|
|
@ -70,18 +67,6 @@ type Service interface {
|
|||
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
|
||||
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)
|
||||
ImportDefaultStarGift(ctx context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportAllDefaultStarGifts(ctx context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error)
|
||||
DefaultStarGifts() []giftdemo.GiftInfo
|
||||
DefaultStarGiftAnimation(ctx context.Context, id int) ([]byte, bool, error)
|
||||
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportAllOfficialStarGifts(ctx context.Context, req admin.ImportAllOfficialStarGiftsRequest) (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)
|
||||
SetStickerSetArchived(ctx context.Context, req admin.SetStickerSetArchivedRequest) (admin.CommandResult, error)
|
||||
SetStickerSetSortOrder(ctx context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error)
|
||||
RenameStickerSet(ctx context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error)
|
||||
|
|
@ -90,11 +75,7 @@ type Service interface {
|
|||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
||||
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||
GiveGift(ctx context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error)
|
||||
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||
|
|
@ -109,11 +90,6 @@ type Service interface {
|
|||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
|
||||
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
|
|
@ -195,7 +171,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
|
||||
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
|
||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
|
||||
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
|
||||
|
|
@ -221,18 +196,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
|
||||
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/default-gifts", s.authenticated(s.handleDefaultStarGifts))
|
||||
mux.HandleFunc("GET /v1/default-gifts/{id}/animation", s.authenticated(s.handleDefaultStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import", s.authenticated(s.handleImportDefaultStarGift))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import-all", s.authenticated(s.handleImportAllDefaultStarGifts))
|
||||
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/official-gifts/import-all", s.authenticated(s.handleImportAllOfficialStarGifts))
|
||||
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))
|
||||
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
|
||||
mux.HandleFunc("POST /v1/stickers/set-sort-order", s.authenticated(s.handleSetStickerSetSortOrder))
|
||||
mux.HandleFunc("POST /v1/stickers/rename", s.authenticated(s.handleRenameStickerSet))
|
||||
|
|
@ -241,11 +204,7 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
||||
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
||||
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
||||
mux.HandleFunc("POST /v1/gifts/give", s.authenticated(s.handleGiveGift))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
|
||||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
|
||||
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
|
||||
|
|
@ -259,10 +218,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
|
||||
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
|
||||
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
|
||||
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
|
|
@ -337,15 +292,6 @@ func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleGrantStars(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GrantStarsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GrantStars(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetVerified(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetVerifiedRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -634,231 +580,6 @@ func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.ImportStarGiftRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "animation file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
writeError(w, http.StatusBadRequest, "animation file is empty or too large")
|
||||
return
|
||||
}
|
||||
req.FileName = header.Filename
|
||||
req.Data = data
|
||||
result, err := s.svc.ImportStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
items := s.svc.DefaultStarGifts()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gifts": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleDefaultStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.DefaultStarGiftAnimation(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "default 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) handleImportDefaultStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportDefaultStarGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportDefaultStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportAllDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportAllDefaultStarGiftsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportAllDefaultStarGifts(r.Context(), req)
|
||||
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) handleImportAllOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportAllOfficialStarGiftsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportAllOfficialStarGifts(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)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.PublishStarGiftCollectiblesRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.GiftID = giftID
|
||||
seen := make(map[string]struct{}, len(req.Models)+len(req.Patterns))
|
||||
if len(req.Models)+len(req.Patterns) > 128 {
|
||||
writeError(w, http.StatusBadRequest, "too many collectible animation files")
|
||||
return
|
||||
}
|
||||
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
|
||||
upload.FileKey = strings.TrimSpace(upload.FileKey)
|
||||
if upload.FileKey == "" {
|
||||
return fmt.Errorf("animation file key is required")
|
||||
}
|
||||
if _, ok := seen[upload.FileKey]; ok {
|
||||
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
|
||||
}
|
||||
seen[upload.FileKey] = struct{}{}
|
||||
file, header, err := r.FormFile(upload.FileKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("animation file %q is required", upload.FileKey)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
|
||||
}
|
||||
upload.FileName = header.Filename
|
||||
upload.Data = data
|
||||
return nil
|
||||
}
|
||||
for i := range req.Models {
|
||||
if err := load(&req.Models[i]); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range req.Patterns {
|
||||
if err := load(&req.Patterns[i]); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.svc.PublishStarGiftCollectibles(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStarGiftEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStarGiftEnabledRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetStarGiftEnabled(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStarGiftSortOrderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetStarGiftSortOrder(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStickerSetArchived(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStickerSetArchivedRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -993,36 +714,6 @@ func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.R
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleGiveGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GiveGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GiveGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.StarGiftAnimation(r.Context(), giftID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "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) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || documentID <= 0 {
|
||||
|
|
@ -1044,86 +735,6 @@ func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
preview, found, err := s.svc.StarGiftCollectibles(r.Context(), giftID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
|
||||
}
|
||||
|
||||
func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
|
||||
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
|
||||
result := map[string]any{
|
||||
"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 {
|
||||
result["source_name"] = value.Animation.SourceName
|
||||
result["source_format"] = value.Animation.SourceFormat
|
||||
}
|
||||
if value.Kind == domain.StarGiftCollectibleBackdrop {
|
||||
result["backdrop_id"] = value.BackdropID
|
||||
result["center_color"] = value.CenterColor
|
||||
result["edge_color"] = value.EdgeColor
|
||||
result["pattern_color"] = value.PatternColor
|
||||
result["text_color"] = value.TextColor
|
||||
}
|
||||
return result
|
||||
}
|
||||
mapAttributes := func(values []domain.StarGiftCollectibleAttribute) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, attribute(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return map[string]any{
|
||||
"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),
|
||||
"backdrops": mapAttributes(preview.Backdrops),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibleAnimation(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)
|
||||
kind := domain.StarGiftCollectibleAttributeKind(r.PathValue("kind"))
|
||||
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 ||
|
||||
(kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern) {
|
||||
writeError(w, http.StatusBadRequest, "invalid collectible animation")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.StarGiftCollectibleAnimation(r.Context(), giftID, kind, attributeID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "collectible 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)
|
||||
}
|
||||
|
||||
type moderationClaimRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
|
|
@ -1450,24 +1061,6 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RecomputeAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.AdjustAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.AdjustAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
|
|
@ -1533,66 +1126,6 @@ func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Reques
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
minLevel, ok := optionalQueryInt(w, query, "min_level")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := optionalQueryInt64(w, query, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
|
||||
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ratings := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
ratings = append(ratings, accountRatingResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rating, err := s.svc.AccountRating(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ledger := make([]map[string]any, 0, len(events))
|
||||
for _, item := range events {
|
||||
ledger = append(ledger, accountRatingEventResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": accountRatingResponse(rating), "events": ledger,
|
||||
})
|
||||
}
|
||||
|
||||
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
|
||||
// identifiers may be present, mirroring the mint/transfer request shape.
|
||||
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
|
||||
|
|
@ -1695,50 +1228,6 @@ func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer
|
|||
|
||||
// accountRatingResponse renders one composite rating. The score and every
|
||||
// component stay decimal strings for the same exactness reason as the asset ids.
|
||||
func accountRatingResponse(rating domain.AccountRating) map[string]any {
|
||||
out := map[string]any{
|
||||
"user_id": strconv.FormatInt(rating.UserID, 10),
|
||||
"level": rating.Level,
|
||||
"stars": strconv.FormatInt(rating.Stars, 10),
|
||||
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
|
||||
"has_next_level": rating.HasNextLevel,
|
||||
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
|
||||
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
|
||||
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
|
||||
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
|
||||
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
|
||||
"version": strconv.FormatInt(rating.Version, 10),
|
||||
}
|
||||
if rating.HasNextLevel {
|
||||
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
|
||||
}
|
||||
if !rating.PendingDate.IsZero() {
|
||||
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.ComputedAt.IsZero() {
|
||||
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(event.ID, 10),
|
||||
"user_id": strconv.FormatInt(event.UserID, 10),
|
||||
"kind": string(event.Kind),
|
||||
"amount": strconv.FormatInt(event.Amount, 10),
|
||||
"reason": event.Reason,
|
||||
"actor": event.Actor,
|
||||
"command_key": event.CommandKey,
|
||||
}
|
||||
if !event.CreatedAt.IsZero() {
|
||||
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeCollectibleUsernameError maps a collectible-username failure onto its
|
||||
// stable admin code and the matching HTTP status, the way writeModerationError
|
||||
|
|
@ -1760,18 +1249,6 @@ func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
|
|||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeAccountRatingError(w http.ResponseWriter, err error) {
|
||||
code := admin.AccountRatingErrorCode(err)
|
||||
status := http.StatusInternalServerError
|
||||
switch code {
|
||||
case admin.CodeRatingNotFound:
|
||||
status = http.StatusNotFound
|
||||
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
|
||||
body := map[string]string{"error": msg}
|
||||
if code != "" {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -12,8 +10,6 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
func TestAdminAPIRequiresBearerToken(t *testing.T) {
|
||||
|
|
@ -278,20 +274,6 @@ func TestAdminAPISetVerified(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIGrantStars(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/grant-stars", strings.NewReader(`{"command_id":"c-stars","actor":"ops","reason":"manual grant","dry_run":true,"user_id":1001,"amount":500}`))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"command_id":"c-stars"`) {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPISetChannelVerified(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/channels/set-verified", strings.NewReader(`{"command_id":"c3","actor":"ops","reason":"official","dry_run":true,"channel_id":2001,"verified":true}`))
|
||||
|
|
@ -306,103 +288,6 @@ func TestAdminAPISetChannelVerified(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("metadata", `{"command_id":"gift-1","actor":"ops","reason":"catalog","dry_run":true,"title":"Gift","stars":50,"convert_stars":25,"enabled":true,"sort_order":3}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", "gift.lottie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
animation := []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`)
|
||||
if _, err := part.Write(animation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &captureGiftService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/import", &body)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.CommandID != "gift-1" || svc.req.FileName != "gift.lottie" || !bytes.Equal(svc.req.Data, animation) || svc.req.Stars != 50 || svc.req.ConvertStars != 25 {
|
||||
t.Fatalf("decoded gift request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":500,"sort_order":0,"file_key":"model-0"},{"name":"Sapphire","rarity_permille":500,"sort_order":1,"file_key":"model-1"}],"patterns":[{"name":"Stars","rarity_permille":500,"sort_order":0,"file_key":"pattern-0"},{"name":"Moons","rarity_permille":500,"sort_order":1,"file_key":"pattern-1"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":500,"sort_order":0},{"name":"Day","backdrop_id":2,"center_color":11189196,"edge_color":7833753,"pattern_color":14544639,"text_color":1118481,"rarity_permille":500,"sort_order":1}]}`
|
||||
if err := writer.WriteField("metadata", metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, name := range map[string]string{
|
||||
"model-0": "ruby.lottie", "model-1": "sapphire.lottie",
|
||||
"pattern-0": "stars.tgs", "pattern-1": "moons.tgs",
|
||||
} {
|
||||
part, err := writer.CreateFormFile(key, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write([]byte(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &captureCollectibleService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/11/collectibles/publish", &body)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 2 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 2 || svc.req.Backdrops[1].BackdropID != 2 {
|
||||
t.Fatalf("decoded collectible request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
type captureFreezeService struct {
|
||||
|
|
@ -410,31 +295,11 @@ type captureFreezeService struct {
|
|||
req admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
type captureGiftService struct {
|
||||
fakeService
|
||||
req admin.ImportStarGiftRequest
|
||||
}
|
||||
|
||||
type captureCollectibleService struct {
|
||||
fakeService
|
||||
req admin.PublishStarGiftCollectiblesRequest
|
||||
}
|
||||
|
||||
func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureGiftService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -443,10 +308,6 @@ func (fakeService) GrantPremium(_ context.Context, req admin.GrantPremiumRequest
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GrantStars(_ context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetVerified(_ context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -483,10 +344,6 @@ func (fakeService) SetSupport(_ context.Context, req admin.SetSupportRequest) (a
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GiveGift(_ context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -551,18 +408,6 @@ func (fakeService) DeletePrivateHistory(context.Context, admin.DeletePrivateHist
|
|||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportDefaultStarGift(_ context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportAllDefaultStarGifts(_ context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountAvatar(context.Context, int64) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
|
@ -599,58 +444,10 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
|
|||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
func (fakeService) DefaultStarGifts() []giftdemo.GiftInfo {
|
||||
return giftdemo.List()
|
||||
}
|
||||
|
||||
func (fakeService) DefaultStarGiftAnimation(context.Context, int) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, 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) ImportAllOfficialStarGifts(_ context.Context, req admin.ImportAllOfficialStarGiftsRequest) (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
|
||||
}
|
||||
|
||||
func (fakeService) SetStarGiftEnabled(_ context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetStarGiftSortOrder(_ context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -747,64 +544,12 @@ func maxInt64Collectible() domain.CollectibleUsername {
|
|||
}
|
||||
}
|
||||
|
||||
type captureAccountRatingService struct {
|
||||
fakeService
|
||||
recompute admin.RecomputeAccountRatingRequest
|
||||
adjust admin.AdjustAccountRatingRequest
|
||||
filter domain.AccountRatingFilter
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.recompute = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.adjust = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
s.filter = filter
|
||||
return []domain.AccountRating{maxInt64Rating()}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating := maxInt64Rating()
|
||||
rating.UserID = userID
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 9223372036854775807, UserID: userID,
|
||||
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
|
||||
Actor: "ops", Reason: "abuse",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func maxInt64Rating() domain.AccountRating {
|
||||
return domain.AccountRating{
|
||||
UserID: 1001,
|
||||
Level: 7,
|
||||
Stars: 9223372036854775807,
|
||||
CurrentLevelStars: 4900,
|
||||
NextLevelStars: 6400,
|
||||
HasNextLevel: true,
|
||||
StarsComponent: 9223372036854775807,
|
||||
ManualComponent: -1500,
|
||||
Version: 9223372036854775807,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
for _, path := range []string{
|
||||
"/v1/collectible-usernames/mint",
|
||||
"/v1/collectible-usernames/transfer",
|
||||
"/v1/collectible-usernames/revoke",
|
||||
"/v1/account-ratings/recompute",
|
||||
"/v1/account-ratings/adjust",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -816,8 +561,6 @@ func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
|
|||
for _, path := range []string{
|
||||
"/v1/collectible-usernames",
|
||||
"/v1/collectible-usernames/7",
|
||||
"/v1/account-ratings",
|
||||
"/v1/account-ratings/7",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -875,28 +618,6 @@ func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingCommands(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
|
||||
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
|
||||
recompute.Header.Set("Authorization", "Bearer secret")
|
||||
recomputeRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(recomputeRec, recompute)
|
||||
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
|
||||
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
|
||||
}
|
||||
|
||||
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
|
||||
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
|
||||
adjust.Header.Set("Authorization", "Bearer secret")
|
||||
adjustRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(adjustRec, adjust)
|
||||
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
|
||||
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
|
|
@ -939,44 +660,7 @@ func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
listRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(listRec, list)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
|
||||
t.Fatalf("rating filter = %+v", svc.filter)
|
||||
}
|
||||
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
|
||||
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
|
||||
}
|
||||
|
||||
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
|
||||
detail.Header.Set("Authorization", "Bearer secret")
|
||||
detailRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(detailRec, detail)
|
||||
if detailRec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Rating map[string]any `json:"rating"`
|
||||
Events []map[string]any `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
|
||||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating detail payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
|
||||
func TestAdminAPIMissingCollectibleReportsCodedError(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
|
||||
asset.Header.Set("Authorization", "Bearer secret")
|
||||
|
|
@ -986,15 +670,6 @@ func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
|
|||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
|
||||
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
|
||||
}
|
||||
|
||||
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
|
||||
rating.Header.Set("Authorization", "Bearer secret")
|
||||
ratingRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(ratingRec, rating)
|
||||
if ratingRec.Code != http.StatusNotFound ||
|
||||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
|
||||
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
|
|
@ -1024,23 +699,3 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
|||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -981,21 +981,6 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
|
|||
return s.channels.SetChannelMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本
|
||||
// Debit 完成,本方法只负责累计与聚合。
|
||||
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.AddChannelMessagePaidReaction(ctx, req)
|
||||
}
|
||||
|
||||
// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。
|
||||
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
|
|
|
|||
|
|
@ -24,15 +24,3 @@ func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, sende
|
|||
}
|
||||
return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息,
|
||||
// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。
|
||||
func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,454 +0,0 @@
|
|||
// Package rating implements the composite account rating use cases: reading the
|
||||
// stored projection, recomputing it from the raw contribution signals, and
|
||||
// applying operator adjustments through the contribution ledger.
|
||||
//
|
||||
// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
|
||||
// private algorithm. The service gathers signals, applies the configured
|
||||
// weights and pending-delay policy, and persists the result under optimistic
|
||||
// concurrency for both admin and read-only client projection.
|
||||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultPendingDelay parks a rating increase for a day, matching the
|
||||
// shipped TELESRV_RATING_PENDING_DELAY default.
|
||||
defaultPendingDelay = 24 * time.Hour
|
||||
// defaultStaleAfter is the recompute horizon used when none is configured.
|
||||
defaultStaleAfter = 6 * time.Hour
|
||||
// defaultListLimit / maxListLimit bound one leaderboard page.
|
||||
defaultListLimit = 50
|
||||
maxListLimit = 200
|
||||
// defaultEventLimit / maxEventLimit bound one ledger page.
|
||||
defaultEventLimit = 50
|
||||
maxEventLimit = 200
|
||||
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
|
||||
defaultRecomputeBatch = 500
|
||||
maxRecomputeBatch = 10000
|
||||
)
|
||||
|
||||
// ErrDisabled reports that the local composite rating feature is switched off.
|
||||
// Reads degrade to an empty admin projection; writes are refused so an operator
|
||||
// never believes an adjustment was recorded when it was not.
|
||||
var ErrDisabled = errors.New("account rating is disabled")
|
||||
|
||||
// Service is the composite account rating use-case layer.
|
||||
type Service struct {
|
||||
store store.AccountRatingStore
|
||||
weights domain.AccountRatingWeights
|
||||
pendingDelay time.Duration
|
||||
staleAfter time.Duration
|
||||
enabled bool
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithStore injects the rating read model and ledger store.
|
||||
func WithStore(st store.AccountRatingStore) Option {
|
||||
return func(s *Service) { s.store = st }
|
||||
}
|
||||
|
||||
// WithWeights installs the composite formula. An invalid set is rejected in
|
||||
// favour of the shipped defaults, so a misconfigured deployment produces a
|
||||
// conservative rating instead of an inconsistent one.
|
||||
func WithWeights(weights domain.AccountRatingWeights) Option {
|
||||
return func(s *Service) {
|
||||
if err := weights.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
s.weights = weights
|
||||
}
|
||||
}
|
||||
|
||||
// WithPendingDelay configures how long a rating increase stays parked as
|
||||
// pending. Zero applies every change immediately.
|
||||
func WithPendingDelay(delay time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if delay >= 0 {
|
||||
s.pendingDelay = delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStaleAfter configures the projection age after which the background
|
||||
// worker recomputes a user.
|
||||
func WithStaleAfter(staleAfter time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if staleAfter > 0 {
|
||||
s.staleAfter = staleAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnabled toggles the feature.
|
||||
func WithEnabled(enabled bool) Option {
|
||||
return func(s *Service) { s.enabled = enabled }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the rating service. It is enabled by default so that the
|
||||
// only switch is the configuration flag, and it stays safe without a store:
|
||||
// reads answer empty and writes report a configuration error.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{
|
||||
weights: domain.DefaultAccountRatingWeights(),
|
||||
pendingDelay: defaultPendingDelay,
|
||||
staleAfter: defaultStaleAfter,
|
||||
enabled: true,
|
||||
now: time.Now,
|
||||
log: zap.NewNop(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
if s.pendingDelay < 0 {
|
||||
s.pendingDelay = 0
|
||||
}
|
||||
if s.staleAfter <= 0 {
|
||||
s.staleAfter = defaultStaleAfter
|
||||
}
|
||||
if err := s.weights.Validate(); err != nil {
|
||||
s.weights = domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether the feature is switched on.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
||||
|
||||
// Ready reports whether the feature is on and backed by a store.
|
||||
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
|
||||
|
||||
// Weights returns the configured composite formula, so the admin panel can
|
||||
// explain a level with the same numbers that produced it.
|
||||
func (s *Service) Weights() domain.AccountRatingWeights {
|
||||
if s == nil {
|
||||
return domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s.weights
|
||||
}
|
||||
|
||||
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
return s.store, nil
|
||||
}
|
||||
|
||||
// Rating returns the stored projection.
|
||||
//
|
||||
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
|
||||
// value so the admin API can distinguish "not computed" from a computed zero.
|
||||
// A missing store reports a configuration error an operator can diagnose.
|
||||
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return st.AccountRating(ctx, userID)
|
||||
}
|
||||
|
||||
// RatingBatch resolves several users in one round trip. Users without a stored
|
||||
// projection are absent from the map, so a disabled feature and an unconfigured
|
||||
// store both read as "nobody has a rating" -- the batch shape already encodes
|
||||
// absence and needs no error to express it.
|
||||
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || s.store == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
unique := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
unique = append(unique, userID)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
batch, err := s.store.AccountRatingBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batch == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// Recompute gathers the contribution signals, applies the configured weights
|
||||
// and the pending-delay policy relative to the stored value, and persists the
|
||||
// result.
|
||||
//
|
||||
// The save is guarded by the stored version. A concurrent writer (another
|
||||
// recompute, an adjustment, the worker) only invalidates the base the pending
|
||||
// policy was resolved against, so exactly one retry against the freshly
|
||||
// returned row is both sufficient and terminating.
|
||||
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
// The service accounts are infrastructure, not participants. Refusing here as
|
||||
// well as in the seeding query means an operator cannot create a rating for one
|
||||
// by hand either -- the platform account is not flagged is_bot, so nothing else
|
||||
// would stop it.
|
||||
if !domain.RatableAccount(userID, false) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
signals, err := st.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
signals.UserID = userID
|
||||
prev, err := s.previous(ctx, st, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
computed := domain.ComputeAccountRating(signals, s.weights, now)
|
||||
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if changed {
|
||||
return stored, nil
|
||||
}
|
||||
// One retry: `stored` is the row that won the race, so resolving the pending
|
||||
// policy against it produces the correct next version.
|
||||
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if !changed {
|
||||
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// Adjust records an operator adjustment in the contribution ledger and
|
||||
// immediately recomputes the projection, so the manual component is visible
|
||||
// without waiting for the background worker. Replaying the same CommandKey
|
||||
// records nothing and reports applied=false; the current rating is still
|
||||
// returned so a retried admin command stays idempotent.
|
||||
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, false, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
_, applied, err := st.AdjustAccountRating(ctx, req)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
rating, err := s.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
// List is the admin leaderboard query with a bounded page size.
|
||||
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.MinLevel < 0 {
|
||||
filter.MinLevel = 0
|
||||
}
|
||||
if filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
filter.MinLevel = domain.MaxAccountRatingLevel
|
||||
}
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return st.ListAccountRatings(ctx, filter)
|
||||
}
|
||||
|
||||
// Events returns one user's contribution ledger, newest first.
|
||||
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
|
||||
}
|
||||
|
||||
// RunRecomputeCycle advances the read model by one bounded batch and returns how
|
||||
// many users it wrote. A single user's failure is logged and skipped: one poisoned
|
||||
// row must not stall the whole cycle.
|
||||
//
|
||||
// The cycle does two things, and the order matters. It first refreshes projections
|
||||
// that have gone stale, because those are rows somebody is already looking at.
|
||||
// Whatever batch budget is left it spends seeding accounts that have no projection
|
||||
// at all -- without that pass the read model can never populate itself, since
|
||||
// StaleAccountRatings walks account_rating and cannot return a user who is not in
|
||||
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
|
||||
// all, which is what makes the admin leaderboard populate without an operator
|
||||
// opening every account first.
|
||||
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return 0, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
|
||||
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
|
||||
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
|
||||
// can never turn one cycle into an unbounded amount of work.
|
||||
remaining := limit - len(userIDs)
|
||||
if remaining <= 0 {
|
||||
return processed, nil
|
||||
}
|
||||
unrated, err := st.UnratedAccounts(ctx, remaining)
|
||||
if err != nil {
|
||||
// Seeding extends the cycle rather than being its purpose: a store that
|
||||
// cannot enumerate accounts must not turn a successful stale pass into a
|
||||
// failed cycle.
|
||||
s.log.Warn("list unrated accounts failed", zap.Error(err))
|
||||
return processed, nil
|
||||
}
|
||||
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
|
||||
return processed + seeded, err
|
||||
}
|
||||
|
||||
// recomputeEach recomputes a list of users, skipping the ones that fail, and
|
||||
// giving up early only when the context is done.
|
||||
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
|
||||
processed := 0
|
||||
for _, userID := range userIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Recompute(ctx, userID); err != nil {
|
||||
s.log.Warn(failureMessage,
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// EnsureRating returns the stored local-admin projection, computing and storing
|
||||
// it first when an administrative caller needs an immediate value.
|
||||
//
|
||||
// The background cycle reaches every account eventually; callers that require a
|
||||
// local rating immediately use this bounded materialization path instead.
|
||||
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := s.Rating(ctx, userID)
|
||||
if err == nil {
|
||||
return rating, nil
|
||||
}
|
||||
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return s.Recompute(ctx, userID)
|
||||
}
|
||||
|
||||
// previous reads the stored projection the pending policy is resolved against.
|
||||
// A never-computed user yields the zero value, which domain.ResolveAccountRating
|
||||
// Pending treats as "apply immediately" -- a first rating is never parked.
|
||||
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
|
||||
prev, err := st.AccountRating(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, nil
|
||||
}
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
|
@ -1,719 +0,0 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
|
||||
// concurrency contract as PostgreSQL: a save whose version does not follow the
|
||||
// stored one reports changed=false and returns the row that won.
|
||||
type fakeRatingStore struct {
|
||||
signals map[int64]domain.AccountRatingSignals
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
events map[int64][]domain.AccountRatingEvent
|
||||
keys map[string]domain.AccountRatingEvent
|
||||
|
||||
stale []int64
|
||||
staleOlderThan int64
|
||||
staleLimit int
|
||||
|
||||
unrated []int64
|
||||
unratedLimit int
|
||||
unratedCalls int
|
||||
unratedErr error
|
||||
|
||||
saves []domain.AccountRating
|
||||
forceConflicts int
|
||||
signalsErr error
|
||||
}
|
||||
|
||||
func newFakeRatingStore() *fakeRatingStore {
|
||||
return &fakeRatingStore{
|
||||
signals: map[int64]domain.AccountRatingSignals{},
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
events: map[int64][]domain.AccountRatingEvent{},
|
||||
keys: map[string]domain.AccountRatingEvent{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if rating, ok := f.ratings[userID]; ok {
|
||||
out[userID] = rating
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
f.saves = append(f.saves, rating)
|
||||
current := f.ratings[rating.UserID]
|
||||
if f.forceConflicts > 0 {
|
||||
f.forceConflicts--
|
||||
return current, false, nil
|
||||
}
|
||||
if rating.Version != current.Version+1 {
|
||||
return current, false, nil
|
||||
}
|
||||
f.ratings[rating.UserID] = rating
|
||||
return rating, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if f.signalsErr != nil {
|
||||
return domain.AccountRatingSignals{}, f.signalsErr
|
||||
}
|
||||
signals := f.signals[userID]
|
||||
signals.UserID = userID
|
||||
signals.Manual = f.manual[userID]
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if req.CommandKey != "" {
|
||||
if event, ok := f.keys[req.CommandKey]; ok {
|
||||
return event, false, nil
|
||||
}
|
||||
}
|
||||
event := domain.AccountRatingEvent{
|
||||
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
|
||||
}
|
||||
f.events[req.UserID] = append(f.events[req.UserID], event)
|
||||
f.manual[req.UserID] += req.Amount
|
||||
if req.CommandKey != "" {
|
||||
f.keys[req.CommandKey] = event
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level >= filter.MinLevel {
|
||||
out = append(out, rating)
|
||||
}
|
||||
if len(out) >= filter.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
events := f.events[userID]
|
||||
if len(events) > limit {
|
||||
events = events[:limit]
|
||||
}
|
||||
return append([]domain.AccountRatingEvent(nil), events...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
f.staleOlderThan = olderThanUnix
|
||||
f.staleLimit = limit
|
||||
if len(f.stale) > limit {
|
||||
return append([]int64(nil), f.stale[:limit]...), nil
|
||||
}
|
||||
return append([]int64(nil), f.stale...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
||||
f.unratedCalls++
|
||||
f.unratedLimit = limit
|
||||
if f.unratedErr != nil {
|
||||
return nil, f.unratedErr
|
||||
}
|
||||
out := make([]int64, 0, len(f.unrated))
|
||||
for _, userID := range f.unrated {
|
||||
if _, rated := f.ratings[userID]; rated {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
|
||||
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
|
||||
return NewService(append(base, opts...)...)
|
||||
}
|
||||
|
||||
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{
|
||||
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
weights := domain.DefaultAccountRatingWeights()
|
||||
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}, weights, testNow)
|
||||
if rating.Stars != want.Stars || rating.Level != want.Level ||
|
||||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
|
||||
rating.PenaltyComponent != want.PenaltyComponent {
|
||||
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
|
||||
}
|
||||
if rating.Version != 1 {
|
||||
t.Fatalf("first stored version = %d, want 1", rating.Version)
|
||||
}
|
||||
if !rating.ComputedAt.Equal(testNow) {
|
||||
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePendingPolicy(t *testing.T) {
|
||||
t.Run("increase is parked", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 100 {
|
||||
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
|
||||
}
|
||||
if rating.PendingStars != 400 {
|
||||
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
|
||||
}
|
||||
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
|
||||
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
|
||||
}
|
||||
if rating.Version != 5 {
|
||||
t.Fatalf("version = %d, want 5", rating.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrease applies immediately", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 0 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
|
||||
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{
|
||||
UserID: 7, Stars: 100, Level: 1, Version: 2,
|
||||
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
|
||||
}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
|
||||
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero delay never parks", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 1
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
|
||||
}
|
||||
if rating.Version != 4 || rating.Stars != 200 {
|
||||
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 2
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute reported success while every save lost the version race")
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
|
||||
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("Adjust = %v, %v", applied, err)
|
||||
}
|
||||
if rating.ManualComponent != 300 || rating.Stars != 400 {
|
||||
t.Fatalf("rating = %#v, want the manual component folded in", rating)
|
||||
}
|
||||
if len(st.events[7]) != 1 {
|
||||
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
|
||||
|
||||
first, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("first Adjust = %v, %v", applied, err)
|
||||
}
|
||||
second, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("replayed Adjust: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("replayed Adjust reported applied=true")
|
||||
}
|
||||
if len(st.events[7]) != 1 || st.manual[7] != 300 {
|
||||
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
|
||||
}
|
||||
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
|
||||
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustValidatesRequest(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st)
|
||||
tests := []domain.AdjustAccountRatingRequest{
|
||||
{UserID: 0, Amount: 10},
|
||||
{UserID: 7, Amount: 0},
|
||||
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
|
||||
}
|
||||
for _, req := range tests {
|
||||
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
|
||||
}
|
||||
}
|
||||
if len(st.events) != 0 || len(st.saves) != 0 {
|
||||
t.Fatal("store was touched by an invalid adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2, 3}
|
||||
for _, userID := range st.stale {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st, WithStaleAfter(6*time.Hour))
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want 3", processed)
|
||||
}
|
||||
if st.staleLimit != 10 {
|
||||
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
|
||||
}
|
||||
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
|
||||
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
|
||||
}
|
||||
for _, userID := range st.stale {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("user %d was not recomputed", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 0, 2}
|
||||
st.forceConflicts = 2 // both saves of the first user lose the race
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the surviving user only", processed)
|
||||
}
|
||||
if st.staleLimit != defaultRecomputeBatch {
|
||||
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("disabled service reported enabled/ready")
|
||||
}
|
||||
// The userFull projection omits both TL flags on this error, which is exactly
|
||||
// the pre-rating wire shape.
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
batch, err := service.RatingBatch(context.Background(), []int64{7})
|
||||
if err != nil || len(batch) != 0 {
|
||||
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
|
||||
}
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil || processed != 0 {
|
||||
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Ready() {
|
||||
t.Fatal("Ready = true without a store")
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); err == nil {
|
||||
t.Fatal("Rating accepted a missing store")
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute accepted a missing store")
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
|
||||
t.Fatal("Adjust accepted a missing store")
|
||||
}
|
||||
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
|
||||
t.Fatal("RunRecomputeCycle accepted a missing store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("nil service reported enabled/ready")
|
||||
}
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("nil service weights = %#v, want the defaults", got)
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
|
||||
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndEventsBoundThePage(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
|
||||
for i := range maxEventLimit + 10 {
|
||||
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List = %d rows, want 1", len(list))
|
||||
}
|
||||
events, err := service.Events(context.Background(), 7, 100000)
|
||||
if err != nil {
|
||||
t.Fatalf("Events: %v", err)
|
||||
}
|
||||
if len(events) != maxEventLimit {
|
||||
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
|
||||
}
|
||||
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Events accepted a zero user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
|
||||
}()
|
||||
// The first cycle runs before the ticker, so cancelling immediately still
|
||||
// leaves exactly one recompute behind.
|
||||
<-time.After(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not stop on context cancellation")
|
||||
}
|
||||
if _, ok := st.ratings[1]; !ok {
|
||||
t.Fatal("worker did not recompute the stale user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
|
||||
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
worker.Run(context.Background())
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled worker kept running")
|
||||
}
|
||||
if worker.batch != defaultRecomputeBatch {
|
||||
t.Fatalf("batch = %d, want the default fallback", worker.batch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
|
||||
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
|
||||
// so it can only ever refresh rows that already exist; without a seeding pass the
|
||||
// very first row for a user has to come from an operator recomputing that user by
|
||||
// hand, and the read model stays permanently empty.
|
||||
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.unrated = []int64{11, 12, 13}
|
||||
for _, userID := range st.unrated {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the three seeded accounts", processed)
|
||||
}
|
||||
for _, userID := range st.unrated {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("account %d was not seeded", userID)
|
||||
}
|
||||
}
|
||||
// A second cycle has nothing left to seed, so seeding converges instead of
|
||||
// rewriting the same rows every interval.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
|
||||
// must not let one cycle do an unbounded amount of work.
|
||||
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2}
|
||||
st.unrated = []int64{11, 12, 13, 14}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the batch bound of 3", processed)
|
||||
}
|
||||
if st.unratedLimit != 1 {
|
||||
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
|
||||
}
|
||||
|
||||
// A cycle whose stale pass already fills the batch does not query for seeds at
|
||||
// all: refreshing rows somebody is looking at comes first.
|
||||
full := newFakeRatingStore()
|
||||
full.stale = []int64{1, 2, 3}
|
||||
full.unrated = []int64{11}
|
||||
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if full.unratedCalls != 0 {
|
||||
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
|
||||
// accounts must not turn a successful stale pass into a failed cycle.
|
||||
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.unratedErr = errors.New("no users table")
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the one stale row", processed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
|
||||
// when the worker has not reached an account yet, the first read materializes the
|
||||
// local projection and the second read must not write again.
|
||||
func TestEnsureRatingMaterializesOnce(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
rating, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRating: %v", err)
|
||||
}
|
||||
if rating.UserID != 7 || rating.Stars == 0 {
|
||||
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
|
||||
}
|
||||
writes := len(st.saves)
|
||||
again, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureRating: %v", err)
|
||||
}
|
||||
if again.Version != rating.Version {
|
||||
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
|
||||
}
|
||||
if len(st.saves) != writes {
|
||||
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled feature materialises nothing. Telegram wire fields remain unset
|
||||
// independently of this local feature flag.
|
||||
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if len(st.saves) != 0 {
|
||||
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
|
||||
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
|
||||
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
|
||||
// rating in the first place -- and an operator must not be able to create one by
|
||||
// hand either.
|
||||
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
|
||||
for _, userID := range domain.SystemUserIDs() {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
st.unrated = []int64{userID}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
|
||||
}
|
||||
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
|
||||
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
|
||||
}
|
||||
if len(st.ratings) != 0 {
|
||||
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
|
||||
}
|
||||
// A seeding pass that is somehow handed one skips it rather than failing the
|
||||
// whole cycle.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary account is unaffected.
|
||||
st := newFakeRatingStore()
|
||||
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
|
||||
t.Fatalf("Recompute of an ordinary account: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRecomputeInterval matches the shipped
|
||||
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
|
||||
defaultRecomputeInterval = 15 * time.Minute
|
||||
)
|
||||
|
||||
// RecomputeWorker keeps the rating read model fresh.
|
||||
//
|
||||
// The projection is derived from signals that change outside the rating write
|
||||
// path (Stars flow, message activity, moderation decisions, account age), so no
|
||||
// single writer can keep it current. This worker walks the stale projections in
|
||||
// bounded batches; it never recomputes the whole table in one pass, and a
|
||||
// cancelled context stops it between users rather than mid-write.
|
||||
type RecomputeWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the retention
|
||||
// worker's contract.
|
||||
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultRecomputeInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultRecomputeBatch
|
||||
}
|
||||
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run recomputes one batch immediately and then on every tick until ctx is
|
||||
// done. A disabled or store-less service exits immediately with one explicit
|
||||
// log line instead of ticking forever over a no-op.
|
||||
func (w *RecomputeWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("account rating recompute worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RecomputeWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("account rating recompute cycle failed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if processed > 0 {
|
||||
w.logger.Info("account rating recompute cycle completed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
|
||||
// single canonical pair used by both the Telegram download path and admin preview.
|
||||
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
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
|
||||
var rawJSON []byte
|
||||
if ext == ".tgs" || isGzip(data) {
|
||||
format = domain.StarGiftAnimationTGS
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
var err error
|
||||
rawJSON, err = decompressSingleTGS(data)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
} else {
|
||||
if ext != ".json" && ext != ".lottie" {
|
||||
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
rawJSON = data
|
||||
}
|
||||
|
||||
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
tgs, err := gzipLottie(normalized)
|
||||
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
sum := sha256.Sum256(tgs)
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: fileName,
|
||||
SourceFormat: format,
|
||||
JSON: normalized,
|
||||
TGS: tgs,
|
||||
SHA256: append([]byte(nil), sum[:]...),
|
||||
Width: meta.W,
|
||||
Height: meta.H,
|
||||
FrameRate: meta.FrameRate,
|
||||
InPoint: meta.InPoint,
|
||||
OutPoint: meta.OutPoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type lottieMetadata struct {
|
||||
Version string `json:"v"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
FrameRate float64 `json:"fr"`
|
||||
InPoint float64 `json:"ip"`
|
||||
OutPoint float64 `json:"op"`
|
||||
Layers []json.RawMessage `json:"layers"`
|
||||
Assets []json.RawMessage `json:"assets"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
var root any
|
||||
if err := dec.Decode(&root); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if _, ok := root.(map[string]any); !ok {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if !allowExpressions && containsLottieExpression(root) {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
var meta lottieMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
frameSpan := meta.OutPoint - meta.InPoint
|
||||
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
|
||||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
|
||||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
|
||||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
|
||||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
|
||||
// pre-composition assets with only an id/layers payload remain valid.
|
||||
for _, raw := range meta.Assets {
|
||||
var asset map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &asset) != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
for _, key := range []string{"p", "u"} {
|
||||
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return compact.Bytes(), meta, nil
|
||||
}
|
||||
|
||||
func containsLottieExpression(value any) bool {
|
||||
switch node := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range node {
|
||||
if key == "x" {
|
||||
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range node {
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGzip(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
|
||||
func decompressSingleTGS(data []byte) ([]byte, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
gz, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
gz.Multistream(false)
|
||||
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
|
||||
closeErr := gz.Close()
|
||||
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func gzipLottie(data []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gz.Header.ModTime = time.Unix(0, 0)
|
||||
gz.Header.OS = 255
|
||||
if _, err := gz.Write(data); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
|
||||
|
||||
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
|
||||
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare lottie: %v", err)
|
||||
}
|
||||
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
|
||||
t.Fatalf("prepared lottie = %+v", fromJSON)
|
||||
}
|
||||
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare tgs: %v", err)
|
||||
}
|
||||
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
|
||||
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
|
||||
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
|
||||
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
|
||||
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
|
||||
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *testGiftBlob) Name() string { return "localfs" }
|
||||
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func TestCreateCatalogRevisionPreservesHistoricalRevision(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)
|
||||
}
|
||||
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
|
||||
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
|
||||
t.Fatalf("current=%+v found=%v", current, found)
|
||||
}
|
||||
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
|
||||
if !found || historical.Stars != 50 || historical.Title != "OwpenGram Pin" {
|
||||
t.Fatalf("historical=%+v found=%v", historical, found)
|
||||
}
|
||||
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareOfficialAnimation("official.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestSHA := make([]byte, sha256.Size)
|
||||
result, err := svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
|
||||
Catalog: domain.StarGiftCatalogWrite{
|
||||
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, Animation: animation,
|
||||
Actor: "test", CommandID: "official-catalog", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA, OfficialSourceJSON: []byte(`{"id":10}`),
|
||||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create official collectible bundle: %v", err)
|
||||
}
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 2 || len(result.Collectible.Patterns) != 2 {
|
||||
t.Fatalf("collectible result = %+v", result.Collectible)
|
||||
}
|
||||
model := result.Collectible.Models[0].Document
|
||||
pattern := result.Collectible.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() {
|
||||
t.Fatalf("model document = %+v, want ordinary sticker", model)
|
||||
}
|
||||
if pattern == nil || pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("pattern document = %+v, want text-color custom emoji with inline path", pattern)
|
||||
}
|
||||
if !pattern.Attributes[1].TextColor {
|
||||
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
|
||||
}
|
||||
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
|
||||
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
|
||||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
|
||||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) {
|
||||
pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor {
|
||||
t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern)
|
||||
}
|
||||
model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel)
|
||||
if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor {
|
||||
t.Fatalf("model attributes = %+v, want ordinary sticker", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) {
|
||||
pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath ||
|
||||
pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern)
|
||||
}
|
||||
pattern[0].Bytes[0] ^= 0xff
|
||||
if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatal("collectibleDocumentThumbs returned shared mutable bytes")
|
||||
}
|
||||
if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 {
|
||||
t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
// Package stargifts implements the durable Star Gift catalog and received-gift state.
|
||||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// BlobBackend is the content-addressed media boundary used by the catalog importer.
|
||||
type BlobBackend interface {
|
||||
Name() string
|
||||
Put(ctx context.Context, data []byte) (string, error)
|
||||
Get(ctx context.Context, objectKey string) ([]byte, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store.StarGiftStore
|
||||
upgrades store.StarGiftUpgradeStore
|
||||
lifecycle store.StarGiftLifecycleStore
|
||||
withdrawal StarGiftWithdrawalProvider
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
|
||||
mu sync.RWMutex
|
||||
built bool
|
||||
gifts []domain.StarGift
|
||||
byID map[int64]domain.StarGift
|
||||
hash int
|
||||
|
||||
formMu sync.Mutex
|
||||
forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm
|
||||
}
|
||||
|
||||
type starGiftPurchaseFormKey struct {
|
||||
buyerUserID int64
|
||||
formID int64
|
||||
}
|
||||
|
||||
// AtomicPurchaseConfigured reports whether the production aggregate
|
||||
// coordinator is installed. It lets the RPC package keep its isolated memory
|
||||
// test adapter without silently downgrading PostgreSQL deployments.
|
||||
func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil }
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
|
||||
return func(service *Service) { service.upgrades = upgrades }
|
||||
}
|
||||
|
||||
func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option {
|
||||
return func(service *Service) { service.lifecycle = lifecycle }
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProvider interface {
|
||||
Name() string
|
||||
CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error)
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProviderRequest struct {
|
||||
UserID int64
|
||||
Gift domain.UniqueStarGift
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProviderResult struct {
|
||||
RequestID string
|
||||
URL string
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option {
|
||||
return func(service *Service) { service.withdrawal = provider }
|
||||
}
|
||||
|
||||
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
||||
service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) ensureCatalog(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
built := s.built
|
||||
s.mu.RUnlock()
|
||||
if built {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.built {
|
||||
return nil
|
||||
}
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("star gift store is not configured")
|
||||
}
|
||||
gifts, err := s.store.Catalog(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gifts = gifts
|
||||
s.byID = make(map[int64]domain.StarGift, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
s.byID[gift.ID] = gift
|
||||
}
|
||||
s.hash = domain.StarGiftCatalogHash(gifts)
|
||||
s.built = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]domain.StarGift(nil), s.gifts...), nil
|
||||
}
|
||||
|
||||
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return domain.StarGift{}, false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
gift, ok := s.byID[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return s.store.CatalogRevision(ctx, revisionID)
|
||||
}
|
||||
|
||||
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
|
||||
func (s *Service) InvalidateStarGiftCatalog() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.built = false
|
||||
s.gifts = nil
|
||||
s.byID = nil
|
||||
s.hash = 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
|
||||
|
||||
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
|
||||
}
|
||||
write.Title = branding.UserVisibleText(strings.TrimSpace(write.Title), "")
|
||||
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
|
||||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
|
||||
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 fmt.Errorf("store star gift animation: %w", err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return fmt.Errorf("generate star gift file reference: %w", err)
|
||||
}
|
||||
write.Document = domain.Document{
|
||||
ID: documentID,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
DCID: s.dc,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
|
||||
},
|
||||
}
|
||||
write.Blob = domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
SHA256: append([]byte(nil), write.Animation.SHA256...),
|
||||
MimeType: "application/x-tgsticker",
|
||||
}
|
||||
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 = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "")
|
||||
write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "")
|
||||
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))
|
||||
brandCollectibleAttributes(write.Collectible.Models)
|
||||
brandCollectibleAttributes(write.Collectible.Patterns)
|
||||
brandCollectibleAttributes(write.Collectible.Backdrops)
|
||||
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
|
||||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
validation := *write.Collectible
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = write.Catalog.GiftID
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = 1
|
||||
}
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
if write.Collectible != nil {
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
result, err := s.store.CreateCatalogBundle(ctx, write)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
|
||||
return s.store.AnimationJSON(ctx, giftID)
|
||||
}
|
||||
|
||||
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
|
||||
}
|
||||
brandCollectibleAttributes(write.Models)
|
||||
brandCollectibleAttributes(write.Patterns)
|
||||
brandCollectibleAttributes(write.Backdrops)
|
||||
revision, err := s.store.PublishCollectibleRevision(ctx, write)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
|
||||
// atomically publishes the complete immutable attribute pool. Callers must pass animations
|
||||
// produced by PrepareAnimation; partial revisions are never exposed to clients.
|
||||
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
|
||||
}
|
||||
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
|
||||
brandCollectibleAttributes(write.Models)
|
||||
brandCollectibleAttributes(write.Patterns)
|
||||
brandCollectibleAttributes(write.Backdrops)
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
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 brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) {
|
||||
for i := range attributes {
|
||||
attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error {
|
||||
for i := range attributes {
|
||||
animation := attributes[i].Animation
|
||||
if animation == nil {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, animation.TGS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return fmt.Errorf("generate collectible file reference: %w", err)
|
||||
}
|
||||
attributes[i].Document = &domain.Document{
|
||||
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(animation.TGS)), DCID: s.dc,
|
||||
Attributes: collectibleDocumentAttributes(attributes[i].Kind),
|
||||
Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
|
||||
}
|
||||
attributes[i].Blob = &domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
|
||||
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder.
|
||||
// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an
|
||||
// animated sticker only when document.thumbs is non-empty. The placeholder is
|
||||
// not used as the rendered collectible pattern: after classification Android
|
||||
// downloads and decodes the document's full TGS first frame. Keeping the
|
||||
// placeholder inline avoids introducing a second downloadable blob and matches
|
||||
// the shape used by official animated-sticker documents.
|
||||
var collectiblePatternPathThumb = []byte{
|
||||
0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e,
|
||||
0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c,
|
||||
0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3,
|
||||
0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c,
|
||||
0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90,
|
||||
0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86,
|
||||
0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79,
|
||||
0x87, 0x03, 0x47, 0x06, 0x87, 0x03,
|
||||
}
|
||||
|
||||
func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize {
|
||||
if kind != domain.StarGiftCollectiblePattern {
|
||||
return nil
|
||||
}
|
||||
return []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindPath,
|
||||
Type: "j",
|
||||
Bytes: append([]byte(nil), collectiblePatternPathThumb...),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute {
|
||||
renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"}
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
// DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the
|
||||
// pattern is a text-color custom emoji. Without this the gradient is
|
||||
// visible but the collectible pattern is rendered with its raw fill.
|
||||
renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
|
||||
}
|
||||
return []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
renderAttribute,
|
||||
{Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return s.collectiblePreview(ctx, giftID, 0)
|
||||
}
|
||||
|
||||
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
|
||||
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
|
||||
// payments.getStarGiftUpgradeAttributes and the admin editor.
|
||||
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
const attributesPerKind = 3
|
||||
return s.collectiblePreview(ctx, giftID, attributesPerKind)
|
||||
}
|
||||
|
||||
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
if s == nil || s.store == nil || giftID <= 0 {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
|
||||
if err != nil || !ok || !revision.Published {
|
||||
return domain.StarGiftUpgradePreview{}, false, err
|
||||
}
|
||||
return domain.StarGiftUpgradePreview{
|
||||
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
|
||||
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
|
||||
SlugPrefix: revision.SlugPrefix,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
if s == nil || s.store == nil || len(giftIDs) == 0 {
|
||||
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
|
||||
}
|
||||
return s.store.CollectibleAvailability(ctx, giftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueByID(ctx, uniqueGiftID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
|
||||
return map[int64]domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || owner.ID <= 0 ||
|
||||
(owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit))
|
||||
}
|
||||
|
||||
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.UpgradeStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// GrantUnique atomically assigns a freshly minted collectible to a user.
|
||||
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
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) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
|
||||
if s == nil {
|
||||
return false, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
// Isolated memory/RPC adapters have no settings table; production's
|
||||
// persisted default is enabled, so preserve that wire behavior.
|
||||
return true, nil
|
||||
}
|
||||
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
|
||||
}
|
||||
|
||||
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, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.TonTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
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, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
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, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.ChannelTonTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
return s.store.DeleteCollection(ctx, owner, collectionID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
return s.store.ReorderCollections(ctx, owner, collectionIDs)
|
||||
}
|
||||
|
||||
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
return s.store.SetPinned(ctx, owner, 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 {
|
||||
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.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)
|
||||
}
|
||||
|
||||
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
offset := filter.Offset
|
||||
if len(offset) > domain.MaxStarGiftsOffsetBytes {
|
||||
filter.Offset = ""
|
||||
}
|
||||
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
|
||||
filter.Limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
return s.store.ListByOwnerFiltered(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
return s.store.GetByRef(ctx, ref)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
return s.store.ResolveSavedIDs(ctx, owner, refs)
|
||||
}
|
||||
|
||||
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
|
||||
return s.store.CountByOwner(ctx, owner)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
||||
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 {
|
||||
return 0, fmt.Errorf("generate star gift id: %w", err)
|
||||
}
|
||||
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
|
||||
st := memory.NewStarGiftStore()
|
||||
st.SeedCatalog(gifts)
|
||||
return NewService(st, nil, 2), st
|
||||
}
|
||||
|
||||
func TestCatalogCachedAndHash(t *testing.T) {
|
||||
gifts := []domain.StarGift{
|
||||
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
|
||||
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
|
||||
}
|
||||
svc, _ := newTestService(gifts)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := svc.Catalog(ctx)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
|
||||
}
|
||||
// 再取一次命中进程内目录缓存。
|
||||
if _, err := svc.Catalog(ctx); err != nil {
|
||||
t.Fatalf("catalog#2: %v", err)
|
||||
}
|
||||
hash, err := svc.CatalogHash(ctx)
|
||||
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
|
||||
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
|
||||
}
|
||||
if g, ok, _ := svc.GiftByID(ctx, 2); !ok || g.Stars != 50 {
|
||||
t.Fatalf("GiftByID(2) = %+v ok %v, want Cake 50", g, ok)
|
||||
}
|
||||
if _, ok, _ := svc.GiftByID(ctx, 999); ok {
|
||||
t.Fatalf("GiftByID(999) found, want missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedGiftLifecycle(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
|
||||
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("RecordSavedGift = %d err %v", id, err)
|
||||
}
|
||||
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
|
||||
if err != nil || len(collection.GiftIDs) != 1 {
|
||||
t.Fatalf("CreateCollection = %+v err %v", collection, err)
|
||||
}
|
||||
|
||||
page, err := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
|
||||
t.Fatalf("list = %d count %d err %v, want 1/1", len(page.Gifts), page.Count, err)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
t.Fatalf("single page next_offset = %q, want empty", page.NextOffset)
|
||||
}
|
||||
|
||||
// 隐藏(unsave=true)→ excludeUnsaved 列表为空。
|
||||
ref := domain.SavedStarGiftRef{Owner: owner, MsgID: 50}
|
||||
if ok, err := svc.ToggleSaved(ctx, ref, true); err != nil || !ok {
|
||||
t.Fatalf("ToggleSaved hide = %v err %v", ok, err)
|
||||
}
|
||||
hidden, _ := svc.ListSaved(ctx, owner, true, "", 100)
|
||||
if len(hidden.Gifts) != 0 {
|
||||
t.Fatalf("excludeUnsaved list = %d, want 0 after hide", len(hidden.Gifts))
|
||||
}
|
||||
// 不带 exclude 仍能看到。
|
||||
all, _ := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if len(all.Gifts) != 1 {
|
||||
t.Fatalf("full list = %d, want 1 (hidden still listed)", len(all.Gifts))
|
||||
}
|
||||
|
||||
// 转换回 Stars → 标记 converted,从列表消失。
|
||||
saved, err := svc.Convert(ctx, ref)
|
||||
if err != nil || saved.ConvertStars != 15 {
|
||||
t.Fatalf("Convert = %+v err %v, want ConvertStars 15", saved, err)
|
||||
}
|
||||
after, _ := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if len(after.Gifts) != 0 {
|
||||
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
|
||||
}
|
||||
collections, err := svc.ListCollections(ctx, owner)
|
||||
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
|
||||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
|
||||
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
|
||||
}
|
||||
// 重复转换被拒。
|
||||
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
|
||||
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
|
||||
|
||||
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
|
||||
Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || savedID == 0 {
|
||||
t.Fatalf("RecordSavedGift(channel) = %d err %v, want allocated saved_id", savedID, err)
|
||||
}
|
||||
|
||||
gift, found, err := svc.GetSaved(ctx, domain.SavedStarGiftRef{Owner: owner, SavedID: savedID})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetSaved(channel) found=%v err=%v, want hit", found, err)
|
||||
}
|
||||
if gift.MsgID != 0 || gift.SavedID != savedID {
|
||||
t.Fatalf("channel saved gift ids = msg_id %d saved_id %d, want 0/%d", gift.MsgID, gift.SavedID, savedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedGiftPagination(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
|
||||
}); err != nil {
|
||||
t.Fatalf("record#%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
page1, _ := svc.ListSaved(ctx, owner, false, "", 2)
|
||||
if len(page1.Gifts) != 2 || page1.NextOffset == "" {
|
||||
t.Fatalf("page1 = %d next=%q, want 2 + next", len(page1.Gifts), page1.NextOffset)
|
||||
}
|
||||
page2, _ := svc.ListSaved(ctx, owner, false, page1.NextOffset, 2)
|
||||
page3, _ := svc.ListSaved(ctx, owner, false, page2.NextOffset, 2)
|
||||
if len(page3.Gifts) != 1 || page3.NextOffset != "" {
|
||||
t.Fatalf("page3 = %d next=%q, want 1 + empty (terminal)", len(page3.Gifts), page3.NextOffset)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页,
|
||||
// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、
|
||||
// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。
|
||||
package stars
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 是 Stars 账本应用服务。
|
||||
type Service struct {
|
||||
store store.StarsStore
|
||||
purchaseStore store.StarsPurchaseStore
|
||||
grantAmount int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Option 配置 Service。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。
|
||||
func WithStartingGrant(amount int64) Option {
|
||||
return func(s *Service) { s.grantAmount = amount }
|
||||
}
|
||||
|
||||
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
|
||||
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
|
||||
return func(s *Service) { s.purchaseStore = st }
|
||||
}
|
||||
|
||||
// WithClock 注入时钟(测试用)。
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。
|
||||
func NewService(st store.StarsStore, opts ...Option) *Service {
|
||||
s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。
|
||||
func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
if s.grantAmount > 0 {
|
||||
bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix()))
|
||||
return bal, err
|
||||
}
|
||||
return s.store.GetBalance(ctx, userID)
|
||||
}
|
||||
|
||||
// GetBalance 返回账号余额,首读时惰性授予起始余额。
|
||||
func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
return s.ensureGranted(ctx, userID)
|
||||
}
|
||||
|
||||
// Credit 为账号入账(amount>0)。
|
||||
func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
|
||||
}
|
||||
|
||||
// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。
|
||||
// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。
|
||||
func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||||
return domain.StarsBalance{}, err
|
||||
}
|
||||
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
|
||||
}
|
||||
|
||||
// ListTransactions 按方向与顺序做 keyset 分页,首读时惰性授予。
|
||||
func (s *Service) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
return s.store.ListTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
// IssuePurchaseForm persists a short-lived, exact checkout intent.
|
||||
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
if s.purchaseStore == nil || !validPurchaseForm(form) {
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
|
||||
}
|
||||
|
||||
// Purchase settles one exact persisted form. Package validation remains at
|
||||
// the RPC boundary as well, while the store revalidates the persisted tuple
|
||||
// under lock before performing any write.
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.PurchaseStars(ctx, req)
|
||||
}
|
||||
|
||||
// GetGiveawayInfo resolves one launch card from the same aggregate that
|
||||
// persisted it. date is supplied by the RPC clock for deterministic tests.
|
||||
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
|
||||
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
|
||||
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
|
||||
}
|
||||
|
||||
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
|
||||
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
|
||||
}
|
||||
|
||||
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
|
||||
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
|
||||
return false
|
||||
}
|
||||
switch form.Kind {
|
||||
case domain.StarsPurchaseTopup:
|
||||
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
|
||||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
|
||||
case domain.StarsPurchaseGift:
|
||||
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
|
||||
case domain.StarsPurchaseGiveaway:
|
||||
g := form.Giveaway
|
||||
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
|
||||
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
|
||||
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
|
||||
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package stars
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newTestService(grant int64) *Service {
|
||||
return NewService(memory.NewStarsStore(), WithStartingGrant(grant))
|
||||
}
|
||||
|
||||
// 起始授予幂等:多次 GetBalance 只授予一次。
|
||||
func TestStartingGrantOnce(t *testing.T) {
|
||||
svc := newTestService(1000)
|
||||
ctx := context.Background()
|
||||
bal, err := svc.GetBalance(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance: %v", err)
|
||||
}
|
||||
if bal.Balance != 1000 || !bal.Granted {
|
||||
t.Fatalf("first balance = %+v, want 1000 granted", bal)
|
||||
}
|
||||
// 再读不应重复授予。
|
||||
bal2, err := svc.GetBalance(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance#2: %v", err)
|
||||
}
|
||||
if bal2.Balance != 1000 {
|
||||
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
|
||||
}
|
||||
// 流水里应恰有一条 grant。
|
||||
page, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTransactions: %v", err)
|
||||
}
|
||||
if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 {
|
||||
t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭授予(grant=0)时余额为 0、无 grant 流水。
|
||||
func TestGrantDisabled(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
bal, err := svc.GetBalance(context.Background(), 9)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance: %v", err)
|
||||
}
|
||||
if bal.Balance != 0 || bal.Granted {
|
||||
t.Fatalf("balance = %+v, want 0 not granted", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。
|
||||
func TestDebitAndInsufficient(t *testing.T) {
|
||||
svc := newTestService(1000)
|
||||
ctx := context.Background()
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555}
|
||||
|
||||
bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Debit: %v", err)
|
||||
}
|
||||
if bal.Balance != 700 {
|
||||
t.Fatalf("after debit = %d, want 700", bal.Balance)
|
||||
}
|
||||
|
||||
// 余额不足。
|
||||
if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
// 余额未被改动。
|
||||
after, _ := svc.GetBalance(ctx, 7)
|
||||
if after.Balance != 700 {
|
||||
t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance)
|
||||
}
|
||||
|
||||
// 非法金额。
|
||||
if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) {
|
||||
t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 贷记增加余额并写正流水。
|
||||
func TestCredit(t *testing.T) {
|
||||
svc := newTestService(0) // 关闭起始授予,单测贷记
|
||||
ctx := context.Background()
|
||||
bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Credit: %v", err)
|
||||
}
|
||||
if bal.Balance != 250 {
|
||||
t.Fatalf("after credit = %d, want 250", bal.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。
|
||||
func TestListTransactionsPagination(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("Credit#%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
page1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page1: %v", err)
|
||||
}
|
||||
if len(page1.Transactions) != 2 || page1.NextOffset == "" {
|
||||
t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset)
|
||||
}
|
||||
// 倒序:最新(id 最大,amount=14)在前。
|
||||
if page1.Transactions[0].Amount != 14 {
|
||||
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
|
||||
}
|
||||
page2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page1.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page2: %v", err)
|
||||
}
|
||||
if len(page2.Transactions) != 2 {
|
||||
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
|
||||
}
|
||||
page3, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page2.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page3: %v", err)
|
||||
}
|
||||
if len(page3.Transactions) != 1 {
|
||||
t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions))
|
||||
}
|
||||
if page3.NextOffset != "" {
|
||||
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTransactionsDirectionAndAscending(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.Credit(ctx, 7, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 100: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 40: %v", err)
|
||||
}
|
||||
if _, err := svc.Credit(ctx, 7, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 20: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 10: %v", err)
|
||||
}
|
||||
|
||||
all, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("all transactions: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, all.Transactions, []int64{-10, 20, -40, 100})
|
||||
if all.Balance != 70 {
|
||||
t.Fatalf("all balance = %d, want 70", all.Balance)
|
||||
}
|
||||
|
||||
incoming1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page1: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming1.Transactions, []int64{20})
|
||||
if incoming1.NextOffset == "" {
|
||||
t.Fatal("incoming page1 missing next offset")
|
||||
}
|
||||
incoming2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page2: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming2.Transactions, []int64{100})
|
||||
if incoming2.NextOffset != "" {
|
||||
t.Fatalf("terminal incoming next offset = %q", incoming2.NextOffset)
|
||||
}
|
||||
|
||||
outgoing, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ascending outgoing: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, outgoing.Transactions, []int64{-40, -10})
|
||||
|
||||
_, err = svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Direction: 99})
|
||||
if !errors.Is(err, domain.ErrStarsTransactionQueryInvalid) {
|
||||
t.Fatalf("invalid direction error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
|
||||
t.Helper()
|
||||
if len(transactions) != len(want) {
|
||||
t.Fatalf("transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
|
||||
}
|
||||
for i, amount := range want {
|
||||
if transactions[i].Amount != amount {
|
||||
t.Fatalf("transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -575,7 +575,7 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
|
|||
// to the account's other sessions and offline difference stream.
|
||||
func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if !status.Valid() {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrEmojiStatusCollectibleInvalid
|
||||
}
|
||||
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
|
|
|
|||
|
|
@ -552,7 +552,7 @@ func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, s
|
|||
return domain.User{}, err
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
return domain.User{}, domain.ErrEmojiStatusCollectibleInvalid
|
||||
}
|
||||
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
|
||||
return domain.User{}, domain.ErrPremiumRequired
|
||||
|
|
|
|||
|
|
@ -546,32 +546,6 @@ func StoryAlbums() tg.StoriesAlbumsClass {
|
|||
return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}}
|
||||
}
|
||||
|
||||
func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
|
||||
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
|
||||
}
|
||||
|
||||
func StarGifts() tg.PaymentsStarGiftsClass {
|
||||
return &tg.PaymentsStarGifts{
|
||||
Gifts: []tg.StarGiftClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
|
||||
return &tg.PaymentsSavedStarGifts{
|
||||
Gifts: []tg.SavedStarGift{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func StarGiftCollections() tg.PaymentsStarGiftCollectionsClass {
|
||||
return &tg.PaymentsStarGiftCollections{
|
||||
Collections: []tg.StarGiftCollection{},
|
||||
}
|
||||
}
|
||||
|
||||
func StarsRevenueStats(ton bool) *tg.PaymentsStarsRevenueStats {
|
||||
zeroAmount := tg.StarsAmountClass(&tg.StarsAmount{})
|
||||
if ton {
|
||||
|
|
|
|||
|
|
@ -246,11 +246,6 @@ 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
|
||||
// BlobBackendKind selects the blob storage backend: "localfs" (default)
|
||||
|
|
@ -432,63 +427,11 @@ type Config struct {
|
|||
// PasskeyAllowedOrigins 是允许的 WebAuthn origin 白名单;为空=不强校验 origin
|
||||
//(服务端通常不预知 Android apk-key-hash origin)。
|
||||
PasskeyAllowedOrigins []string
|
||||
// StarsStartingGrant 是 Stars 本地账本的起始余额(首读时惰性授予、granted 布尔幂等,
|
||||
// 新老账号都覆盖、免回填迁移);0 关闭自动授予。
|
||||
StarsStartingGrant int64
|
||||
// PremiumSweepInterval 是会员到期 sweeper 的轮询间隔。premium 下发正确性
|
||||
// 由读取路径即时派生,sweeper 只负责清理过期行并推 updateUser 通知。
|
||||
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
|
||||
|
||||
// RatingEnabled controls the local admin-only composite account rating.
|
||||
// Disabled keeps every local projection empty and refuses rating writes; no
|
||||
// client-facing Telegram field changes in either mode.
|
||||
RatingEnabled bool
|
||||
// RatingPendingDelay is how long a rating increase stays parked as a pending
|
||||
// local score before it becomes the visible admin level. A decrease is
|
||||
// always applied immediately: a penalty must not sit behind a delay.
|
||||
// 0 applies every change immediately.
|
||||
RatingPendingDelay time.Duration
|
||||
// RatingRecomputeInterval / RatingRecomputeBatch drive the background
|
||||
// recompute worker. The rating derives from signals owned by other
|
||||
// subsystems, so freshness is a worker property, not a write-path one.
|
||||
RatingRecomputeInterval time.Duration
|
||||
RatingRecomputeBatch int
|
||||
// RatingStaleAfter is the projection age after which the worker recomputes a
|
||||
// user.
|
||||
RatingStaleAfter time.Duration
|
||||
// Rating weights are the integer composite formula. Defaults mirror
|
||||
// domain.DefaultAccountRatingWeights() exactly, so the shipped behaviour is
|
||||
// identical whether or not these keys are set. Every weight is a magnitude:
|
||||
// the penalties are subtracted by the domain formula, so all values are
|
||||
// non-negative and a negative value fails startup.
|
||||
RatingWeightStarsReceivedPermille int64
|
||||
RatingWeightStarsSpentPermille int64
|
||||
RatingWeightMessageSent int64
|
||||
RatingWeightAccountAgeDay int64
|
||||
RatingWeightGiftReceived int64
|
||||
RatingWeightModerationCase int64
|
||||
RatingWeightScamPenalty int64
|
||||
RatingWeightFakePenalty int64
|
||||
// RatingActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh Stars and moderation; 0 leaves it uncapped.
|
||||
RatingActivityCap int64
|
||||
// VerificationEnabled controls official platform verification: the @verifybot
|
||||
// application flow and the panel's review queue. Disabled refuses every
|
||||
// verification use case explicitly; already-verified peers keep their badge,
|
||||
|
|
@ -697,9 +640,6 @@ func Load() (Config, error) {
|
|||
if err != nil {
|
||||
return Config{}, fmt.Errorf("TELESRV_ADVERTISE_IP: %w", err)
|
||||
}
|
||||
// The composite rating weight defaults are the domain formula's own defaults;
|
||||
// see RatingWeight* below.
|
||||
defaultRatingWeights := domain.DefaultAccountRatingWeights()
|
||||
adminScopedTokens, err := parseAdminScopedTokens(envAllowEmptyOr("TELESRV_ADMIN_SCOPED_TOKENS", ""))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
|
@ -809,8 +749,6 @@ 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"),
|
||||
// s3 (MinIO by default, see deploy/docker-compose.yml's minio service) is
|
||||
// the default blob backend; localfs remains fully supported as an
|
||||
|
|
@ -894,43 +832,13 @@ func Load() (Config, error) {
|
|||
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
|
||||
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
|
||||
|
||||
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
|
||||
DefaultStickerSetID: envInt64Or("TELESRV_DEFAULT_STICKER_SET_ID", 0),
|
||||
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
|
||||
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
|
||||
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),
|
||||
|
||||
RatingEnabled: envBoolOr("TELESRV_RATING_ENABLED", true),
|
||||
RatingPendingDelay: envDurationOr("TELESRV_RATING_PENDING_DELAY", 24*time.Hour),
|
||||
RatingRecomputeInterval: envDurationOr("TELESRV_RATING_RECOMPUTE_INTERVAL", 15*time.Minute),
|
||||
RatingRecomputeBatch: envIntOr("TELESRV_RATING_RECOMPUTE_BATCH", 500),
|
||||
RatingStaleAfter: envDurationOr("TELESRV_RATING_STALE_AFTER", 6*time.Hour),
|
||||
// Weight defaults are read from the domain formula itself so the shipped
|
||||
// behaviour cannot drift from domain.DefaultAccountRatingWeights().
|
||||
RatingWeightStarsReceivedPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", defaultRatingWeights.StarsReceivedPermille),
|
||||
RatingWeightStarsSpentPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE", defaultRatingWeights.StarsSpentPermille),
|
||||
RatingWeightMessageSent: envInt64Or("TELESRV_RATING_WEIGHT_MESSAGE_SENT", defaultRatingWeights.PerMessageSent),
|
||||
RatingWeightAccountAgeDay: envInt64Or("TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY", defaultRatingWeights.PerAccountAgeDay),
|
||||
RatingWeightGiftReceived: envInt64Or("TELESRV_RATING_WEIGHT_GIFT_RECEIVED", defaultRatingWeights.PerGiftReceived),
|
||||
RatingWeightModerationCase: envInt64Or("TELESRV_RATING_WEIGHT_MODERATION_CASE", defaultRatingWeights.PerModerationCase),
|
||||
RatingWeightScamPenalty: envInt64Or("TELESRV_RATING_WEIGHT_SCAM_PENALTY", defaultRatingWeights.ScamPenalty),
|
||||
RatingWeightFakePenalty: envInt64Or("TELESRV_RATING_WEIGHT_FAKE_PENALTY", defaultRatingWeights.FakePenalty),
|
||||
RatingActivityCap: envInt64Or("TELESRV_RATING_ACTIVITY_CAP", defaultRatingWeights.ActivityCap),
|
||||
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
|
||||
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
|
||||
DefaultStickerSetID: envInt64Or("TELESRV_DEFAULT_STICKER_SET_ID", 0),
|
||||
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
|
||||
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
|
||||
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
|
||||
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
|
||||
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
|
||||
|
||||
// Official verification defaults ship the feature on with the official bar
|
||||
// in place: user accounts are not accepted, a rejection costs a month, and
|
||||
|
|
@ -988,12 +896,6 @@ func Load() (Config, error) {
|
|||
if err := validateRPCExecutionConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateStarGiftConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateAccountRatingConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateCollectibleUsernameConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
|
@ -1078,77 +980,6 @@ func validateTelegramLoginConfig(cfg Config) error {
|
|||
return 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
|
||||
}
|
||||
|
||||
// AccountRatingWeights renders the configured composite rating formula. It is
|
||||
// the single conversion point between env keys and the domain formula, so the
|
||||
// app service and the admin explanation always use the same numbers.
|
||||
func (c Config) AccountRatingWeights() domain.AccountRatingWeights {
|
||||
return domain.AccountRatingWeights{
|
||||
StarsReceivedPermille: c.RatingWeightStarsReceivedPermille,
|
||||
StarsSpentPermille: c.RatingWeightStarsSpentPermille,
|
||||
PerMessageSent: c.RatingWeightMessageSent,
|
||||
PerAccountAgeDay: c.RatingWeightAccountAgeDay,
|
||||
PerGiftReceived: c.RatingWeightGiftReceived,
|
||||
PerModerationCase: c.RatingWeightModerationCase,
|
||||
ScamPenalty: c.RatingWeightScamPenalty,
|
||||
FakePenalty: c.RatingWeightFakePenalty,
|
||||
ActivityCap: c.RatingActivityCap,
|
||||
}
|
||||
}
|
||||
|
||||
// validateAccountRatingConfig rejects a formula or worker cadence that cannot
|
||||
// produce a reproducible rating. Weights are validated even when the feature is
|
||||
// disabled: enabling it later must not be the moment a typo is discovered.
|
||||
func validateAccountRatingConfig(cfg Config) error {
|
||||
if err := cfg.AccountRatingWeights().Validate(); err != nil {
|
||||
return fmt.Errorf("TELESRV_RATING_WEIGHT_* and TELESRV_RATING_ACTIVITY_CAP must be non-negative: %w", err)
|
||||
}
|
||||
if cfg.RatingPendingDelay < 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must be non-negative")
|
||||
}
|
||||
const maxRatingPendingDelay = 30 * 24 * time.Hour
|
||||
if cfg.RatingPendingDelay > maxRatingPendingDelay {
|
||||
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must not exceed 720h")
|
||||
}
|
||||
if cfg.RatingRecomputeInterval <= 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_INTERVAL must be positive")
|
||||
}
|
||||
if cfg.RatingStaleAfter <= 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_STALE_AFTER must be positive")
|
||||
}
|
||||
if cfg.RatingRecomputeBatch <= 0 || cfg.RatingRecomputeBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_BATCH must be 1..10000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminPermissionAll is the wildcard permission: a session or token carrying it
|
||||
// may perform every admin action.
|
||||
const adminPermissionAll = "*"
|
||||
|
|
|
|||
|
|
@ -785,102 +785,6 @@ 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 TestLoadAccountRatingDefaults(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.RatingEnabled {
|
||||
t.Fatal("RatingEnabled = false, want the feature on by default")
|
||||
}
|
||||
if cfg.RatingPendingDelay != 24*time.Hour || cfg.RatingRecomputeInterval != 15*time.Minute ||
|
||||
cfg.RatingRecomputeBatch != 500 || cfg.RatingStaleAfter != 6*time.Hour {
|
||||
t.Fatalf("rating worker defaults = %v/%v/%d/%v, want 24h/15m/500/6h",
|
||||
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
|
||||
}
|
||||
if got, want := cfg.AccountRatingWeights(), domain.DefaultAccountRatingWeights(); got != want {
|
||||
t.Fatalf("rating weights = %#v, want the domain defaults %#v", got, want)
|
||||
}
|
||||
if cfg.CollectibleUsernameURLTemplate != "" {
|
||||
t.Fatalf("CollectibleUsernameURLTemplate = %q, want empty (derived from the public base URL)",
|
||||
cfg.CollectibleUsernameURLTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAccountRatingOverrides(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_RATING_ENABLED", "false")
|
||||
t.Setenv("TELESRV_RATING_PENDING_DELAY", "1h")
|
||||
t.Setenv("TELESRV_RATING_RECOMPUTE_INTERVAL", "90s")
|
||||
t.Setenv("TELESRV_RATING_RECOMPUTE_BATCH", "42")
|
||||
t.Setenv("TELESRV_RATING_STALE_AFTER", "30m")
|
||||
t.Setenv("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", "500")
|
||||
t.Setenv("TELESRV_RATING_WEIGHT_MESSAGE_SENT", "0")
|
||||
t.Setenv("TELESRV_RATING_ACTIVITY_CAP", "0")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.RatingEnabled {
|
||||
t.Fatal("RatingEnabled = true, want the explicit override")
|
||||
}
|
||||
weights := cfg.AccountRatingWeights()
|
||||
if weights.StarsReceivedPermille != 500 || weights.PerMessageSent != 0 || weights.ActivityCap != 0 {
|
||||
t.Fatalf("weights = %#v, want the overridden values", weights)
|
||||
}
|
||||
if weights.StarsSpentPermille != domain.DefaultAccountRatingWeights().StarsSpentPermille {
|
||||
t.Fatalf("unset weight = %d, want the domain default", weights.StarsSpentPermille)
|
||||
}
|
||||
if cfg.RatingPendingDelay != time.Hour || cfg.RatingRecomputeInterval != 90*time.Second ||
|
||||
cfg.RatingRecomputeBatch != 42 || cfg.RatingStaleAfter != 30*time.Minute {
|
||||
t.Fatalf("rating worker overrides = %v/%v/%d/%v",
|
||||
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidAccountRatingConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{name: "negative stars weight", key: "TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", value: "-1"},
|
||||
{name: "negative moderation weight", key: "TELESRV_RATING_WEIGHT_MODERATION_CASE", value: "-150"},
|
||||
{name: "negative scam penalty", key: "TELESRV_RATING_WEIGHT_SCAM_PENALTY", value: "-1"},
|
||||
{name: "negative activity cap", key: "TELESRV_RATING_ACTIVITY_CAP", value: "-5000"},
|
||||
{name: "negative pending delay", key: "TELESRV_RATING_PENDING_DELAY", value: "-1h"},
|
||||
{name: "zero recompute interval", key: "TELESRV_RATING_RECOMPUTE_INTERVAL", value: "0s"},
|
||||
{name: "zero stale horizon", key: "TELESRV_RATING_STALE_AFTER", value: "0s"},
|
||||
{name: "zero recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "0"},
|
||||
{name: "oversized recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "20000"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv(test.key, test.value)
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCollectibleUsernameURLTemplate(t *testing.T) {
|
||||
t.Run("absolute template accepted", func(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Composite account rating.
|
||||
//
|
||||
// This is gramsrv's server-local account score. It deliberately uses its own
|
||||
// inputs and thresholds (Stars, activity and moderation), rather than claiming
|
||||
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
|
||||
// stored level through userFull's existing rating fields so official clients can
|
||||
// render it without a client patch.
|
||||
const (
|
||||
// MaxAccountRatingLevel bounds the local gramsrv level.
|
||||
MaxAccountRatingLevel = 50
|
||||
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
|
||||
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
|
||||
accountRatingLevelUnit = 100
|
||||
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
|
||||
MaxAccountRatingReasonLength = 512
|
||||
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
|
||||
MaxAccountRatingActorLength = 128
|
||||
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
|
||||
MaxAccountRatingCommandKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountRatingNotFound reports a user with no rating row yet.
|
||||
ErrAccountRatingNotFound = errors.New("account rating not found")
|
||||
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
|
||||
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
|
||||
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
|
||||
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
|
||||
)
|
||||
|
||||
// AccountRatingEventKind is the contribution source of a ledger row. Only
|
||||
// 'manual' rows survive a full recompute; the rest are audit trail.
|
||||
type AccountRatingEventKind string
|
||||
|
||||
const (
|
||||
AccountRatingEventStars AccountRatingEventKind = "stars"
|
||||
AccountRatingEventActivity AccountRatingEventKind = "activity"
|
||||
AccountRatingEventModeration AccountRatingEventKind = "moderation"
|
||||
AccountRatingEventManual AccountRatingEventKind = "manual"
|
||||
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
|
||||
)
|
||||
|
||||
// Valid reports whether the kind is modelled.
|
||||
func (k AccountRatingEventKind) Valid() bool {
|
||||
switch k {
|
||||
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
|
||||
AccountRatingEventManual, AccountRatingEventRecompute:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRating is the stored read model for one user.
|
||||
type AccountRating struct {
|
||||
UserID int64
|
||||
Level int
|
||||
Stars int64
|
||||
CurrentLevelStars int64
|
||||
// NextLevelStars is meaningful only when HasNextLevel is true.
|
||||
NextLevelStars int64
|
||||
HasNextLevel bool
|
||||
// Components are the explainable breakdown. PenaltyComponent is a
|
||||
// non-negative magnitude that is subtracted.
|
||||
StarsComponent int64
|
||||
ActivityComponent int64
|
||||
PenaltyComponent int64
|
||||
ManualComponent int64
|
||||
// PendingStars is a score delta not yet applied to the visible level, with
|
||||
// PendingDate reporting when it becomes effective.
|
||||
PendingStars int64
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// AccountRatingLevel is the local client/admin-facing level snapshot.
|
||||
type AccountRatingLevel struct {
|
||||
Level int
|
||||
CurrentLevelStars int64
|
||||
Stars int64
|
||||
NextLevelStars int64
|
||||
HasNextLevelStars bool
|
||||
}
|
||||
|
||||
// RatableAccount reports whether an account may carry a composite rating.
|
||||
//
|
||||
// The rating measures what an account did with Stars -- gifts bought, paid
|
||||
// messages sent, activity, moderation history. Two kinds of account have no
|
||||
// meaningful answer there and are excluded everywhere the rating is computed,
|
||||
// seeded or projected:
|
||||
//
|
||||
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
|
||||
// its score would only ever be the flat account-age term.
|
||||
// - The built-in service accounts (the platform account, BotFather, @Stickers,
|
||||
// @ChatBot, the verification bots). They are infrastructure rather than
|
||||
// participants: a leaderboard entry for the platform account is noise, and a
|
||||
// level badge on it would claim something about transaction volume that means
|
||||
// nothing.
|
||||
//
|
||||
// Note that the platform account is not flagged is_bot, so the bot check alone
|
||||
// does not cover it -- which is exactly how it ended up in the seeding pass.
|
||||
func RatableAccount(userID int64, bot bool) bool {
|
||||
return userID > 0 && !bot && !IsSystemUserID(userID)
|
||||
}
|
||||
|
||||
// LevelSnapshot returns the current visible local level.
|
||||
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
|
||||
return AccountRatingLevel{
|
||||
Level: r.Level,
|
||||
CurrentLevelStars: r.CurrentLevelStars,
|
||||
Stars: r.Stars,
|
||||
NextLevelStars: r.NextLevelStars,
|
||||
HasNextLevelStars: r.HasNextLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// PendingLevel returns the local level after the pending score is applied and
|
||||
// reports whether a pending record exists at all.
|
||||
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
|
||||
if r.PendingStars == 0 || r.PendingDate.IsZero() {
|
||||
return AccountRatingLevel{}, false
|
||||
}
|
||||
total := r.Stars + r.PendingStars
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
return AccountRatingLevel{
|
||||
Level: level,
|
||||
CurrentLevelStars: current,
|
||||
Stars: total,
|
||||
NextLevelStars: next,
|
||||
HasNextLevelStars: hasNext,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AccountRatingWeights is the composite formula. All weights are integers so the
|
||||
// score is exactly reproducible across a recompute and across store backends.
|
||||
type AccountRatingWeights struct {
|
||||
// StarsReceivedPermille weighs Stars credited to the account (gifts,
|
||||
// reactions, paid messages received), in permille of the raw amount.
|
||||
StarsReceivedPermille int64
|
||||
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
|
||||
// signal than receiving, so the default is lower.
|
||||
StarsSpentPermille int64
|
||||
// PerMessageSent rewards sustained use.
|
||||
PerMessageSent int64
|
||||
// PerAccountAgeDay rewards account longevity.
|
||||
PerAccountAgeDay int64
|
||||
// PerGiftReceived rewards collectible gifts held.
|
||||
PerGiftReceived int64
|
||||
// PerModerationCase is the penalty for each upheld moderation case.
|
||||
PerModerationCase int64
|
||||
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
|
||||
ScamPenalty int64
|
||||
FakePenalty int64
|
||||
// ActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh everything else. Zero means uncapped.
|
||||
ActivityCap int64
|
||||
}
|
||||
|
||||
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
|
||||
// activity contributes a bounded floor, and moderation subtracts.
|
||||
func DefaultAccountRatingWeights() AccountRatingWeights {
|
||||
return AccountRatingWeights{
|
||||
StarsReceivedPermille: 1000,
|
||||
StarsSpentPermille: 250,
|
||||
PerMessageSent: 1,
|
||||
PerAccountAgeDay: 2,
|
||||
PerGiftReceived: 25,
|
||||
PerModerationCase: 150,
|
||||
ScamPenalty: 5000,
|
||||
FakePenalty: 5000,
|
||||
ActivityCap: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects negative weights and an impossible cap.
|
||||
func (w AccountRatingWeights) Validate() error {
|
||||
values := []int64{
|
||||
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
|
||||
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
|
||||
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
|
||||
}
|
||||
for _, v := range values {
|
||||
if v < 0 {
|
||||
return ErrAccountRatingWeightsInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals is the raw snapshot gathered from the contributing
|
||||
// sources for one user. It is deliberately a plain value: the same snapshot must
|
||||
// produce the same score in a unit test and in production.
|
||||
type AccountRatingSignals struct {
|
||||
UserID int64
|
||||
StarsReceived int64
|
||||
StarsSpent int64
|
||||
MessagesSent int64
|
||||
AccountAgeDays int64
|
||||
GiftsReceived int64
|
||||
ModerationCases int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
// Manual is the sum of admin adjustments, carried across recomputes.
|
||||
Manual int64
|
||||
}
|
||||
|
||||
// ComputeAccountRating turns a signal snapshot into the read model. The score is
|
||||
// clamped at zero: penalties can erase this local score but never invert it.
|
||||
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
|
||||
if err := weights.Validate(); err != nil {
|
||||
weights = DefaultAccountRatingWeights()
|
||||
}
|
||||
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
|
||||
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
|
||||
|
||||
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
|
||||
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
|
||||
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
|
||||
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
|
||||
activityComponent = weights.ActivityCap
|
||||
}
|
||||
|
||||
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
|
||||
if signals.Scam {
|
||||
penalty += weights.ScamPenalty
|
||||
}
|
||||
if signals.Fake {
|
||||
penalty += weights.FakePenalty
|
||||
}
|
||||
|
||||
total := starsComponent + activityComponent + signals.Manual - penalty
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
|
||||
return AccountRating{
|
||||
UserID: signals.UserID,
|
||||
Level: level,
|
||||
Stars: total,
|
||||
CurrentLevelStars: current,
|
||||
NextLevelStars: next,
|
||||
HasNextLevel: hasNext,
|
||||
StarsComponent: starsComponent,
|
||||
ActivityComponent: activityComponent,
|
||||
PenaltyComponent: penalty,
|
||||
ManualComponent: signals.Manual,
|
||||
ComputedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRatingLevelThreshold returns the score needed to reach the given level.
|
||||
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
|
||||
// later ones stay meaningful.
|
||||
func AccountRatingLevelThreshold(level int) int64 {
|
||||
if level <= 0 {
|
||||
return 0
|
||||
}
|
||||
if level > MaxAccountRatingLevel {
|
||||
level = MaxAccountRatingLevel
|
||||
}
|
||||
n := int64(level)
|
||||
return accountRatingLevelUnit * n * n
|
||||
}
|
||||
|
||||
// AccountRatingLevelForStars maps a score onto the level and the surrounding
|
||||
// thresholds. hasNext is false at MaxAccountRatingLevel.
|
||||
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
|
||||
if stars < 0 {
|
||||
stars = 0
|
||||
}
|
||||
level = 0
|
||||
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
|
||||
if stars < AccountRatingLevelThreshold(candidate) {
|
||||
break
|
||||
}
|
||||
level = candidate
|
||||
}
|
||||
currentLevelStars = AccountRatingLevelThreshold(level)
|
||||
if level >= MaxAccountRatingLevel {
|
||||
return level, currentLevelStars, 0, false
|
||||
}
|
||||
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
|
||||
}
|
||||
|
||||
// ResolveAccountRatingPending decides whether a freshly computed score becomes
|
||||
// visible immediately or is parked as pending.
|
||||
//
|
||||
// A score that dropped is applied at once -- a penalty must not sit behind a
|
||||
// delay. A score that grew is parked until delay has elapsed; once the parked
|
||||
// window has passed the pending delta is folded into the visible local rating.
|
||||
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
|
||||
out := computed
|
||||
out.Version = prev.Version + 1
|
||||
if out.Version <= 0 {
|
||||
out.Version = 1
|
||||
}
|
||||
if delay <= 0 || prev.UserID == 0 {
|
||||
return out
|
||||
}
|
||||
if computed.Stars <= prev.Stars {
|
||||
return out
|
||||
}
|
||||
// A previously parked delta whose date has arrived is applied now.
|
||||
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
|
||||
return out
|
||||
}
|
||||
pendingSince := prev.PendingDate
|
||||
if prev.PendingStars == 0 || pendingSince.IsZero() {
|
||||
pendingSince = now.Add(delay)
|
||||
}
|
||||
visible := prev
|
||||
visible.StarsComponent = computed.StarsComponent
|
||||
visible.ActivityComponent = computed.ActivityComponent
|
||||
visible.PenaltyComponent = computed.PenaltyComponent
|
||||
visible.ManualComponent = computed.ManualComponent
|
||||
visible.PendingStars = computed.Stars - prev.Stars
|
||||
visible.PendingDate = pendingSince
|
||||
visible.ComputedAt = now
|
||||
visible.UpdatedAt = now
|
||||
visible.Version = out.Version
|
||||
return visible
|
||||
}
|
||||
|
||||
// AccountRatingEvent is one contribution ledger row.
|
||||
type AccountRatingEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind AccountRatingEventKind
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
|
||||
type AdjustAccountRatingRequest struct {
|
||||
UserID int64
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate rejects a no-op or oversized adjustment.
|
||||
func (r AdjustAccountRatingRequest) Validate() error {
|
||||
if r.UserID <= 0 || r.Amount == 0 {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxAccountRatingReasonLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxAccountRatingActorLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingFilter bounds an admin listing query.
|
||||
type AccountRatingFilter struct {
|
||||
MinLevel int
|
||||
UserID int64
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func permille(value, weight int64) int64 {
|
||||
if value <= 0 || weight <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value * weight / 1000
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -611,11 +611,6 @@ const (
|
|||
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice:
|
||||
// 广播频道 Direct Messages 开关/价格变更的服务消息。
|
||||
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"
|
||||
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
|
||||
|
|
@ -655,9 +650,6 @@ type ChannelMessageAction struct {
|
|||
Completed []int
|
||||
Incompleted []int
|
||||
TodoItems []MessageTodoItem
|
||||
// StarGift 仅 star_gift 服务消息使用。
|
||||
StarGift *MessageStarGiftAction
|
||||
StarGiftUnique *MessageStarGiftUniqueAction
|
||||
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
|
||||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
|
|
@ -852,9 +844,6 @@ type ChannelMessageReactions struct {
|
|||
AsTags bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions
|
||||
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
|
||||
Paid *ChannelMessagePaidReactions
|
||||
}
|
||||
|
||||
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
|
||||
|
|
@ -1685,19 +1674,17 @@ func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
|
|||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
// optional Published result is the broadcast post.
|
||||
type ToggleSuggestedPostApprovalResult struct {
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
PayerStarsBalance *StarsBalance
|
||||
PayerTONBalance *int64
|
||||
Duplicate bool
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
|
||||
|
|
@ -1824,8 +1811,6 @@ type SendChannelMessageResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。
|
||||
SenderStarsBalance *StarsBalance
|
||||
// ReplayDeleteEvent is the existing durable channel delete event paired
|
||||
// with a deleted exact-random_id replay. It must be returned only to the
|
||||
// caller echo and must never be fanned out as a fresh event.
|
||||
|
|
|
|||
|
|
@ -591,18 +591,6 @@ const (
|
|||
// 状态切换与关闭请求。会话级保护不能写入普通消息的 NoForwards 字段。
|
||||
MessageServiceActionNoForwardsToggle MessageServiceActionKind = "no_forwards_toggle"
|
||||
MessageServiceActionNoForwardsRequest MessageServiceActionKind = "no_forwards_request"
|
||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
||||
// MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased
|
||||
// Stars credited directly to a friend, distinct from collectible Star Gifts.
|
||||
MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars"
|
||||
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
|
||||
// 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 的协议中立载荷。
|
||||
|
|
@ -685,92 +673,6 @@ type MessageServiceAction struct {
|
|||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
|
||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
|
||||
}
|
||||
|
||||
// MessageGiftStarsAction is the immutable service-message projection. The
|
||||
// recipient-only BalanceAfter field is not encoded in messageActionGiftStars;
|
||||
// it lets online push and offline difference attach the matching non-PTS
|
||||
// updateStarsBalance without querying mutable current state.
|
||||
type MessageGiftStarsAction struct {
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Stars int64 `json:"stars"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type MessageStarGiftUniqueAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
Peer Peer `json:"peer"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
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 快照)。
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
package domain
|
||||
|
||||
// 频道帖子付费 reaction(messages.sendPaidReaction):用户花 Stars 为一条频道消息「点赞」,
|
||||
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
|
||||
// 与 Stars 账本(stars.go)配合:rpc 先 Debit 再在此累计。
|
||||
|
||||
const (
|
||||
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max)。
|
||||
MaxPaidReactionStarsPerRequest = 10000
|
||||
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
|
||||
MaxPaidReactionTopReactors = 3
|
||||
)
|
||||
|
||||
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
|
||||
type PaidReactor struct {
|
||||
UserID int64
|
||||
Stars int64
|
||||
Anonymous bool
|
||||
My bool // 是否为当前 viewer(投影时按视角置位)
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
|
||||
type ChannelMessagePaidReactions struct {
|
||||
TotalStars int64 // 全体 reactor 投入星数之和
|
||||
MyStars int64 // 当前 viewer 投入的星数(0 = 未投)
|
||||
MyAnonymous bool // 当前 viewer 是否匿名投入
|
||||
TopReactors []PaidReactor // 按 Stars DESC,含当前 viewer
|
||||
}
|
||||
|
||||
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
|
||||
type SendChannelPaidReactionRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Stars int64
|
||||
Anonymous bool // 隐私:是否匿名投入
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
|
||||
type ChannelMessagePaidReactionResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Paid ChannelMessagePaidReactions
|
||||
Recipients []int64
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,194 +0,0 @@
|
|||
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: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func storedCollectibleWrite() StarGiftCollectibleWrite {
|
||||
write := validCollectibleDraft()
|
||||
for i := range write.Models {
|
||||
write.Models[i].Document = &Document{
|
||||
ID: int64(100 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
|
||||
}
|
||||
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
|
||||
}
|
||||
for i := range write.Patterns {
|
||||
write.Patterns[i].Document = &Document{
|
||||
ID: int64(200 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
|
||||
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
|
||||
}
|
||||
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
|
||||
}
|
||||
return write
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"one selectable model": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models = append(write.Models[:1], write.Models[2:]...)
|
||||
},
|
||||
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns = write.Patterns[:1]
|
||||
},
|
||||
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops = write.Backdrops[:1]
|
||||
},
|
||||
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
|
||||
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
if kind == StarGiftCollectibleModel {
|
||||
write.Models[1].Document = write.Models[0].Document
|
||||
} else {
|
||||
write.Patterns[1].Document = write.Patterns[0].Document
|
||||
}
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
|
||||
t.Fatalf("valid stored collectible: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
|
||||
},
|
||||
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes[0].TextColor = false
|
||||
},
|
||||
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Thumbs = nil
|
||||
},
|
||||
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
|
||||
},
|
||||
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
|
||||
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
|
||||
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
|
||||
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
|
||||
got, ok := DecodeSavedStarGiftListCursor(encoded)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
|
||||
}
|
||||
|
||||
unpinned := SavedStarGiftListCursor{ID: 42}
|
||||
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
|
||||
if !ok || got != unpinned {
|
||||
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
|
||||
for _, cursor := range []string{
|
||||
"not-base64!",
|
||||
EncodeStarGiftCursor(42),
|
||||
EncodeSavedStarGiftListCursor(-1, 42),
|
||||
EncodeSavedStarGiftListCursor(1, 0),
|
||||
} {
|
||||
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
|
||||
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
|
||||
// 非真实支付:余额为整数 Stars(线上 Nanos 恒 0),借记原子、永不为负。
|
||||
|
||||
// StarsBalance 是一个账号的当前可用 Stars 余额。
|
||||
type StarsBalance struct {
|
||||
UserID int64
|
||||
Balance int64 // 当前可花费 Stars,恒 >= 0
|
||||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
||||
}
|
||||
|
||||
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
|
||||
// checkout. It is persisted with the form so a client cannot reinterpret a
|
||||
// self top-up as a friend gift (or vice versa) when submitting the form.
|
||||
type StarsPurchaseKind string
|
||||
|
||||
const (
|
||||
StarsPurchaseTopup StarsPurchaseKind = "topup"
|
||||
StarsPurchaseGift StarsPurchaseKind = "gift"
|
||||
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
|
||||
)
|
||||
|
||||
func (k StarsPurchaseKind) Valid() bool {
|
||||
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
|
||||
}
|
||||
|
||||
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
|
||||
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
|
||||
// eventual winner draw is a separate lifecycle transition.
|
||||
type StarsGiveawayPurchase struct {
|
||||
BoostPeer Peer `json:"boost_peer"`
|
||||
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
|
||||
CountriesISO2 []string `json:"countries_iso2,omitempty"`
|
||||
PrizeDescription string `json:"prize_description,omitempty"`
|
||||
RandomID int64 `json:"random_id"`
|
||||
UntilDate int `json:"until_date"`
|
||||
Users int `json:"users"`
|
||||
PerUserStars int64 `json:"per_user_stars"`
|
||||
YearlyBoosts int `json:"yearly_boosts"`
|
||||
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
|
||||
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
|
||||
}
|
||||
|
||||
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
|
||||
// authenticated buyer, purpose and exact server-advertised package. Recipient
|
||||
// is zero for a self top-up and mandatory for a friend gift.
|
||||
type StarsPurchaseForm struct {
|
||||
FormID int64
|
||||
Kind StarsPurchaseKind
|
||||
BuyerUserID int64
|
||||
RecipientUserID int64
|
||||
SpendPurposePeer Peer
|
||||
Giveaway *StarsGiveawayPurchase
|
||||
Stars int64
|
||||
Currency string
|
||||
Amount int64
|
||||
IssuedAt int
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
// StarsPurchaseRequest is the immutable settlement command carried by
|
||||
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
|
||||
// command after the ordinary fiat checkout has produced provider credentials.
|
||||
type StarsPurchaseRequest struct {
|
||||
StarsPurchaseForm
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// StarsPurchaseResult is the atomically committed credit and, for a friend
|
||||
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
|
||||
type StarsPurchaseResult struct {
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
ChannelSend SendChannelMessageResult
|
||||
TransactionID string
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
|
||||
// Winner selection/results are intentionally outside the purchase aggregate.
|
||||
type StarsGiveawayInfo struct {
|
||||
StartDate int
|
||||
Participating bool
|
||||
PreparingResults bool
|
||||
JoinedTooEarlyDate int
|
||||
AdminDisallowedChatID int64
|
||||
DisallowedCountry string
|
||||
}
|
||||
|
||||
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
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" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
type StarsTransaction struct {
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
Reason StarsTransactionReason
|
||||
Title string // 可选,投影到 tg.StarsTransaction.Title
|
||||
Description string // 可选,投影到 tg.StarsTransaction.Description
|
||||
}
|
||||
|
||||
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
|
||||
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
|
||||
|
||||
// StarsTransactionDirection scopes one payments.getStarsTransactions view.
|
||||
// The zero value intentionally means the combined inbound/outbound history.
|
||||
type StarsTransactionDirection uint8
|
||||
|
||||
const (
|
||||
StarsTransactionDirectionAll StarsTransactionDirection = iota
|
||||
StarsTransactionDirectionIncoming
|
||||
StarsTransactionDirectionOutgoing
|
||||
)
|
||||
|
||||
func (d StarsTransactionDirection) Valid() bool {
|
||||
return d <= StarsTransactionDirectionOutgoing
|
||||
}
|
||||
|
||||
func (d StarsTransactionDirection) IncludesAmount(amount int64) bool {
|
||||
switch d {
|
||||
case StarsTransactionDirectionAll:
|
||||
return true
|
||||
case StarsTransactionDirectionIncoming:
|
||||
return amount > 0
|
||||
case StarsTransactionDirectionOutgoing:
|
||||
return amount < 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor
|
||||
// together so filtering is applied before LIMIT in every ledger backend.
|
||||
type StarsTransactionQuery struct {
|
||||
Offset string
|
||||
Limit int
|
||||
Direction StarsTransactionDirection
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
// NormalizeStarsTransactionQuery preserves the existing bounded limit/offset
|
||||
// behavior while rejecting impossible internal direction values.
|
||||
func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) {
|
||||
if !query.Direction.Valid() {
|
||||
return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid
|
||||
}
|
||||
if len(query.Offset) > MaxStarsTransactionsOffsetBytes {
|
||||
query.Offset = ""
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit {
|
||||
query.Limit = MaxStarsTransactionsLimit
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
|
||||
type StarsTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []StarsTransaction
|
||||
NextOffset string // 空表示无更多页(DrKLO 据此停止翻页,勿在末页给非空值)
|
||||
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 余额(本地测试用)。
|
||||
DefaultStarsStartingGrant = 1000
|
||||
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
|
||||
MaxStarsTransactionsLimit = 100
|
||||
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
|
||||
MaxStarsTransactionsOffsetBytes = 64
|
||||
)
|
||||
|
||||
// Stars 账本哨兵错误(rpc 层 errors.Is 匹配后映射为 tgerr,仿 ErrPremiumRequired)。
|
||||
var (
|
||||
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW)。
|
||||
ErrStarsInsufficient = errors.New("stars: insufficient balance")
|
||||
// ErrStarsInvalidAmount 表示金额非法(<=0)。
|
||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
||||
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
|
||||
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
|
||||
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
|
||||
// ErrStarsPurchaseFormExpired is returned before any settlement write.
|
||||
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
|
||||
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
|
||||
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
|
||||
)
|
||||
|
||||
// StarsPaymentRequiredError reports the minimum paid-message authorization the
|
||||
// sender must include in allow_paid_stars. The authorization is a ceiling; the
|
||||
// ledger debits only the channel's current configured price.
|
||||
type StarsPaymentRequiredError struct {
|
||||
Stars int64
|
||||
}
|
||||
|
||||
func (e *StarsPaymentRequiredError) Error() string {
|
||||
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
|
||||
}
|
||||
|
||||
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
|
||||
func EncodeStarsCursor(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
// DecodeStarsCursor 反解 EncodeStarsCursor;无法解析(含空串)时返回 ok=false,
|
||||
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
|
||||
func DecodeStarsCursor(s string) (int64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrEmojiStatusCollectibleInvalid is returned when a collectible emoji-status
|
||||
// snapshot fails EmojiStatusCollectible.Valid() -- partial/malformed rather
|
||||
// than either fully empty or fully populated.
|
||||
var ErrEmojiStatusCollectibleInvalid = errors.New("emoji status collectible invalid")
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,533 +0,0 @@
|
|||
// 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"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
)
|
||||
|
||||
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: branding.UserVisibleText(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: branding.UserVisibleText(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: branding.UserVisibleText(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: branding.UserVisibleText(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: branding.UserVisibleText(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: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc})
|
||||
}
|
||||
for _, value := range set.Backdrops {
|
||||
collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: branding.UserVisibleText(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
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
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, Title: "Telegram Pin", 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 || items[0].Title != "OwpenGram Pin" {
|
||||
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) || bundle.Gift.Title != "OwpenGram Pin" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1792,7 +1792,7 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
|
|||
if errors.Is(err, domain.ErrPremiumRequired) {
|
||||
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) {
|
||||
return false, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return false, internalErr()
|
||||
|
|
@ -1842,29 +1842,9 @@ func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input
|
|||
}
|
||||
return value, nil
|
||||
case *tg.InputEmojiStatusCollectible:
|
||||
if r.deps.Gifts == nil || status.CollectibleID <= 0 {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID)
|
||||
if err != nil {
|
||||
return domain.UserEmojiStatus{}, internalErr()
|
||||
}
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
collectible, valid := domain.CollectibleEmojiStatus(gift)
|
||||
if !valid {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
if until, ok := status.GetUntil(); ok {
|
||||
value.Until = until
|
||||
}
|
||||
if !value.Valid() {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
// telesrv has no Star Gifts, so no account can ever own the collectible
|
||||
// this would reference.
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
default:
|
||||
return domain.UserEmojiStatus{}, inputConstructorInvalidErr()
|
||||
}
|
||||
|
|
@ -1969,35 +1949,11 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6
|
|||
// owned unique gifts as complete emojiStatusCollectible values. The bounded
|
||||
// list order and hash are stable, so Android can safely reuse its cache.
|
||||
func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
}
|
||||
gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
ids := make([]int64, 0, len(gifts))
|
||||
statuses := make([]tg.EmojiStatusClass, 0, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(gift)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, collectible.CollectibleID)
|
||||
statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{
|
||||
DocumentID: collectible.DocumentID,
|
||||
Collectible: collectible,
|
||||
}))
|
||||
}
|
||||
catalogHash := mediaCatalogHash(ids)
|
||||
if hash != 0 && hash == catalogHash {
|
||||
return &tg.AccountEmojiStatusesNotModified{}, nil
|
||||
}
|
||||
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
|
||||
// telesrv has no Star Gifts, so no account ever owns a collectible to list.
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
}
|
||||
|
||||
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) *tg.User {
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type collectibleEmojiGiftService struct {
|
||||
GiftsService
|
||||
gifts map[int64]domain.UniqueStarGift
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) {
|
||||
gift, ok := s.gifts[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
out := make([]domain.UniqueStarGift, 0, len(s.gifts))
|
||||
for _, gift := range s.gifts {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift {
|
||||
return domain.UniqueStarGift{
|
||||
ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1",
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}},
|
||||
Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}},
|
||||
Backdrop: domain.StarGiftCollectibleAttribute{
|
||||
CenterColor: 0x102030, EdgeColor: 0x405060,
|
||||
PatternColor: 0x708090, TextColor: 0xa0b0c0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := appusers.NewService(userStore)
|
||||
if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil {
|
||||
t.Fatalf("grant premium: %v", err)
|
||||
}
|
||||
gift := collectibleEmojiTestGift(owner.ID)
|
||||
gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}}
|
||||
r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System)
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
|
||||
listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get collectible statuses: %v", err)
|
||||
}
|
||||
statuses, ok := listed.(*tg.AccountEmojiStatuses)
|
||||
if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 {
|
||||
t.Fatalf("collectible list = %T %#v", listed, listed)
|
||||
}
|
||||
collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible)
|
||||
if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID ||
|
||||
collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor {
|
||||
t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0])
|
||||
}
|
||||
if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil {
|
||||
t.Fatalf("get cached collectible statuses: %v", err)
|
||||
} else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok {
|
||||
t.Fatalf("cached collectible statuses = %T, want notModified", cached)
|
||||
}
|
||||
|
||||
input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}
|
||||
input.SetUntil(2_000_000_000)
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok {
|
||||
t.Fatalf("set collectible status: ok=%v err=%v", ok, err)
|
||||
}
|
||||
self, err := users.Self(ctx, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID ||
|
||||
self.EmojiStatusUntil != 2_000_000_000 {
|
||||
t.Fatalf("persisted collectible status = %+v", self.EmojiStatus())
|
||||
}
|
||||
wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible)
|
||||
if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor {
|
||||
t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire)
|
||||
}
|
||||
|
||||
stolen := gift
|
||||
stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
gifts.gifts[gift.ID] = stolen
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") {
|
||||
t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1))
|
||||
if !ok {
|
||||
t.Fatal("test gift should project")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{
|
||||
UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value,
|
||||
}).(*tg.UpdateUserEmojiStatus)
|
||||
if !ok {
|
||||
t.Fatal("durable event did not produce updateUserEmojiStatus")
|
||||
}
|
||||
if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID {
|
||||
t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeAccountRatingProjection struct {
|
||||
byUser map[int64]domain.AccountRating
|
||||
err error
|
||||
ratingCalls int
|
||||
ensureCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.ratingCalls++
|
||||
if f.err != nil {
|
||||
return domain.AccountRating{}, f.err
|
||||
}
|
||||
rating, ok := f.byUser[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// EnsureRating deliberately exists on the fake even though it is not part of
|
||||
// AccountRatingService. The assertion below pins that profile reads stay
|
||||
// read-only if a future implementation happens to expose a materializer.
|
||||
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.ensureCalls++
|
||||
return f.byUser[userID], nil
|
||||
}
|
||||
|
||||
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
|
||||
|
||||
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 11,
|
||||
Phone: "15550004001",
|
||||
FirstName: "Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
other, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 22,
|
||||
Phone: "15550004002",
|
||||
FirstName: "Other",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
AccountRatings: ratings,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return router, owner, other
|
||||
}
|
||||
|
||||
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
|
||||
pendingDate := time.Unix(1800000000, 0).UTC()
|
||||
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
||||
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
|
||||
ratings.byUser[owner.ID] = domain.AccountRating{
|
||||
UserID: owner.ID,
|
||||
Level: 3,
|
||||
Stars: 1200,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
|
||||
NextLevelStars: domain.AccountRatingLevelThreshold(4),
|
||||
HasNextLevel: true,
|
||||
PendingStars: 500,
|
||||
PendingDate: pendingDate,
|
||||
}
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
|
||||
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
|
||||
if err != nil {
|
||||
t.Fatalf("get self full user: %v", err)
|
||||
}
|
||||
rating, ok := full.FullUser.GetStarsRating()
|
||||
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
|
||||
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
|
||||
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
|
||||
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
|
||||
}
|
||||
pending, ok := full.FullUser.GetStarsMyPendingRating()
|
||||
if !ok || pending.Stars != 1700 {
|
||||
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
|
||||
}
|
||||
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
|
||||
t.Fatalf("self pending date = %d (present=%v)", date, ok)
|
||||
}
|
||||
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
||||
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
||||
}
|
||||
|
||||
// The second response is served from the existing UserFull projection cache:
|
||||
// the rating read cannot become a per-request database query.
|
||||
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
|
||||
t.Fatalf("get cached self full user: %v", err)
|
||||
}
|
||||
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
||||
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
|
||||
pendingDate := time.Unix(1800000000, 0).UTC()
|
||||
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
||||
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
|
||||
ratings.byUser[other.ID] = domain.AccountRating{
|
||||
UserID: other.ID,
|
||||
Level: 1,
|
||||
Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
NextLevelStars: domain.AccountRatingLevelThreshold(2),
|
||||
HasNextLevel: true,
|
||||
PendingStars: 500,
|
||||
PendingDate: pendingDate,
|
||||
}
|
||||
|
||||
full, err := router.onUsersGetFullUser(
|
||||
WithUserID(context.Background(), owner.ID),
|
||||
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get other full user: %v", err)
|
||||
}
|
||||
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
|
||||
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
||||
t.Fatal("other pending rating is visible")
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
|
||||
t.Fatal("other pending rating date is visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ratings AccountRatingService
|
||||
}{
|
||||
{name: "service absent"},
|
||||
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
|
||||
{name: "read failure", ratings: &fakeAccountRatingProjection{
|
||||
byUser: map[int64]domain.AccountRating{},
|
||||
err: errors.New("rating unavailable"),
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
|
||||
full, err := router.onUsersGetFullUser(
|
||||
WithUserID(context.Background(), owner.ID),
|
||||
&tg.InputUserSelf{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get full user: %v", err)
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("rating set without a stored projection")
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
||||
t.Fatal("pending rating set without a stored projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
viewer, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 31,
|
||||
Phone: "15550004101",
|
||||
FirstName: "Viewer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
bot, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 32,
|
||||
Phone: "15550004102",
|
||||
FirstName: "Helper",
|
||||
Bot: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
|
||||
viewer.ID: {
|
||||
UserID: viewer.ID,
|
||||
Level: domain.MaxAccountRatingLevel,
|
||||
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
||||
},
|
||||
bot.ID: {
|
||||
UserID: bot.ID,
|
||||
Level: 4,
|
||||
Stars: 2000,
|
||||
},
|
||||
domain.OfficialSystemUserID: {
|
||||
UserID: domain.OfficialSystemUserID,
|
||||
Level: 5,
|
||||
Stars: 3000,
|
||||
},
|
||||
}}
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
AccountRatings: ratings,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
viewerCtx := WithUserID(ctx, viewer.ID)
|
||||
|
||||
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
|
||||
if err != nil {
|
||||
t.Fatalf("get own full user: %v", err)
|
||||
}
|
||||
rating, ok := own.FullUser.GetStarsRating()
|
||||
if !ok || rating.Level != domain.MaxAccountRatingLevel {
|
||||
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if _, ok := rating.GetNextLevelStars(); ok {
|
||||
t.Fatal("next_level_stars set at the maximum level")
|
||||
}
|
||||
|
||||
botFull, err := router.onUsersGetFullUser(
|
||||
viewerCtx,
|
||||
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot full user: %v", err)
|
||||
}
|
||||
if _, ok := botFull.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("bot rating is visible")
|
||||
}
|
||||
|
||||
official, err := router.onUsersGetFullUser(
|
||||
viewerCtx,
|
||||
&tg.InputUser{
|
||||
UserID: domain.OfficialSystemUserID,
|
||||
AccessHash: domain.OfficialSystemUser().AccessHash,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get official system user: %v", err)
|
||||
}
|
||||
if _, ok := official.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("system account rating is visible")
|
||||
}
|
||||
// One read for the ratable viewer and none for the bot/system guards.
|
||||
if ratings.ratingCalls != 1 {
|
||||
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -34,19 +32,6 @@ func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) er
|
|||
return nil
|
||||
}
|
||||
|
||||
// NotifyStarsBalanceChanged is the domain-only hook used by the internal Admin
|
||||
// API after the local Stars ledger balance has changed outside a client RPC.
|
||||
func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
|
||||
if r == nil || balance.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
r.pushUserUpdates(ctx, balance.UserID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAccountFreezeChanged invalidates target-scoped projections immediately
|
||||
// and wakes the durable audience nudge worker. Cross-instance cache invalidation
|
||||
// is also carried by the committed user_visibility read-model notification.
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
|
||||
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
|
||||
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
|
||||
r.applyBotVerificationToChannelFull(ctx, ref.ID, &full)
|
||||
|
|
@ -192,7 +191,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
return nil, err
|
||||
}
|
||||
full := tgChannelFull(view, r.cfg.PublicBaseURL)
|
||||
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
|
||||
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
|
||||
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
|
||||
// (此前审计误判为死代码,已由单测纠正——勿删。)
|
||||
|
|
@ -237,16 +235,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) applyStarGiftsCountToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
|
||||
if r.deps.Gifts == nil || channelID == 0 || full == nil {
|
||||
return
|
||||
}
|
||||
n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||||
if err == nil && n > 0 {
|
||||
full.SetStargiftsCount(n)
|
||||
}
|
||||
}
|
||||
|
||||
type channelReadModelResolver interface {
|
||||
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,12 +175,6 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
|
|||
if !ok || len(some.Reactions) != catalogSize {
|
||||
t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize)
|
||||
}
|
||||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
||||
|
|
@ -230,9 +224,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
|||
if fullChannel.ReactionsLimit != 7 {
|
||||
t.Fatalf("reactions limit after omitted flag = %d, want preserved 7", fullChannel.ReactionsLimit)
|
||||
}
|
||||
if !fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after omitted flag = false, want preserved true")
|
||||
}
|
||||
|
||||
disablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: peer,
|
||||
|
|
@ -253,12 +244,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
|||
if fullChannel.ReactionsLimit != 7 {
|
||||
t.Fatalf("reactions limit after paid-only update = %d, want preserved 7", fullChannel.ReactionsLimit)
|
||||
}
|
||||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after explicit false = true, want false")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
||||
|
|
@ -299,10 +284,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get full channel after TDesktop sentinel set: %v", err)
|
||||
}
|
||||
fullChannel := full.FullChat.(*tg.ChannelFull)
|
||||
if !fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
|
||||
}
|
||||
some := mustChannelFullSomeReactions(t, full)
|
||||
if len(some.Reactions) != 2 {
|
||||
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
|
||||
|
|
@ -336,81 +317,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Broadcast",
|
||||
Broadcast: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast channel: %v", err)
|
||||
}
|
||||
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel: %v", err)
|
||||
}
|
||||
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed = false, want true")
|
||||
}
|
||||
if broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
|
||||
}
|
||||
|
||||
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enablePaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
|
||||
t.Fatalf("enable broadcast paid reactions: %v", err)
|
||||
}
|
||||
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel after paid enable: %v", err)
|
||||
}
|
||||
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
|
||||
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
|
||||
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Mega",
|
||||
Megagroup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enableMegaPaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
|
||||
t.Fatalf("set megagroup paid_enabled request: %v", err)
|
||||
}
|
||||
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get megagroup full channel: %v", err)
|
||||
}
|
||||
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
|
||||
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
|
||||
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -304,10 +304,6 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
BroadcastMessagesAllowed: action.BroadcastMessagesAllowed,
|
||||
Stars: action.Stars,
|
||||
}
|
||||
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}
|
||||
|
|
@ -649,19 +645,6 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
|||
if ch.ReactionPolicy.Limit > 0 {
|
||||
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
|
||||
}
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
// Android uses paid_media_allowed as the capability gate for showing the
|
||||
// paid-reaction setting; paid_reactions_available below remains the saved
|
||||
// on/off state.
|
||||
full.SetPaidMediaAllowed(true)
|
||||
full.SetStargiftsAvailable(true)
|
||||
}
|
||||
// paid_reactions_available reflects the saved chat policy, not mere broadcast
|
||||
// capability. Android counts this flag as an extra available reaction in the
|
||||
// settings row, so advertising it without paid_enabled corrupts the UI count.
|
||||
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
|
||||
full.SetPaidReactionsAvailable(true)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -242,90 +242,11 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
ButtonID: shared.ButtonID,
|
||||
Peers: tgPeerList(shared.Peers),
|
||||
}
|
||||
case domain.MessageServiceActionStarGift:
|
||||
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
|
||||
case domain.MessageServiceActionGiftStars:
|
||||
action := m.ServiceAction.GiftStars
|
||||
if action == nil || action.Currency == "" || action.Amount <= 0 || action.Stars <= 0 {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
out := &tg.MessageActionGiftStars{
|
||||
Currency: action.Currency,
|
||||
Amount: action.Amount,
|
||||
Stars: action.Stars,
|
||||
}
|
||||
// Telegram only exposes the provider transaction id to the receiver.
|
||||
if !msg.Out && action.TransactionID != "" {
|
||||
out.SetTransactionID(action.TransactionID)
|
||||
}
|
||||
return out
|
||||
case domain.MessageServiceActionStarGiftUnique:
|
||||
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)
|
||||
}
|
||||
// Channel Craft is not executable yet. Gate on the authoritative gift owner
|
||||
// as a final wire boundary so historical JSON/admin-log actions or a future
|
||||
// constructor cannot accidentally expose Android's Craft entry marker.
|
||||
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
|
||||
}
|
||||
if peer := tgPeer(action.Peer); peer != nil {
|
||||
out.SetPeer(peer)
|
||||
}
|
||||
if action.SavedID != 0 {
|
||||
out.SetSavedID(action.SavedID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
|
||||
out := make([]tg.PeerClass, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
|
|
@ -511,11 +432,6 @@ func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions)
|
|||
out.SetRecentReactions(recent)
|
||||
}
|
||||
}
|
||||
// 付费 reaction:注入 ReactionPaid 计数 + top reactors(My/chosen 由 in.Paid 的视角数据驱动,
|
||||
// 调用方对他人视角已抹除 My/MyStars)。统一在此注入,覆盖所有频道消息读路径。
|
||||
if in.Paid != nil {
|
||||
injectPaidReaction(out, *in.Paid)
|
||||
}
|
||||
if out.Results == nil {
|
||||
out.Results = []tg.ReactionCount{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,6 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
|
|||
out.NewMessages = append(out.NewMessages, msg)
|
||||
addMessageUsers(out, seenUsers, event.Message)
|
||||
}
|
||||
if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil {
|
||||
out.OtherUpdates = append(out.OtherUpdates, balance)
|
||||
}
|
||||
case domain.UpdateEventReadHistoryInbox:
|
||||
if update := tgReadHistoryInboxUpdate(event); update != nil {
|
||||
out.OtherUpdates = append(out.OtherUpdates, update)
|
||||
|
|
|
|||
|
|
@ -810,7 +810,6 @@ type ChannelsService interface {
|
|||
// SetActiveCall / AppendCallServiceMessage 是群通话模块的频道侧挂接点。
|
||||
SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error)
|
||||
AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error)
|
||||
AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error
|
||||
InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
|
||||
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
}
|
||||
|
|
@ -1004,16 +1003,6 @@ type UsernameRegistryService interface {
|
|||
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
||||
}
|
||||
|
||||
// AccountRatingService exposes the stored gramsrv composite rating used by the
|
||||
// userFull rating projection.
|
||||
//
|
||||
// It is deliberately read-only at the RPC boundary: ratings are computed by the
|
||||
// bounded background worker, while profile reads only fetch the latest stored
|
||||
// projection. A nil service or a read failure leaves every rating flag unset.
|
||||
type AccountRatingService interface {
|
||||
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
}
|
||||
|
||||
// BotVerificationService is the third-party bot verification boundary
|
||||
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
|
||||
// own icon and description, which official clients render as a badge distinct from
|
||||
|
|
@ -1065,7 +1054,6 @@ type Deps struct {
|
|||
Moderation ModerationService
|
||||
Users UsersService
|
||||
Usernames UsernameRegistryService
|
||||
AccountRatings AccountRatingService
|
||||
BotVerifications BotVerificationService
|
||||
TelegramLogin TelegramLoginService
|
||||
Updates UpdatesService
|
||||
|
|
@ -1096,8 +1084,6 @@ type Deps struct {
|
|||
Limiter RateLimiter
|
||||
Metrics Metrics
|
||||
SecretChats SecretChatService
|
||||
Stars StarsService
|
||||
Gifts GiftsService
|
||||
Passkey PasskeyService
|
||||
Themes ThemeService
|
||||
}
|
||||
|
|
@ -1127,71 +1113,6 @@ type PasskeyService interface {
|
|||
Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error)
|
||||
}
|
||||
|
||||
// GiftsService 抽象 Star 礼物(app/stargifts):目录 + peer 收到的礼物实例 CRUD。
|
||||
// 扣费/退款/服务消息投递由 rpc 层经 Stars 账本 + Messages.SendPrivateText 编排。
|
||||
type GiftsService interface {
|
||||
Catalog(ctx context.Context) ([]domain.StarGift, error)
|
||||
CatalogHash(ctx context.Context) (int, error)
|
||||
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
|
||||
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
|
||||
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
|
||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
|
||||
ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]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)
|
||||
GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
|
||||
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)
|
||||
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, query domain.StarsTransactionQuery) (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):余额查询、贷记/借记、流水分页。
|
||||
// 借记原子且永不为负;余额不足返回 domain.ErrStarsInsufficient(rpc 经 starsErr
|
||||
// 映射为 BALANCE_TOO_LOW)。getStarsStatus 首读时惰性授予起始余额。
|
||||
type StarsService interface {
|
||||
GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error)
|
||||
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
||||
Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
||||
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
|
||||
}
|
||||
|
||||
// SecretChatService 抽象私聊端对端加密(Secret Chat)握手状态机(app/secretchat)。
|
||||
// 服务端是盲中继;错误集合见 domain.ErrSecretChat* 与 app/secretchat.ErrGAInvalid
|
||||
// (rpc 层经 secretChatErr 映射为 ENCRYPTION_* / CHAT_ID_INVALID / DH_G_A_INVALID)。
|
||||
|
|
|
|||
|
|
@ -300,11 +300,6 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
|
|||
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
|
||||
}
|
||||
updates = append(updates, newMsg)
|
||||
if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{
|
||||
Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance},
|
||||
})
|
||||
}
|
||||
date := int(r.clock.Now().Unix())
|
||||
if res.Duplicate && res.ReplayDeleteEvent != nil {
|
||||
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {
|
||||
|
|
|
|||
|
|
@ -296,12 +296,6 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 {
|
||||
t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft)
|
||||
}
|
||||
tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554}
|
||||
tooLow.SetAllowPaidStars(9)
|
||||
if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") {
|
||||
t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err)
|
||||
}
|
||||
|
||||
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
|
||||
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
|
||||
subReq.ClearDraft = true
|
||||
|
|
@ -319,7 +313,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd)
|
||||
}
|
||||
var subMessageID int
|
||||
var subPaidStars, subBalance int64
|
||||
var subPaidStars int64
|
||||
for _, update := range subUpdates.Updates {
|
||||
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
|
||||
if message, ok := newMessage.Message.(*tg.Message); ok {
|
||||
|
|
@ -336,36 +330,21 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
|
||||
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
|
||||
subBalance = amount.Amount
|
||||
}
|
||||
}
|
||||
}
|
||||
if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 {
|
||||
t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates)
|
||||
// telesrv has no Stars economy: DM sends are always free, so no
|
||||
// UpdateStarsBalance is ever emitted regardless of allow_paid_stars.
|
||||
if subMessageID == 0 || subPaidStars != 0 {
|
||||
t.Fatalf("subscriber send updates id/paid = %d/%d, want id>0/0: %#v", subMessageID, subPaidStars, subUpdates.Updates)
|
||||
}
|
||||
if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found {
|
||||
t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err)
|
||||
t.Fatalf("clear_draft after send found/err = %v/%v, want false/nil", found, err)
|
||||
}
|
||||
duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber paid replay: %v", err)
|
||||
t.Fatalf("subscriber replay: %v", err)
|
||||
}
|
||||
duplicateUpdates, ok := duplicateUpd.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd)
|
||||
}
|
||||
var duplicateBalance int64
|
||||
for _, update := range duplicateUpdates.Updates {
|
||||
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
|
||||
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
|
||||
duplicateBalance = amount.Amount
|
||||
}
|
||||
}
|
||||
}
|
||||
if duplicateBalance != 990 {
|
||||
t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance)
|
||||
if _, ok := duplicateUpd.(*tg.Updates); !ok {
|
||||
t.Fatalf("subscriber replay = %T, want *tg.Updates", duplicateUpd)
|
||||
}
|
||||
|
||||
// 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id
|
||||
|
|
@ -435,8 +414,10 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
if _, ok := top.Media.(*tg.MessageMediaContact); !ok {
|
||||
t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media)
|
||||
}
|
||||
if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 {
|
||||
t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok)
|
||||
// telesrv has no Stars economy: sends are always free regardless of the
|
||||
// channel's configured price, so paid_message_stars is never set.
|
||||
if paid, ok := top.GetPaidMessageStars(); ok || paid != 0 {
|
||||
t.Fatalf("media paid_message_stars = %d/%v, want 0/false", paid, ok)
|
||||
}
|
||||
topSuggested, ok := top.GetSuggestedPost()
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// channelPaidReactionService 是 r.deps.Channels 的可选扩展(app/channels.Service 实现),
|
||||
// 用类型断言接入,避免在 ChannelsService 接口上为付费 reaction 单加方法。
|
||||
type channelPaidReactionService interface {
|
||||
SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error)
|
||||
}
|
||||
|
||||
// channelPaidReactionUpdates 为某 viewer 构造一条 updateMessageReactions:把消息已有的普通
|
||||
// reaction 与付费 ReactionPaid(总星数 + top reactors)合并。非请求者走 min 语义(置 Min,
|
||||
// 客户端忽略 chosen 保留本地态),避免把请求者视角串给他人。
|
||||
func (r *Router) channelPaidReactionUpdates(ctx context.Context, requestUserID, viewerUserID int64, res domain.ChannelMessagePaidReactionResult, ids []int) *tg.Updates {
|
||||
isRequester := viewerUserID == requestUserID
|
||||
base := domain.ChannelMessageReactions{}
|
||||
if res.Message.Reactions != nil {
|
||||
base = *res.Message.Reactions
|
||||
}
|
||||
paid := clonePaidForViewer(res.Paid, isRequester)
|
||||
base.Paid = &paid
|
||||
mr := tgMessageReactions(viewerUserID, &base)
|
||||
if mr == nil {
|
||||
mr = &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
}
|
||||
if !isRequester {
|
||||
mr.Min = true
|
||||
}
|
||||
msgID := res.Message.ID
|
||||
if msgID == 0 && len(ids) > 0 {
|
||||
msgID = ids[0]
|
||||
}
|
||||
update := &tg.UpdateMessageReactions{
|
||||
Peer: &tg.PeerChannel{ChannelID: res.Channel.ID},
|
||||
MsgID: msgID,
|
||||
Reactions: *mr,
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update},
|
||||
Users: r.tgUsersForIDs(ctx, viewerUserID, paidReactorUserIDs(res.Paid)),
|
||||
Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// clonePaidForViewer 返回付费聚合的副本;非请求者抹除 My/MyStars(min 语义防串视角)。
|
||||
func clonePaidForViewer(paid domain.ChannelMessagePaidReactions, isRequester bool) domain.ChannelMessagePaidReactions {
|
||||
out := paid
|
||||
out.TopReactors = make([]domain.PaidReactor, len(paid.TopReactors))
|
||||
copy(out.TopReactors, paid.TopReactors)
|
||||
if !isRequester {
|
||||
out.MyStars = 0
|
||||
out.MyAnonymous = false
|
||||
for i := range out.TopReactors {
|
||||
out.TopReactors[i].My = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectPaidReaction 把付费 reaction 注入 MessageReactions:ReactionPaid 计数置首位、填充
|
||||
// top reactors 排行。My/chosen 完全由 paid 的视角数据驱动(调用方对他人视角已抹除 My/MyStars)。
|
||||
func injectPaidReaction(mr *tg.MessageReactions, paid domain.ChannelMessagePaidReactions) {
|
||||
if paid.TotalStars <= 0 {
|
||||
return
|
||||
}
|
||||
paidCount := tg.ReactionCount{Reaction: &tg.ReactionPaid{}, Count: int(paid.TotalStars)}
|
||||
if paid.MyStars > 0 {
|
||||
// chosen_order 仅标记「本人已投」,具体序值不关键,置正。
|
||||
paidCount.SetChosenOrder(int(paid.MyStars))
|
||||
}
|
||||
mr.Results = append([]tg.ReactionCount{paidCount}, mr.Results...)
|
||||
|
||||
reactors := make([]tg.MessageReactor, 0, len(paid.TopReactors))
|
||||
for _, rr := range paid.TopReactors {
|
||||
item := tg.MessageReactor{Count: int(rr.Stars)}
|
||||
if rr.My {
|
||||
item.My = true
|
||||
} else {
|
||||
item.Top = true
|
||||
}
|
||||
if rr.Anonymous {
|
||||
item.Anonymous = true
|
||||
}
|
||||
// PeerID:非匿名给出;匿名仅本人给出(TL 语义:匿名本人 anonymous+peer 都置)。
|
||||
if rr.UserID != 0 && (!rr.Anonymous || rr.My) {
|
||||
item.SetPeerID(&tg.PeerUser{UserID: rr.UserID})
|
||||
}
|
||||
reactors = append(reactors, item)
|
||||
}
|
||||
if len(reactors) > 0 {
|
||||
mr.SetTopReactors(reactors)
|
||||
}
|
||||
}
|
||||
|
||||
// paidReactorUserIDs 收集非匿名 reactor 的用户 id,供 Updates.Users 富化头像。
|
||||
func paidReactorUserIDs(paid domain.ChannelMessagePaidReactions) []int64 {
|
||||
ids := make([]int64, 0, len(paid.TopReactors))
|
||||
for _, rr := range paid.TopReactors {
|
||||
if rr.UserID != 0 && !rr.Anonymous {
|
||||
ids = append(ids, rr.UserID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// injectPaidReaction 请求者视角:ReactionPaid 置首、计数=总星、chosen 置位;
|
||||
// TopReactors 含本人带 My + PeerID。
|
||||
func TestInjectPaidReactionRequesterView(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 150,
|
||||
MyStars: 50,
|
||||
TopReactors: []domain.PaidReactor{
|
||||
{UserID: 2, Stars: 100, My: false},
|
||||
{UserID: 1, Stars: 50, My: true},
|
||||
},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, true))
|
||||
|
||||
if len(mr.Results) != 1 {
|
||||
t.Fatalf("results = %d, want 1 paid", len(mr.Results))
|
||||
}
|
||||
if _, ok := mr.Results[0].Reaction.(*tg.ReactionPaid); !ok {
|
||||
t.Fatalf("results[0] reaction = %T, want *tg.ReactionPaid", mr.Results[0].Reaction)
|
||||
}
|
||||
if mr.Results[0].Count != 150 {
|
||||
t.Fatalf("paid count = %d, want 150", mr.Results[0].Count)
|
||||
}
|
||||
if order, ok := mr.Results[0].GetChosenOrder(); !ok || order <= 0 {
|
||||
t.Fatalf("requester chosen = %d ok %v, want set+positive (MyStars>0)", order, ok)
|
||||
}
|
||||
reactors, ok := mr.GetTopReactors()
|
||||
if !ok || len(reactors) != 2 {
|
||||
t.Fatalf("top reactors = %d ok %v, want 2", len(reactors), ok)
|
||||
}
|
||||
// 第二条是本人。
|
||||
me := reactors[1]
|
||||
if !me.My || me.Count != 50 {
|
||||
t.Fatalf("my reactor = %+v, want My count 50", me)
|
||||
}
|
||||
if peer, ok := me.GetPeerID(); !ok {
|
||||
t.Fatalf("my reactor peer = %v, want set", peer)
|
||||
}
|
||||
// 第一条是 top(非本人)。
|
||||
if !reactors[0].Top || reactors[0].My {
|
||||
t.Fatalf("top reactor = %+v, want Top not My", reactors[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 非请求者视角:无 chosen、My 一律抹去(min 语义防串视角)。
|
||||
func TestInjectPaidReactionOtherViewerScrubsMy(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 150,
|
||||
MyStars: 50,
|
||||
TopReactors: []domain.PaidReactor{
|
||||
{UserID: 2, Stars: 100, My: false},
|
||||
{UserID: 1, Stars: 50, My: true},
|
||||
},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, false))
|
||||
|
||||
if _, ok := mr.Results[0].GetChosenOrder(); ok {
|
||||
t.Fatalf("non-requester chosen must be unset")
|
||||
}
|
||||
reactors, _ := mr.GetTopReactors()
|
||||
for i, rr := range reactors {
|
||||
if rr.My {
|
||||
t.Fatalf("reactor[%d] My must be scrubbed for non-requester: %+v", i, rr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 匿名 reactor(非本人):Anonymous 置位、不暴露 PeerID。
|
||||
func TestInjectPaidReactionAnonymousHidesPeer(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 100,
|
||||
TopReactors: []domain.PaidReactor{{UserID: 9, Stars: 100, Anonymous: true, My: false}},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, true))
|
||||
reactors, ok := mr.GetTopReactors()
|
||||
if !ok || len(reactors) != 1 {
|
||||
t.Fatalf("reactors = %d ok %v, want 1", len(reactors), ok)
|
||||
}
|
||||
if !reactors[0].Anonymous {
|
||||
t.Fatalf("reactor must be Anonymous: %+v", reactors[0])
|
||||
}
|
||||
if peer, ok := reactors[0].GetPeerID(); ok {
|
||||
t.Fatalf("anonymous non-self reactor must not expose peer, got %v", peer)
|
||||
}
|
||||
}
|
||||
|
||||
// 空付费态:不注入任何 ReactionPaid。
|
||||
func TestInjectPaidReactionEmpty(t *testing.T) {
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, domain.ChannelMessagePaidReactions{})
|
||||
if len(mr.Results) != 0 {
|
||||
t.Fatalf("empty paid must inject nothing, got %d results", len(mr.Results))
|
||||
}
|
||||
if _, ok := mr.GetTopReactors(); ok {
|
||||
t.Fatalf("empty paid must not set top reactors")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -60,179 +59,3 @@ func (r *Router) validateDefaultReaction(ctx context.Context, reaction domain.Me
|
|||
}
|
||||
return reactionInvalidErr()
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
settings := domain.DefaultAccountReactionSettings()
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
next, err := svc.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
settings = next
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{
|
||||
Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy),
|
||||
}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) {
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return false, messageIDInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
return false, err
|
||||
}
|
||||
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
privacy = next.PaidPrivacy
|
||||
}
|
||||
r.pushUserUpdates(ctx, userID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// onMessagesSendPaidReaction 为一条广播频道消息发送付费 reaction:从 Stars 账本 Debit
|
||||
// req.Count 星,累计到消息上,返回带 updateMessageReactions(含 ReactionPaid 总星数 +
|
||||
// top reactors)与 updateStarsBalance 的 Updates。崩溃约束:必须返回合法 Updates——
|
||||
// DrKLO StarsController 对响应无 instanceof 强转 (TLRPC.Updates)。
|
||||
func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) {
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if req.Count <= 0 || req.Count > domain.MaxPaidReactionStarsPerRequest {
|
||||
return nil, starsAmountInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 付费 reaction 仅用于(广播)频道帖子。
|
||||
if peer.Type != domain.PeerTypeChannel {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
anonymous, err := r.resolvePaidReactionAnonymous(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return nil, balanceTooLowErr()
|
||||
}
|
||||
paidSvc, ok := r.deps.Channels.(channelPaidReactionService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: peer.ID}
|
||||
// 1. 先从账本 Debit(余额不足→真实 BALANCE_TOO_LOW)。
|
||||
balance, err := r.deps.Stars.Debit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction", "")
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
// 2. 累计付费 reaction;失败则补偿退款。
|
||||
res, err := paidSvc.SendPaidReaction(ctx, userID, domain.SendChannelPaidReactionRequest{
|
||||
UserID: userID,
|
||||
ChannelID: peer.ID,
|
||||
MessageID: req.MsgID,
|
||||
Stars: int64(req.Count),
|
||||
Anonymous: anonymous,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
if _, refundErr := r.deps.Stars.Credit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction refund", ""); refundErr != nil {
|
||||
r.log.Error("paid reaction refund failed after record error",
|
||||
zap.Int64("user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("msg_id", req.MsgID), zap.Error(refundErr))
|
||||
}
|
||||
return nil, channelReactionErr(err)
|
||||
}
|
||||
// 3. 构建并扇出 updateMessageReactions;请求者额外带 updateStarsBalance。
|
||||
ids := []int{res.Message.ID}
|
||||
build := func(viewerUserID int64) *tg.Updates {
|
||||
updates := r.channelPaidReactionUpdates(ctx, userID, viewerUserID, res, ids)
|
||||
if updates != nil && viewerUserID == userID {
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
recipients := append([]int64{res.Message.SenderUserID}, res.Recipients...)
|
||||
r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, recipients, build)
|
||||
return build(userID), nil
|
||||
}
|
||||
|
||||
// resolvePaidReactionAnonymous 计算本次付费 reaction 是否匿名:显式 private 优先,
|
||||
// 缺省回退用户保存的默认付费 reaction 隐私。
|
||||
func (r *Router) resolvePaidReactionAnonymous(ctx context.Context, userID int64, req *tg.MessagesSendPaidReactionRequest) (bool, error) {
|
||||
if private, ok := req.GetPrivate(); ok {
|
||||
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, private)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return privacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
|
||||
}
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
if settings, err := svc.GetReactionSettings(ctx, userID); err == nil {
|
||||
return settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) {
|
||||
switch typed := in.(type) {
|
||||
case nil, *tg.PaidReactionPrivacyDefault:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil
|
||||
case *tg.PaidReactionPrivacyAnonymous:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil
|
||||
case *tg.PaidReactionPrivacyPeer:
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer)
|
||||
if err != nil {
|
||||
return domain.PaidReactionPrivacy{}, err
|
||||
}
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil
|
||||
default:
|
||||
return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass {
|
||||
switch in.Kind {
|
||||
case domain.PaidReactionPrivacyAnonymous:
|
||||
return &tg.PaidReactionPrivacyAnonymous{}
|
||||
case domain.PaidReactionPrivacyPeer:
|
||||
if in.Peer == nil {
|
||||
return &tg.PaidReactionPrivacyDefault{}
|
||||
}
|
||||
if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil {
|
||||
return &tg.PaidReactionPrivacyPeer{Peer: peer}
|
||||
}
|
||||
}
|
||||
return &tg.PaidReactionPrivacyDefault{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -903,15 +903,6 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
return r.onMessagesSetDefaultReaction(ctx, layerRequest.
|
||||
Reaction)
|
||||
})
|
||||
registerRPC[*tg.MessagesGetPaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesGetPaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesGetPaidReactionPrivacyRequest) (any, error) {
|
||||
return r.onMessagesGetPaidReactionPrivacy(ctx)
|
||||
})
|
||||
registerRPC[*tg.MessagesTogglePaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesTogglePaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesTogglePaidReactionPrivacyRequest) (any, error) {
|
||||
return r.onMessagesTogglePaidReactionPrivacy(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesSendPaidReactionRequest](d, tlprofile.SemanticMethodMessagesSendPaidReaction, func(ctx context.Context, layerRequest *tg.MessagesSendPaidReactionRequest) (any, error) {
|
||||
return r.onMessagesSendPaidReaction(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesDeleteParticipantReactionsRequest](d, tlprofile.SemanticMethodMessagesDeleteParticipantReactions, func(ctx context.Context, layerRequest *tg.MessagesDeleteParticipantReactionsRequest) (any, error) {
|
||||
return r.onMessagesDeleteParticipantReactions(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -258,12 +258,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
}
|
||||
|
||||
func messageSendErr(err error) error {
|
||||
var paymentRequired *domain.StarsPaymentRequiredError
|
||||
switch {
|
||||
case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0:
|
||||
return allowPaymentRequiredErr(paymentRequired.Stars)
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return balanceTooLowErr()
|
||||
case errors.Is(err, domain.ErrUserFrozen):
|
||||
return frozenMethodInvalidErr()
|
||||
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
|
||||
|
|
@ -507,9 +502,6 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
|
|||
Pts: event.Pts,
|
||||
PtsCount: event.PtsCount,
|
||||
})
|
||||
if balance := tgGiftStarsBalanceUpdate(msg); balance != nil {
|
||||
updates = append(updates, balance)
|
||||
}
|
||||
date := event.Date
|
||||
if date == 0 {
|
||||
date = msg.Date
|
||||
|
|
@ -523,18 +515,6 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
|
|||
}
|
||||
}
|
||||
|
||||
func tgGiftStarsBalanceUpdate(msg domain.Message) tg.UpdateClass {
|
||||
if msg.Out || msg.Media == nil || msg.Media.ServiceAction == nil ||
|
||||
msg.Media.ServiceAction.Kind != domain.MessageServiceActionGiftStars {
|
||||
return nil
|
||||
}
|
||||
action := msg.Media.ServiceAction.GiftStars
|
||||
if action == nil || action.BalanceAfter < 0 {
|
||||
return nil
|
||||
}
|
||||
return &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: action.BalanceAfter}}
|
||||
}
|
||||
|
||||
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
|
||||
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
|
||||
// transition its local pending message to SENT. Visible edited messages use the
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID
|
|||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
|
||||
}
|
||||
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
|
||||
if result.Parent.ID != 0 {
|
||||
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
|
||||
|
|
|
|||
|
|
@ -196,35 +196,6 @@ func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPaymentsGetStarGiftCollectionsNoServiceFallbackAndValidatesPeer(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
var okReq bin.Buffer
|
||||
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}).Encode(&okReq); err != nil {
|
||||
t.Fatalf("encode request: %v", err)
|
||||
}
|
||||
got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
collections, ok := got.(*tg.PaymentsStarGiftCollections)
|
||||
if !ok {
|
||||
t.Fatalf("response type = %T, want *tg.PaymentsStarGiftCollections", got)
|
||||
}
|
||||
if len(collections.Collections) != 0 {
|
||||
t.Fatalf("collections = %+v, want empty list", collections.Collections)
|
||||
}
|
||||
|
||||
var badReq bin.Buffer
|
||||
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil {
|
||||
t.Fatalf("encode bad request: %v", err)
|
||||
}
|
||||
if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") {
|
||||
t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
|
||||
// gift/auction/revenue 第一阶段兼容桩。
|
||||
// registerPayments 注册 payments.* RPC。telesrv 不实现 Stars/Star Gift 经济:
|
||||
// 大部分曾经的 Stars/gift 动作 RPC 已不再注册;仍注册的几个只读状态 RPC
|
||||
// (getStarsStatus/Subscriptions/Transactions/RevenueStats)返回固定空值,
|
||||
// 因为部分客户端界面会无条件加载它们——错误返回会导致界面卡死或崩溃,
|
||||
// 空值则让界面正常渲染成"没有 Stars"。
|
||||
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) {
|
||||
return r.onPaymentsCanPurchaseStore(ctx, req)
|
||||
|
|
@ -22,24 +22,6 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsAssignPlayMarketTransactionRequest](d, tlprofile.SemanticMethodPaymentsAssignPlayMarketTransaction, func(ctx context.Context, req *tg.PaymentsAssignPlayMarketTransactionRequest) (any, error) {
|
||||
return r.onPaymentsAssignPlayMarketTransaction(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiftOptions(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiveawayOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiveawayOptions, func(ctx context.Context, _ *tg.PaymentsGetStarsGiveawayOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiveawayOptions(ctx)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetGiveawayInfoRequest](d, tlprofile.SemanticMethodPaymentsGetGiveawayInfo, func(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (any, error) {
|
||||
return r.onPaymentsGetGiveawayInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
|
||||
|
||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
||||
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
|
||||
// premiumGiftsReady=true,否则整框不弹出——此前返 NOT_IMPLEMENTED 导致点生日礼物无反应。
|
||||
// 空列表即解门,星礼物正常发送;premium 区段另由 userFull.disallow_premium_gifts=true 隐藏。
|
||||
error) {
|
||||
return devStarsTopupOptions(), nil
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetPremiumGiftCodeOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetPremiumGiftCodeOptions, func(ctx context.Context, req *tg.PaymentsGetPremiumGiftCodeOptionsRequest) (any, error) {
|
||||
return []tg.PremiumGiftCodeOption{}, nil
|
||||
})
|
||||
|
|
@ -52,110 +34,6 @@ 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) {
|
||||
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.
|
||||
Hash)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftUpgradePreviewRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradePreview, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradePreviewRequest) (any, error) {
|
||||
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)
|
||||
})
|
||||
registerRPC[*tg.PaymentsValidateRequestedInfoRequest](d, tlprofile.SemanticMethodPaymentsValidateRequestedInfo, func(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (any, error) {
|
||||
return r.onPaymentsValidateRequestedInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
|
||||
return r.onPaymentsSendStarsForm(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsSendPaymentForm, func(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (any, error) {
|
||||
return r.onPaymentsSendPaymentForm(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetSavedStarGifts(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetSavedStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsGetSavedStarGift(ctx, layerRequest.
|
||||
Stargift)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSaveStarGiftRequest](d, tlprofile.SemanticMethodPaymentsSaveStarGift, func(ctx context.Context, layerRequest *tg.PaymentsSaveStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsSaveStarGift(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsConvertStarGiftRequest](d, tlprofile.SemanticMethodPaymentsConvertStarGift, func(ctx context.Context, layerRequest *tg.PaymentsConvertStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsConvertStarGift(ctx, layerRequest.
|
||||
Stargift)
|
||||
})
|
||||
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)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCreateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsCreateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsCreateStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsCreateStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsUpdateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsUpdateStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsUpdateStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsDeleteStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsDeleteStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsDeleteStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsDeleteStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsReorderStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsReorderStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsReorderStarGiftCollectionsRequest) (any, error) {
|
||||
return r.onPaymentsReorderStarGiftCollections(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsToggleStarGiftsPinnedToTopRequest](d, tlprofile.SemanticMethodPaymentsToggleStarGiftsPinnedToTop, func(ctx context.Context, layerRequest *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (any, error) {
|
||||
return r.onPaymentsToggleStarGiftsPinnedToTop(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsRevenueAdsAccountURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueAdsAccountURL, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsRevenueAdsAccountURLRequest) (any, error) {
|
||||
peer := layerRequest.
|
||||
Peer
|
||||
|
|
@ -194,10 +72,9 @@ func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *t
|
|||
return nil, tgerr.New(400, "STORE_PAYMENT_UNAVAILABLE")
|
||||
}
|
||||
|
||||
// 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.
|
||||
// onPaymentsGetStarsRevenueStats: telesrv has no Stars/gift economy, so every
|
||||
// peer gets the same zero-balance compatibility response (no channel ledger
|
||||
// branch left to read from).
|
||||
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -206,319 +83,43 @@ func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.Pay
|
|||
if req == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 tdesktop.StarsRevenueStats(req.GetTon()), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsStatus 无 Stars 账本,恒返回零余额(响应仍是合法的
|
||||
// payments.starsStatus——两端客户端无条件读取 balance 字段)。
|
||||
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
stats := tdesktop.StarsRevenueStats(ton)
|
||||
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
|
||||
if ton {
|
||||
amount = &tg.StarsTonAmount{Amount: balance}
|
||||
if req != nil && req.GetTon() {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
// 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
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
type channelGiftLedgerReader interface {
|
||||
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
|
||||
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (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) {
|
||||
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
|
||||
}
|
||||
bal, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsSubscriptions returns the authoritative current balance
|
||||
// with an empty subscription page. telesrv does not create recurring Stars
|
||||
// subscriptions yet; returning a well-shaped terminal page lets both official
|
||||
// clients finish loading the Stars screen without inventing subscription state.
|
||||
// onPaymentsGetStarsSubscriptions returns a zero balance and no subscriptions
|
||||
// — telesrv never creates recurring Stars subscriptions.
|
||||
func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
return nil, inputRequestInvalidErr()
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
userID, owner, err := r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner.Type != domain.PeerTypeUser || owner.ID != userID {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
balance, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{Amount: balance.Balance}), nil
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
|
||||
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
|
||||
// onPaymentsGetStarsTransactions 无 Stars 账本,恒返回零余额、空流水(不设置
|
||||
// next_offset,避免客户端无限翻页)。
|
||||
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
query, err := starsTransactionQuery(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if req != nil && req.GetTon() {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
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, query)
|
||||
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, query)
|
||||
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, query)
|
||||
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, query)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 富化流水中提到的用户对手方(频道对手方进 Chats 留待 paid reaction 阶段)。
|
||||
if ids := starsTransactionUserIDs(page.Transactions); len(ids) > 0 {
|
||||
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, ids))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func starsTransactionQuery(req *tg.PaymentsGetStarsTransactionsRequest) (domain.StarsTransactionQuery, error) {
|
||||
if req == nil {
|
||||
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
|
||||
}
|
||||
inbound, outbound := req.GetInbound(), req.GetOutbound()
|
||||
if inbound && outbound {
|
||||
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
|
||||
}
|
||||
if _, ok := req.GetSubscriptionID(); ok {
|
||||
// Stars subscriptions are not part of the current business model. Do not
|
||||
// silently return the unfiltered ledger for a requested subscription.
|
||||
return domain.StarsTransactionQuery{}, subscriptionIDInvalidErr()
|
||||
}
|
||||
direction := domain.StarsTransactionDirectionAll
|
||||
if inbound {
|
||||
direction = domain.StarsTransactionDirectionIncoming
|
||||
} else if outbound {
|
||||
direction = domain.StarsTransactionDirectionOutgoing
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
return domain.StarsTransactionQuery{
|
||||
Offset: req.Offset,
|
||||
Limit: limit,
|
||||
Direction: direction,
|
||||
Ascending: req.GetAscending(),
|
||||
}, 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))
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。
|
||||
|
|
@ -529,119 +130,3 @@ func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
|
|||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
// tgStarsTransactions 把账本流水投影为 tg.StarsTransaction(amount 带符号:借记为负)。
|
||||
func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
|
||||
out := make([]tg.StarsTransaction, 0, len(in))
|
||||
for _, t := range in {
|
||||
item := tg.StarsTransaction{
|
||||
ID: strconv.FormatInt(t.ID, 10),
|
||||
Amount: &tg.StarsAmount{Amount: t.Amount},
|
||||
Date: t.Date,
|
||||
Peer: tgStarsTransactionPeer(t),
|
||||
}
|
||||
if t.Title != "" {
|
||||
item.SetTitle(t.Title)
|
||||
}
|
||||
if t.Description != "" {
|
||||
item.SetDescription(t.Description)
|
||||
}
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonReaction:
|
||||
item.Reaction = true
|
||||
case domain.StarsReasonPaidMessage:
|
||||
item.SetPaidMessages(1)
|
||||
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)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tgStarsTransactionPeer 选择对手方构造器:grant/topup 走 Fragment(站外充值轨),
|
||||
// 真实 peer 走 starsTransactionPeer,其余兜底 Unsupported(Peer 字段必填,不可为 nil)。
|
||||
func tgStarsTransactionPeer(t domain.StarsTransaction) tg.StarsTransactionPeerClass {
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonGrant, domain.StarsReasonTopup:
|
||||
return &tg.StarsTransactionPeerFragment{}
|
||||
}
|
||||
if t.Peer.Type != "" && t.Peer.ID != 0 {
|
||||
if p := tgPeer(t.Peer); p != nil {
|
||||
return &tg.StarsTransactionPeer{Peer: p}
|
||||
}
|
||||
}
|
||||
return &tg.StarsTransactionPeerUnsupported{}
|
||||
}
|
||||
|
||||
// starsTransactionUserIDs 收集流水中去重的用户类对手方 id。
|
||||
func starsTransactionUserIDs(in []domain.StarsTransaction) []int64 {
|
||||
seen := make(map[int64]struct{}, len(in))
|
||||
ids := make([]int64, 0, len(in))
|
||||
for _, t := range in {
|
||||
if t.Peer.Type != domain.PeerTypeUser || t.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[t.Peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[t.Peer.ID] = struct{}{}
|
||||
ids = append(ids, t.Peer.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// starsErr 把 Stars 账本领域错误映射为客户端可识别的 tgerr(仿 premiumBoostErr)。
|
||||
func starsErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return balanceTooLowErr()
|
||||
case errors.Is(err, domain.ErrStarsInvalidAmount):
|
||||
return starsAmountInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftCollections(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return &tg.PaymentsStarGiftCollections{Collections: []tg.StarGiftCollection{}}, nil
|
||||
}
|
||||
collections, err := r.deps.Gifts.ListCollections(ctx, owner)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
if req.Hash != 0 && req.Hash == domain.StarGiftCollectionsHash(collections) {
|
||||
return &tg.PaymentsStarGiftCollectionsNotModified{}, nil
|
||||
}
|
||||
return &tg.PaymentsStarGiftCollections{Collections: tgStarGiftCollections(collections)}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsCreateStarGiftCollection(ctx context.Context, req *tg.PaymentsCreateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collection, err := r.deps.Gifts.CreateCollection(ctx, owner, strings.TrimSpace(req.Title), ids)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
out := tgStarGiftCollection(collection)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsUpdateStarGiftCollection(ctx context.Context, req *tg.PaymentsUpdateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
|
||||
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := domain.StarGiftCollectionPatch{}
|
||||
if title, ok := req.GetTitle(); ok {
|
||||
title = strings.TrimSpace(title)
|
||||
patch.Title = &title
|
||||
}
|
||||
if refs, ok := req.GetDeleteStargift(); ok {
|
||||
patch.DeleteIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if refs, ok := req.GetAddStargift(); ok {
|
||||
patch.AddIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if refs, ok := req.GetOrder(); ok {
|
||||
patch.Order, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
collection, err := r.deps.Gifts.UpdateCollection(ctx, owner, req.CollectionID, patch)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
out := tgStarGiftCollection(collection)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsDeleteStarGiftCollection(ctx context.Context, req *tg.PaymentsDeleteStarGiftCollectionRequest) (bool, error) {
|
||||
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
deleted, err := r.deps.Gifts.DeleteCollection(ctx, owner, req.CollectionID)
|
||||
if err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
if !deleted {
|
||||
return false, starGiftCollectionErr(domain.ErrStarGiftCollectionNotFound)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsReorderStarGiftCollections(ctx context.Context, req *tg.PaymentsReorderStarGiftCollectionsRequest) (bool, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.deps.Gifts.ReorderCollections(ctx, owner, req.Order); err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsToggleStarGiftsPinnedToTop(ctx context.Context, req *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (bool, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.deps.Gifts.SetPinned(ctx, owner, ids); err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) resolveStarGiftCollectionRefs(ctx context.Context, userID int64, owner domain.Peer, refs []tg.InputSavedStarGiftClass) ([]int64, error) {
|
||||
if len(refs) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
domainRefs := make([]domain.SavedStarGiftRef, 0, len(refs))
|
||||
for _, input := range refs {
|
||||
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || ref.Owner != owner {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
domainRefs = append(domainRefs, ref)
|
||||
}
|
||||
ids, err := r.deps.Gifts.ResolveSavedIDs(ctx, owner, domainRefs)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func tgStarGiftCollections(in []domain.StarGiftCollection) []tg.StarGiftCollection {
|
||||
out := make([]tg.StarGiftCollection, 0, len(in))
|
||||
for _, collection := range in {
|
||||
out = append(out, tgStarGiftCollection(collection))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftCollection(in domain.StarGiftCollection) tg.StarGiftCollection {
|
||||
return tg.StarGiftCollection{
|
||||
CollectionID: in.CollectionID,
|
||||
Title: in.Title,
|
||||
GiftsCount: len(in.GiftIDs),
|
||||
Hash: in.Hash,
|
||||
}
|
||||
}
|
||||
|
||||
func starGiftCollectionErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCollectionNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftCollectionsFull):
|
||||
return inputRequestInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,454 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentFormClass, error) {
|
||||
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tg.PaymentsPaymentFormStarGift{
|
||||
FormID: starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails),
|
||||
Invoice: tg.Invoice{
|
||||
Currency: "XTR",
|
||||
Prices: []tg.LabeledPrice{{Label: "Star gift upgrade", Amount: preview.UpgradeStars}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
|
||||
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
|
||||
}
|
||||
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
|
||||
if formID == 0 || formID != wantFormID {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
chargeStars = preview.UpgradeStars
|
||||
}
|
||||
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
|
||||
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),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starGiftUpgradeErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(saved.Owner)
|
||||
updates := r.tgStarGiftUpgradeUpdates(ctx, userID, result, true)
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.PaymentsUpgradeStarGiftRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
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: starGiftUpgradeSavedRef(saved),
|
||||
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
|
||||
CommandKey: commandKey,
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starGiftUpgradeErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(saved.Owner)
|
||||
return r.tgStarGiftUpgradeUpdates(ctx, userID, result, false), nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
|
||||
}
|
||||
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{}, internalErr()
|
||||
}
|
||||
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.StarGiftUpgradePreview{}, internalErr()
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
|
||||
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
}
|
||||
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 {
|
||||
message, event := result.Send.RecipientMessage, result.Send.RecipientEvent
|
||||
if result.Send.SenderMessage.OwnerUserID == ownerUserID {
|
||||
message, event = result.Send.SenderMessage, result.Send.SenderEvent
|
||||
}
|
||||
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}})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
func starGiftUpgradeFormID(userID, savedGiftID, stars int64, keepOriginal bool) int64 {
|
||||
id := userID*0x9e3779b1 ^ savedGiftID<<11 ^ stars<<19 ^ 0x55504752414445
|
||||
if keepOriginal {
|
||||
id ^= 0x4b454550
|
||||
}
|
||||
if id < 0 {
|
||||
id = ^id
|
||||
}
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func starGiftUpgradeErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return starsErr(err)
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleSoldOut),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleInvalid):
|
||||
return starGiftInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func sessionIDOrZero(ctx context.Context) int64 {
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradePreview, error) {
|
||||
if giftID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || preview.Issued >= preview.SupplyTotal {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
return &tg.PaymentsStarGiftUpgradePreview{
|
||||
SampleAttributes: tgStarGiftPreviewAttributes(preview),
|
||||
Prices: []tg.StarGiftUpgradePrice{},
|
||||
NextPrices: []tg.StarGiftUpgradePrice{},
|
||||
}, 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()
|
||||
}
|
||||
viewerUserID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
out := &tg.PaymentsUniqueStarGift{
|
||||
Gift: tgUniqueStarGift(unique),
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
switch unique.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
ids := []int64{unique.Owner.ID}
|
||||
if unique.KeepOriginalDetails && !unique.OriginalNameHidden && unique.OriginalFromUserID != 0 && unique.OriginalFromUserID != unique.Owner.ID {
|
||||
ids = append(ids, unique.OriginalFromUserID)
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids))
|
||||
case domain.PeerTypeChannel:
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerUserID, []int64{unique.Owner.ID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
for _, attribute := range preview.Backdrops {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
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 := 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, Crafted: attribute.Crafted}
|
||||
case domain.StarGiftCollectiblePattern:
|
||||
document := tg.DocumentClass(&tg.DocumentEmpty{})
|
||||
if attribute.Document != nil {
|
||||
document = tgDocument(*attribute.Document)
|
||||
}
|
||||
return &tg.StarGiftAttributePattern{Name: attribute.Name, Document: document, Rarity: rarity}
|
||||
case domain.StarGiftCollectibleBackdrop:
|
||||
return &tg.StarGiftAttributeBackdrop{
|
||||
Name: attribute.Name, BackdropID: attribute.BackdropID,
|
||||
CenterColor: attribute.CenterColor, EdgeColor: attribute.EdgeColor,
|
||||
PatternColor: attribute.PatternColor, TextColor: attribute.TextColor, Rarity: rarity,
|
||||
}
|
||||
default:
|
||||
return &tg.StarGiftAttributeBackdrop{Name: attribute.Name, Rarity: rarity}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
tgStarGiftAttribute(unique.Pattern),
|
||||
tgStarGiftAttribute(unique.Backdrop),
|
||||
}
|
||||
if unique.KeepOriginalDetails && unique.OriginalOwner.ID != 0 {
|
||||
original := &tg.StarGiftAttributeOriginalDetails{
|
||||
RecipientID: tgPeer(unique.OriginalOwner),
|
||||
Date: unique.OriginalDate,
|
||||
}
|
||||
if unique.OriginalFromUserID != 0 && !unique.OriginalNameHidden {
|
||||
original.SetSenderID(&tg.PeerUser{UserID: unique.OriginalFromUserID})
|
||||
}
|
||||
if unique.OriginalMessage != "" {
|
||||
original.SetMessage(tg.TextWithEntities{Text: unique.OriginalMessage})
|
||||
}
|
||||
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 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,102 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type adminUniqueStarGiftGranter interface {
|
||||
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
|
||||
}
|
||||
|
||||
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
|
||||
// grant.SenderID without charging any Stars. It powers the admin console "Give
|
||||
// gift" action: the gift is loaded from the catalog and delivered through the
|
||||
// exact same path a paid send uses (messageActionStarGift service message for
|
||||
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
|
||||
//
|
||||
// SenderID must be zero or the official system account (777000). When Upgrade
|
||||
// is true, the store assigns a genuine collectible directly in the same
|
||||
// transaction as its service message and durable updates. The optional
|
||||
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
|
||||
// collectible facts (0 => random; number is always sequential). Upgraded
|
||||
// delivery is supported for user recipients only.
|
||||
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
|
||||
senderID := grant.SenderID
|
||||
if senderID <= 0 {
|
||||
senderID = domain.OfficialSystemUserID
|
||||
}
|
||||
if senderID != domain.OfficialSystemUserID {
|
||||
return fmt.Errorf("gift sender must be the official system account")
|
||||
}
|
||||
if grant.GiftID <= 0 {
|
||||
return fmt.Errorf("gift_id is required")
|
||||
}
|
||||
if grant.Recipient.ID <= 0 {
|
||||
return fmt.Errorf("recipient is required")
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return fmt.Errorf("gifts dependency is not configured")
|
||||
}
|
||||
gift, ok, err := r.deps.Gifts.GiftByID(ctx, grant.GiftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("gift %d not found", grant.GiftID)
|
||||
}
|
||||
if grant.Upgrade {
|
||||
return r.adminGrantUpgradedStarGift(ctx, senderID, gift, grant)
|
||||
}
|
||||
switch grant.Recipient.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, _, err = r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, _, err = r.sendStarGiftToChannel(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported recipient peer type %q", grant.Recipient.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// adminGrantUpgradedStarGift assigns a collectible through the atomic store
|
||||
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
|
||||
// outbox event behind.
|
||||
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
|
||||
if grant.Recipient.Type != domain.PeerTypeUser {
|
||||
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, gift.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 {
|
||||
return fmt.Errorf("gift %d has no published collectible upgrade", gift.ID)
|
||||
}
|
||||
if preview.Issued >= preview.SupplyTotal {
|
||||
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
|
||||
}
|
||||
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
|
||||
if !ok {
|
||||
return fmt.Errorf("atomic collectible grant is not configured")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grant.SenderID = senderID
|
||||
grant.Date = int(r.clock.Now().Unix())
|
||||
grant.RecipientBlocked = recipientBlocked
|
||||
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
||||
return err
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(grant.Recipient)
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,262 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appstars "telesrv/internal/app/stars"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type starsFriendGiftRPCStore struct {
|
||||
*memory.StarsStore
|
||||
issued domain.StarsPurchaseForm
|
||||
purchased domain.StarsPurchaseRequest
|
||||
purchases int
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
form.FormID = 70001
|
||||
s.issued = form
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) PurchaseStars(_ context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
s.purchased = req
|
||||
s.purchases++
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
Currency: req.Currency, Amount: req.Amount, Stars: req.Stars,
|
||||
TransactionID: "stars-gift-test", BalanceAfter: 4321,
|
||||
}}
|
||||
sender := domain.Message{ID: 11, OwnerUserID: req.BuyerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, Out: true, Date: req.Date,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
recipient := sender
|
||||
recipient.ID, recipient.OwnerUserID, recipient.Peer, recipient.Out = 12, req.RecipientUserID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, false
|
||||
return domain.StarsPurchaseResult{
|
||||
Balance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
|
||||
TransactionID: "stars-gift-test",
|
||||
Send: domain.SendPrivateTextResult{
|
||||
SenderMessage: sender, RecipientMessage: recipient,
|
||||
SenderEvent: domain.UpdateEvent{UserID: req.BuyerUserID, Type: domain.UpdateEventNewMessage, Pts: 5, PtsCount: 1, Date: req.Date, Message: sender},
|
||||
RecipientEvent: domain.UpdateEvent{UserID: req.RecipientUserID, Type: domain.UpdateEventNewMessage, Pts: 9, PtsCount: 1, Date: req.Date, Message: recipient},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
buyer, err := users.Create(ctx, domain.User{AccessHash: 8101, Phone: "+15558101", FirstName: "Buyer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipient, err := users.Create(ctx, domain.User{AccessHash: 8102, Phone: "+15558102", FirstName: "Recipient"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()}
|
||||
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, st, buyer, recipient
|
||||
}
|
||||
|
||||
func TestStarsFriendGiftOptionsFormAndFiatSettlement(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
generic, err := r.onPaymentsGetStarsGiftOptions(ctx, &tg.PaymentsGetStarsGiftOptionsRequest{})
|
||||
if err != nil || len(generic) != 3 || generic[0].Stars != 1000 || generic[0].Currency != "USD" || generic[0].Amount != 99 {
|
||||
t.Fatalf("generic gift options = %+v err=%v", generic, err)
|
||||
}
|
||||
input := &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}
|
||||
personalReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
|
||||
personalReq.SetUserID(input)
|
||||
personal, err := r.onPaymentsGetStarsGiftOptions(ctx, personalReq)
|
||||
if err != nil || len(personal) != len(generic) {
|
||||
t.Fatalf("personal gift options = %+v err=%v", personal, err)
|
||||
}
|
||||
|
||||
purpose := &tg.InputStorePaymentStarsGift{UserID: input, Stars: 2500, Currency: "USD", Amount: 199}
|
||||
invoice := &tg.InputInvoiceStars{Purpose: purpose}
|
||||
formClass, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
|
||||
if err != nil {
|
||||
t.Fatalf("get gift payment form: %v", err)
|
||||
}
|
||||
form, ok := formClass.(*tg.PaymentsPaymentForm)
|
||||
if !ok || form.FormID != 70001 || !form.Invoice.Test || form.Invoice.Currency != "USD" ||
|
||||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 199 ||
|
||||
form.ProviderID != domain.OfficialSystemUserID || form.URL != "https://links.example.test/payments/dev-stars?form_id=70001" {
|
||||
t.Fatalf("gift payment form = %T %+v", formClass, formClass)
|
||||
}
|
||||
if st.issued.Kind != domain.StarsPurchaseGift || st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
|
||||
t.Fatalf("issued form = %+v", st.issued)
|
||||
}
|
||||
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("sendStarsForm fiat gift err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
|
||||
}
|
||||
resultClass, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sendPaymentForm gift: %v", err)
|
||||
}
|
||||
result, ok := resultClass.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
t.Fatalf("sendStarsForm result = %T", resultClass)
|
||||
}
|
||||
updates, ok := result.Updates.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("sender updates = %T %+v", result.Updates, result.Updates)
|
||||
}
|
||||
newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage)
|
||||
if !ok || newMessage.Pts != 5 || newMessage.PtsCount != 1 {
|
||||
t.Fatalf("sender new message = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
serviceMessage, ok := newMessage.Message.(*tg.MessageService)
|
||||
if !ok {
|
||||
t.Fatalf("gift message = %T", newMessage.Message)
|
||||
}
|
||||
action, ok := serviceMessage.Action.(*tg.MessageActionGiftStars)
|
||||
if !ok || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 || action.TransactionID != "" {
|
||||
t.Fatalf("sender gift action = %T %+v", serviceMessage.Action, serviceMessage.Action)
|
||||
}
|
||||
if st.purchased.FormID != form.FormID || st.purchased.RecipientUserID != recipient.ID || st.purchases != 1 {
|
||||
t.Fatalf("purchase request = %+v count=%d", st.purchased, st.purchases)
|
||||
}
|
||||
|
||||
if st.purchases != 1 {
|
||||
t.Fatalf("settlement count = %d, want one fiat submit", st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsDirectPurchaseValidateRequestedInfoIsReadOnly(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
topup := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}}
|
||||
gift := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 2500, Currency: "USD", Amount: 199,
|
||||
}}
|
||||
for name, invoice := range map[string]tg.InputInvoiceClass{"topup": topup, "gift": gift} {
|
||||
result, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Save: true, Invoice: invoice,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s validateRequestedInfo: %v", name, err)
|
||||
}
|
||||
if result == nil || !result.Zero() {
|
||||
t.Fatalf("%s validated info = %+v, want flags=0", name, result)
|
||||
}
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
|
||||
withInfo := &tg.PaymentsValidateRequestedInfoRequest{Invoice: topup}
|
||||
withInfo.Info.SetName("unexpected")
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_INVALID") {
|
||||
t.Fatalf("non-empty info err=%v, want REQUESTED_INFO_INVALID", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceSlug{Slug: "unsupported"},
|
||||
}); !tgerr.Is(err, "NOT_IMPLEMENTED") {
|
||||
t.Fatalf("non-Stars invoice err=%v, want NOT_IMPLEMENTED", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 100}},
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package err=%v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("invalid validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
badRecipientReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
|
||||
badRecipientReq.SetUserID(&tg.InputUser{UserID: recipient.ID + 99999})
|
||||
if _, err := r.onPaymentsGetStarsGiftOptions(ctx, badRecipientReq); !tgerr.Is(err, "USER_ID_INVALID") {
|
||||
t.Fatalf("bad recipient err = %v", err)
|
||||
}
|
||||
bad := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 2500, Currency: "USD", Amount: 200,
|
||||
}}
|
||||
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package form err = %v", err)
|
||||
}
|
||||
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: 70001, Invoice: bad, Credentials: devStarsCredentials(70001),
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package settle err = %v", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("invalid request reached store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidStorePurchaseFailsClosedInFavorOfInvoiceCheckout(t *testing.T) {
|
||||
r, _, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
purpose := &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}
|
||||
allowed, err := r.onPaymentsCanPurchaseStore(ctx, &tg.PaymentsCanPurchaseStoreRequest{Purpose: purpose})
|
||||
if err != nil {
|
||||
t.Fatalf("canPurchaseStore: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("canPurchaseStore = true, want false")
|
||||
}
|
||||
if _, err := r.onPaymentsAssignPlayMarketTransaction(ctx, &tg.PaymentsAssignPlayMarketTransactionRequest{
|
||||
Receipt: tg.DataJSON{Data: `{"orderId":"unverified"}`}, Purpose: purpose,
|
||||
}); !tgerr.Is(err, "STORE_PAYMENT_UNAVAILABLE") {
|
||||
t.Fatalf("assignPlayMarketTransaction err = %v, want STORE_PAYMENT_UNAVAILABLE", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftStarsRecipientProjectionCarriesBalanceOnlineAndDifference(t *testing.T) {
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
Currency: "USD", Amount: 99, Stars: 1000, TransactionID: "txn-1", BalanceAfter: 3100,
|
||||
}}
|
||||
msg := domain.Message{ID: 4, OwnerUserID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1700000000,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
event := domain.UpdateEvent{UserID: 2, Type: domain.UpdateEventNewMessage, Pts: 8, PtsCount: 1, Date: msg.Date, Message: msg}
|
||||
online := tgPrivateMessageUpdates(event, msg, 0, false, nil, nil)
|
||||
if len(online.Updates) != 2 {
|
||||
t.Fatalf("online updates = %+v", online.Updates)
|
||||
}
|
||||
balance, ok := online.Updates[1].(*tg.UpdateStarsBalance)
|
||||
if !ok || balance.Balance.(*tg.StarsAmount).Amount != 3100 {
|
||||
t.Fatalf("online balance = %T %+v", online.Updates[1], online.Updates[1])
|
||||
}
|
||||
diff := tgUpdatesDifference(2, domain.UpdateDifference{Events: []domain.UpdateEvent{event}, State: domain.UpdateState{Pts: 8}})
|
||||
full, ok := diff.(*tg.UpdatesDifference)
|
||||
if !ok || len(full.NewMessages) != 1 || len(full.OtherUpdates) != 1 {
|
||||
t.Fatalf("difference = %T %+v", diff, diff)
|
||||
}
|
||||
if _, ok := full.OtherUpdates[0].(*tg.UpdateStarsBalance); !ok {
|
||||
t.Fatalf("difference balance = %T", full.OtherUpdates[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appstars "telesrv/internal/app/stars"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func starsRouter(t *testing.T, grant int64) *Router {
|
||||
t.Helper()
|
||||
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(grant))
|
||||
return New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
|
||||
}
|
||||
|
||||
// getStarsStatus 首读惰性授予后返回真实余额;响应必须是合法 starsStatus
|
||||
// (balance 必填 + chats/users 非 nil vector)。
|
||||
func TestOnPaymentsGetStarsStatusGranted(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsStatus: %v", err)
|
||||
}
|
||||
amount, ok := status.Balance.(*tg.StarsAmount)
|
||||
if !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
|
||||
}
|
||||
if status.Chats == nil || status.Users == nil {
|
||||
t.Fatalf("chats/users must be non-nil vectors, got chats=%v users=%v", status.Chats, status.Users)
|
||||
}
|
||||
// 余额是 flag 外必填字段,不能省略。
|
||||
if _, hasHistory := status.GetHistory(); hasHistory {
|
||||
t.Fatalf("status (not transactions) should carry no history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsSubscriptionsReturnsTerminalEmptyPage(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
status, err := r.onPaymentsGetStarsSubscriptions(ctx, &tg.PaymentsGetStarsSubscriptionsRequest{
|
||||
Peer: &tg.InputPeerSelf{}, Offset: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsSubscriptions: %v", err)
|
||||
}
|
||||
amount, ok := status.Balance.(*tg.StarsAmount)
|
||||
if !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
|
||||
}
|
||||
if subscriptions, ok := status.GetSubscriptions(); ok || len(subscriptions) != 0 {
|
||||
t.Fatalf("subscriptions = %+v ok=%v, want absent terminal page", subscriptions, ok)
|
||||
}
|
||||
if _, ok := status.GetSubscriptionsNextOffset(); ok {
|
||||
t.Fatal("empty subscription page unexpectedly has next offset")
|
||||
}
|
||||
}
|
||||
|
||||
// TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。
|
||||
func TestOnPaymentsGetStarsStatusTon(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
// SetTon 同时置 flag 位+字段(gotd true-flag:GetTon 读 flag 位,手工 struct 字面量不置位)。
|
||||
req := &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}
|
||||
req.SetTon(true)
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsStatus ton: %v", err)
|
||||
}
|
||||
if _, ok := status.Balance.(*tg.StarsTonAmount); !ok {
|
||||
t.Fatalf("ton balance = %#v, want StarsTonAmount", status.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
// getStarsTransactions 返回授予流水;keyset 分页末页省略 next_offset(防 DrKLO 死循环)。
|
||||
func TestOnPaymentsGetStarsTransactions(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
status, err := r.onPaymentsGetStarsTransactions(ctx, &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsTransactions: %v", err)
|
||||
}
|
||||
history, ok := status.GetHistory()
|
||||
if !ok || len(history) != 1 {
|
||||
t.Fatalf("history = %d ok=%v, want 1 grant txn", len(history), ok)
|
||||
}
|
||||
txn := history[0]
|
||||
if amount, ok := txn.Amount.(*tg.StarsAmount); !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("grant txn amount = %#v, want +1000", txn.Amount)
|
||||
}
|
||||
// grant 走 Fragment 对手方(Peer 必填,不可 nil)。
|
||||
if _, ok := txn.Peer.(*tg.StarsTransactionPeerFragment); !ok {
|
||||
t.Fatalf("grant peer = %#v, want StarsTransactionPeerFragment", txn.Peer)
|
||||
}
|
||||
// 单页装得下 → 无 next_offset。
|
||||
if off, ok := status.GetNextOffset(); ok {
|
||||
t.Fatalf("single-page next_offset = %q, want absent (no infinite paging)", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsTransactionsDirections(t *testing.T) {
|
||||
const userID int64 = 1000000001
|
||||
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(0))
|
||||
ctx := WithUserID(context.Background(), userID)
|
||||
if _, err := svc.Credit(ctx, userID, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 100: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, userID, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 40: %v", err)
|
||||
}
|
||||
if _, err := svc.Credit(ctx, userID, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 20: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, userID, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 10: %v", err)
|
||||
}
|
||||
r := New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
all := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
assertRPCStarsAmounts(t, r, ctx, all, []int64{-10, 20, -40, 100})
|
||||
|
||||
incoming := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
incoming.SetInbound(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, incoming, []int64{20, 100})
|
||||
|
||||
outgoing := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
outgoing.SetOutbound(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, outgoing, []int64{-10, -40})
|
||||
|
||||
ascending := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
ascending.SetInbound(true)
|
||||
ascending.SetAscending(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, ascending, []int64{100, 20})
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsTransactionsRejectsInvalidFilters(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
both := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
|
||||
both.SetInbound(true)
|
||||
both.SetOutbound(true)
|
||||
if _, err := r.onPaymentsGetStarsTransactions(ctx, both); err == nil {
|
||||
t.Fatal("mutually exclusive inbound/outbound unexpectedly succeeded")
|
||||
}
|
||||
|
||||
subscription := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
|
||||
subscription.SetSubscriptionID("subscription-1")
|
||||
if _, err := r.onPaymentsGetStarsTransactions(ctx, subscription); err == nil {
|
||||
t.Fatal("unsupported subscription filter unexpectedly returned the unfiltered ledger")
|
||||
}
|
||||
}
|
||||
|
||||
func assertRPCStarsAmounts(t *testing.T, r *Router, ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest, want []int64) {
|
||||
t.Helper()
|
||||
status, err := r.onPaymentsGetStarsTransactions(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsTransactions: %v", err)
|
||||
}
|
||||
history, _ := status.GetHistory()
|
||||
if len(history) != len(want) {
|
||||
t.Fatalf("history count = %d, want %d: %+v", len(history), len(want), history)
|
||||
}
|
||||
for i, amount := range want {
|
||||
stars, ok := history[i].Amount.(*tg.StarsAmount)
|
||||
if !ok || stars.Amount != amount {
|
||||
t.Fatalf("history[%d].amount = %#v, want %d", i, history[i].Amount, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTGStarsTransactionsPaidMessage(t *testing.T) {
|
||||
out := tgStarsTransactions([]domain.StarsTransaction{{
|
||||
ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50},
|
||||
Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message",
|
||||
}})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("paid-message transactions = %d, want 1", len(out))
|
||||
}
|
||||
if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 {
|
||||
t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok)
|
||||
}
|
||||
if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 {
|
||||
t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount)
|
||||
}
|
||||
}
|
||||
|
||||
// deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。
|
||||
func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("nil-deps getStarsStatus: %v", err)
|
||||
}
|
||||
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 0 {
|
||||
t.Fatalf("nil-deps balance = %#v, want StarsAmount 0", status.Balance)
|
||||
}
|
||||
_ = 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, domain.StarsTransactionQuery) (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, domain.StarsTransactionQuery) (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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
|
||||
// ownership-write boundary. The rule does not reject the gift: it decides
|
||||
// whether an incoming user gift is displayed immediately (unsaved=false) or
|
||||
// waits for the recipient's approval (unsaved=true).
|
||||
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
|
||||
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
|
||||
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
|
||||
return false, nil
|
||||
}
|
||||
allowed, err := r.deps.Privacy.CanSee(
|
||||
ctx,
|
||||
recipient.ID,
|
||||
senderUserID,
|
||||
domain.PrivacyKeyStarGiftsAutoSave,
|
||||
)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return !allowed, nil
|
||||
}
|
||||
|
|
@ -1288,9 +1288,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
|
||||
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
|
||||
{name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}},
|
||||
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
|
||||
{name: "payments.getStarsGiftOptions", req: &tg.PaymentsGetStarsGiftOptionsRequest{}},
|
||||
{name: "payments.getStarsGiveawayOptions", req: &tg.PaymentsGetStarsGiveawayOptionsRequest{}},
|
||||
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
||||
|
|
@ -1350,11 +1347,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "stories.getPeerMaxIDs", req: &tg.StoriesGetPeerMaxIDsRequest{ID: []tg.InputPeerClass{&tg.InputPeerSelf{}}}},
|
||||
{name: "stories.getStoriesViews", req: &tg.StoriesGetStoriesViewsRequest{Peer: &tg.InputPeerSelf{}, ID: []int{1}}},
|
||||
{name: "stories.getChatsToSend", req: &tg.StoriesGetChatsToSendRequest{}},
|
||||
{name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}},
|
||||
{name: "payments.getStarGifts", req: &tg.PaymentsGetStarGiftsRequest{}},
|
||||
{name: "payments.getStarGiftCollections", req: &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}},
|
||||
{name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}},
|
||||
{name: "payments.getStarsRevenueAdsAccountUrl", req: &tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsRevenueStats", req: &tg.PaymentsGetStarsRevenueStatsRequest{Ton: true, Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "bots.getBotRecommendations", req: &tg.BotsGetBotRecommendationsRequest{Bot: &tg.InputUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
||||
|
|
|
|||
|
|
@ -324,18 +324,6 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
|
|||
userIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if msg.Action.StarGift != nil {
|
||||
if id := msg.Action.StarGift.FromUserID; id != 0 && !msg.Action.StarGift.NameHidden {
|
||||
userIDs[id] = struct{}{}
|
||||
}
|
||||
if id := msg.Action.StarGift.PeerUserID; id != 0 {
|
||||
userIDs[id] = struct{}{}
|
||||
}
|
||||
if id := msg.Action.StarGift.PeerChannelID; id != 0 && id != currentChannelID {
|
||||
channelIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
collectStarGiftUniquePeerRefs(msg.Action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
if msg.Reactions != nil {
|
||||
for _, reaction := range msg.Reactions.Recent {
|
||||
|
|
@ -356,37 +344,6 @@ func collectServiceActionPeerRefs(media *domain.MessageMedia, currentChannelID i
|
|||
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
if gift := action.StarGift; gift != nil {
|
||||
if gift.FromUserID != 0 && !gift.NameHidden {
|
||||
userIDs[gift.FromUserID] = struct{}{}
|
||||
}
|
||||
if gift.PeerUserID != 0 {
|
||||
userIDs[gift.PeerUserID] = struct{}{}
|
||||
}
|
||||
if gift.PeerChannelID != 0 && gift.PeerChannelID != currentChannelID {
|
||||
channelIDs[gift.PeerChannelID] = struct{}{}
|
||||
}
|
||||
addDomainPeerRef(gift.To, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
collectStarGiftUniquePeerRefs(action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
|
||||
func collectStarGiftUniquePeerRefs(action *domain.MessageStarGiftUniqueAction, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
|
||||
if action == nil {
|
||||
return
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
userIDs[action.FromUserID] = struct{}{}
|
||||
}
|
||||
addDomainPeerRef(action.Peer, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.Owner, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.OriginalOwner, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.ReleasedBy, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.ThemePeer, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.Host, currentChannelID, userIDs, channelIDs)
|
||||
if action.Gift.OriginalFromUserID != 0 && !action.Gift.OriginalNameHidden {
|
||||
userIDs[action.Gift.OriginalFromUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
|
||||
|
|
|
|||
|
|
@ -28,96 +28,3 @@ func TestRemoveKnownChannelRefs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsIncludesStarGiftServiceActions(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
FromUserID: 1001, PeerChannelID: 55,
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1001]; !ok {
|
||||
t.Fatalf("ordinary star-gift user refs=%v, missing sender", users)
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("ordinary star-gift channel refs=%v", channels)
|
||||
}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{PeerUserID: 1002},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1002]; !ok {
|
||||
t.Fatalf("ordinary star-gift recipient refs=%v", users)
|
||||
}
|
||||
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
FromUserID: 2001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
|
||||
Gift: domain.UniqueStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
|
||||
OriginalFromUserID: 2002,
|
||||
OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
|
||||
ReleasedBy: domain.Peer{Type: domain.PeerTypeUser, ID: 2004},
|
||||
ThemePeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 57},
|
||||
Host: domain.Peer{Type: domain.PeerTypeUser, ID: 2005},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
for _, id := range []int64{2001, 2002, 2003, 2004, 2005} {
|
||||
if _, ok := users[id]; !ok {
|
||||
t.Fatalf("unique star-gift user refs=%v, missing %d", users, id)
|
||||
}
|
||||
}
|
||||
for _, id := range []int64{56, 57} {
|
||||
if _, ok := channels[id]; !ok {
|
||||
t.Fatalf("unique star-gift channel refs=%v, missing %d", channels, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsHidesStarGiftSenderDetails(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
FromUserID: 1001, NameHidden: true, PeerChannelID: 55,
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: domain.UniqueStarGift{
|
||||
OriginalFromUserID: 2001,
|
||||
OriginalNameHidden: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
for _, id := range []int64{1001, 2001} {
|
||||
if _, ok := users[id]; ok {
|
||||
t.Fatalf("hidden star-gift sender %d leaked into refs=%v", id, users)
|
||||
}
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("hidden ordinary gift lost recipient channel ref=%v", channels)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,14 +362,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
|
|||
}
|
||||
full.CommonChatsCount = common.Count
|
||||
}
|
||||
// star gift 数量:客户端把资料页 Gifts 区段/标签页门控在 stargifts_count>0
|
||||
//(DrKLO ProfileActivity:10497 / TDesktop data_user.cpp:924),不下发则收到的礼物
|
||||
// 不在资料页展示。计展示在资料的礼物数(非转换、非隐藏)。
|
||||
if r.deps.Gifts != nil {
|
||||
if n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}); err == nil && n > 0 {
|
||||
full.SetStargiftsCount(n)
|
||||
}
|
||||
}
|
||||
// 屏蔽 premium 礼物赠送:telesrv 未实现 payments.getPremiumGiftCodeOptions,
|
||||
// DrKLO GiftSheet 对个人送礼时总会渲染一个「Gift Premium」区段(fillItems:918),
|
||||
// premiumTiers 永远空 → 卡死成三个 flicker 占位骨架。设 disallow_premium_gifts=true
|
||||
|
|
@ -389,7 +381,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
|
|||
full.SetBirthday(tgBirthday(u.Birthday))
|
||||
}
|
||||
}
|
||||
r.applyAccountRatingToUserFull(ctx, currentUserID, u, &full)
|
||||
// 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的
|
||||
// overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及
|
||||
// build/chats 两次解析同一频道。
|
||||
|
|
@ -441,50 +432,6 @@ func (r *Router) userFullPrivacyVisibility(ctx context.Context, viewerUserID, ow
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// applyAccountRatingToUserFull projects gramsrv's stored composite rating through
|
||||
// the rating fields official clients already render. This is a gramsrv policy
|
||||
// score, not a promise that its inputs or thresholds match Telegram's service.
|
||||
//
|
||||
// The projection is built inside the existing per-(viewer,target) UserFull
|
||||
// cache. Therefore a cache miss adds at most one primary-key read and a cache hit
|
||||
// adds none. Recompute and writes remain exclusively in the bounded background
|
||||
// worker/admin paths.
|
||||
func (r *Router) applyAccountRatingToUserFull(ctx context.Context, viewerUserID int64, target domain.User, full *tg.UserFull) {
|
||||
targetUserID := target.ID
|
||||
if r.deps.AccountRatings == nil || full == nil || !domain.RatableAccount(targetUserID, target.Bot) {
|
||||
return
|
||||
}
|
||||
rating, err := r.deps.AccountRatings.Rating(ctx, targetUserID)
|
||||
if err != nil {
|
||||
// Missing, disabled and temporarily unavailable projections all preserve
|
||||
// the legacy wire shape instead of failing the surrounding profile read.
|
||||
return
|
||||
}
|
||||
full.SetStarsRating(tgAccountRatingLevel(rating.LevelSnapshot()))
|
||||
if viewerUserID == 0 || viewerUserID != targetUserID {
|
||||
return
|
||||
}
|
||||
pending, ok := rating.PendingLevel()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
full.SetStarsMyPendingRating(tgAccountRatingLevel(pending))
|
||||
full.SetStarsMyPendingRatingDate(int(rating.PendingDate.Unix()))
|
||||
}
|
||||
|
||||
// tgAccountRatingLevel maps the local level snapshot onto starsRating#1b0e4f07.
|
||||
// next_level_stars remains absent at the configured maximum local level.
|
||||
func tgAccountRatingLevel(in domain.AccountRatingLevel) tg.StarsRating {
|
||||
out := tg.StarsRating{
|
||||
Level: in.Level,
|
||||
CurrentLevelStars: in.CurrentLevelStars,
|
||||
Stars: in.Stars,
|
||||
}
|
||||
if in.HasNextLevelStars {
|
||||
out.SetNextLevelStars(in.NextLevelStars)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tgBirthday 把 domain 生日转 tg.Birthday(Year 可选,0 表示不含年份)。
|
||||
func tgBirthday(b domain.Birthday) tg.Birthday {
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
package giftdemo
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// This file hand-builds small, fully original 512x512 Lottie animations from
|
||||
// geometric primitives (polystars, ellipses, rings). They are deliberately
|
||||
// simple placeholders — the point is a legally-clean demo asset set the admin
|
||||
// can later replace with real artwork, not studio-grade graphics. Everything
|
||||
// here is emitted as plain shape/transform JSON, with no expressions and no
|
||||
// external assets, so it passes the Star Gift animation validator unchanged.
|
||||
|
||||
const (
|
||||
canvasSize = 512
|
||||
center = canvasSize / 2
|
||||
frameRate = 60
|
||||
// 120 frames @ 60fps = 2s loop, well under the 30s ceiling.
|
||||
outPoint = 120
|
||||
)
|
||||
|
||||
// rgb is a 0..1 normalized colour triplet, the form Lottie fills expect.
|
||||
type rgb [3]float64
|
||||
|
||||
func fromHex(v int) rgb {
|
||||
return rgb{
|
||||
float64((v>>16)&0xff) / 255,
|
||||
float64((v>>8)&0xff) / 255,
|
||||
float64(v&0xff) / 255,
|
||||
}
|
||||
}
|
||||
|
||||
type motion int
|
||||
|
||||
const (
|
||||
motionSpin motion = iota
|
||||
motionPulse
|
||||
motionSpinPulse
|
||||
)
|
||||
|
||||
// shapeKind picks which primitive the layer draws.
|
||||
type shapeKind int
|
||||
|
||||
const (
|
||||
shapeStar shapeKind = iota // pointed star (polystar type 1)
|
||||
shapePolygon
|
||||
shapeRing // ellipse outline + inner disc, used for "coin"
|
||||
shapeBurst
|
||||
)
|
||||
|
||||
type lottieSpec struct {
|
||||
kind shapeKind
|
||||
points int // star / polygon point count
|
||||
fill rgb
|
||||
stroke rgb
|
||||
strokeW float64 // 0 = no stroke
|
||||
radius float64
|
||||
motion motion
|
||||
}
|
||||
|
||||
// prop builds a static Lottie animated-value wrapper {a:0,k:value}.
|
||||
func prop(value any) map[string]any { return map[string]any{"a": 0, "k": value} }
|
||||
|
||||
// easing handles are arrays (never strings), so the validator's expression
|
||||
// check — which only rejects string-valued "x" keys — never trips on them.
|
||||
func keyframe(t float64, value []float64) map[string]any {
|
||||
return map[string]any{
|
||||
"t": t,
|
||||
"s": value,
|
||||
"i": map[string]any{"x": []float64{0.6}, "y": []float64{1}},
|
||||
"o": map[string]any{"x": []float64{0.4}, "y": []float64{0}},
|
||||
}
|
||||
}
|
||||
|
||||
func spinRotation() map[string]any {
|
||||
return map[string]any{"a": 1, "k": []any{
|
||||
keyframe(0, []float64{0}),
|
||||
keyframe(outPoint, []float64{360}),
|
||||
}}
|
||||
}
|
||||
|
||||
func pulseScale() map[string]any {
|
||||
return map[string]any{"a": 1, "k": []any{
|
||||
keyframe(0, []float64{100, 100, 100}),
|
||||
keyframe(outPoint/2, []float64{114, 114, 114}),
|
||||
keyframe(outPoint, []float64{100, 100, 100}),
|
||||
}}
|
||||
}
|
||||
|
||||
func transform(m motion) map[string]any {
|
||||
rotation := prop(0.0)
|
||||
scale := prop([]float64{100, 100, 100})
|
||||
switch m {
|
||||
case motionSpin:
|
||||
rotation = spinRotation()
|
||||
case motionPulse:
|
||||
scale = pulseScale()
|
||||
case motionSpinPulse:
|
||||
rotation = spinRotation()
|
||||
scale = pulseScale()
|
||||
}
|
||||
return map[string]any{
|
||||
"o": prop(100.0),
|
||||
"r": rotation,
|
||||
"p": prop([]float64{center, center, 0}),
|
||||
"a": prop([]float64{0, 0, 0}),
|
||||
"s": scale,
|
||||
"sk": prop(0.0),
|
||||
"sa": prop(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
func fill(c rgb) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "fl", "nm": "Fill", "r": 1,
|
||||
"o": prop(100.0),
|
||||
"c": prop([]float64{c[0], c[1], c[2]}),
|
||||
}
|
||||
}
|
||||
|
||||
func stroke(c rgb, width float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "st", "nm": "Stroke", "lc": 2, "lj": 2, "ml": 4,
|
||||
"o": prop(100.0),
|
||||
"w": prop(width),
|
||||
"c": prop([]float64{c[0], c[1], c[2]}),
|
||||
}
|
||||
}
|
||||
|
||||
func polystar(points int, starType int, outer, innerRatio float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "sr", "nm": "Polystar", "sy": starType,
|
||||
"d": 1,
|
||||
"pt": prop(float64(points)),
|
||||
"p": prop([]float64{0, 0}),
|
||||
"r": prop(0.0),
|
||||
"ir": prop(outer * innerRatio),
|
||||
"is": prop(0.0),
|
||||
"or": prop(outer),
|
||||
"os": prop(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
func ellipse(radius float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "el", "nm": "Ellipse", "d": 1,
|
||||
"p": prop([]float64{0, 0}),
|
||||
"s": prop([]float64{radius * 2, radius * 2}),
|
||||
}
|
||||
}
|
||||
|
||||
// renderLottie serializes one spec to Lottie JSON bytes.
|
||||
func renderLottie(spec lottieSpec) ([]byte, error) {
|
||||
var shapes []any
|
||||
switch spec.kind {
|
||||
case shapeStar:
|
||||
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.5))
|
||||
case shapePolygon:
|
||||
shapes = append(shapes, polystar(spec.points, 2, spec.radius, 0.5))
|
||||
case shapeBurst:
|
||||
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.32))
|
||||
case shapeRing:
|
||||
shapes = append(shapes, ellipse(spec.radius))
|
||||
}
|
||||
shapes = append(shapes, fill(spec.fill))
|
||||
if spec.strokeW > 0 {
|
||||
shapes = append(shapes, stroke(spec.stroke, spec.strokeW))
|
||||
}
|
||||
// A coin gets a smaller contrasting inner disc for a bit of depth.
|
||||
if spec.kind == shapeRing {
|
||||
shapes = append(shapes, ellipse(spec.radius*0.55), fill(spec.stroke))
|
||||
}
|
||||
|
||||
layer := map[string]any{
|
||||
"ddd": 0, "ind": 1, "ty": 4, "nm": "gift", "sr": 1,
|
||||
"ks": transform(spec.motion),
|
||||
"ao": 0,
|
||||
"shapes": shapes,
|
||||
"ip": 0, "op": outPoint, "st": 0, "bm": 0,
|
||||
}
|
||||
root := map[string]any{
|
||||
"v": "5.7.4", "fr": frameRate, "ip": 0, "op": outPoint,
|
||||
"w": canvasSize, "h": canvasSize, "nm": "owpengram-demo-gift",
|
||||
"ddd": 0, "assets": []any{}, "layers": []any{layer},
|
||||
}
|
||||
return json.Marshal(root)
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
// Package giftdemo is an in-memory catalog of small, fully original demo Star
|
||||
// Gifts (geometric Lottie authored here — not Telegram's copyrighted assets).
|
||||
// It backs the admin console's "Default gifts" import source: operators can
|
||||
// import these to demo the complete gift surface (upgrade + craft) without any
|
||||
// third-party artwork. Nothing here is enabled automatically; gifts appear in
|
||||
// the catalog only once an operator imports them.
|
||||
package giftdemo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Preparer normalizes raw Lottie bytes into the canonical Star Gift animation
|
||||
// pair. *stargifts.Service satisfies it.
|
||||
type Preparer interface {
|
||||
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
|
||||
}
|
||||
|
||||
const (
|
||||
seedActor = "system-default-gifts"
|
||||
seedCommandID = "default-gifts-v1"
|
||||
)
|
||||
|
||||
type attrSpec struct {
|
||||
name string
|
||||
spec lottieSpec
|
||||
permille int // >0 for a normal (drawable) attribute
|
||||
crafted bool // craft-only model; permille must be 0 and rarity named
|
||||
rarity domain.StarGiftAttributeRarityKind
|
||||
}
|
||||
|
||||
type backdropSpec struct {
|
||||
name string
|
||||
center int
|
||||
edge int
|
||||
pattern int
|
||||
text int
|
||||
permille int
|
||||
}
|
||||
|
||||
type upgradeSpec struct {
|
||||
upgradeStars int64
|
||||
supplyTotal int
|
||||
slug string
|
||||
models []attrSpec
|
||||
patterns []attrSpec
|
||||
backdrops []backdropSpec
|
||||
}
|
||||
|
||||
type giftSpec struct {
|
||||
title string
|
||||
stars int64
|
||||
convert int64
|
||||
base lottieSpec
|
||||
limited bool
|
||||
availability int
|
||||
requirePremium bool
|
||||
birthday bool
|
||||
upgrade *upgradeSpec
|
||||
}
|
||||
|
||||
// GiftInfo is a catalog listing entry for the import picker.
|
||||
type GiftInfo struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
Upgradeable bool `json:"upgradeable"`
|
||||
Craftable bool `json:"craftable"`
|
||||
Limited bool `json:"limited"`
|
||||
Availability int `json:"availability"`
|
||||
RequirePremium bool `json:"require_premium"`
|
||||
ModelCount int `json:"model_count"`
|
||||
PatternCount int `json:"pattern_count"`
|
||||
BackdropCount int `json:"backdrop_count"`
|
||||
CraftedCount int `json:"crafted_count"`
|
||||
}
|
||||
|
||||
// List returns the built-in demo gifts, ids 1..N in display order.
|
||||
func List() []GiftInfo {
|
||||
gifts := demoGifts()
|
||||
out := make([]GiftInfo, 0, len(gifts))
|
||||
for i, spec := range gifts {
|
||||
info := GiftInfo{
|
||||
ID: i + 1, Title: spec.title, Stars: spec.stars, ConvertStars: spec.convert,
|
||||
Limited: spec.limited, Availability: spec.availability, RequirePremium: spec.requirePremium,
|
||||
}
|
||||
if spec.upgrade != nil {
|
||||
info.Upgradeable = true
|
||||
info.UpgradeStars = spec.upgrade.upgradeStars
|
||||
info.ModelCount = len(spec.upgrade.models)
|
||||
info.PatternCount = len(spec.upgrade.patterns)
|
||||
info.BackdropCount = len(spec.upgrade.backdrops)
|
||||
for _, m := range spec.upgrade.models {
|
||||
if m.crafted {
|
||||
info.CraftedCount++
|
||||
}
|
||||
}
|
||||
info.Craftable = info.CraftedCount > 0
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func specByID(id int) (giftSpec, int, bool) {
|
||||
gifts := demoGifts()
|
||||
if id < 1 || id > len(gifts) {
|
||||
return giftSpec{}, 0, false
|
||||
}
|
||||
return gifts[id-1], id - 1, true
|
||||
}
|
||||
|
||||
// BaseAnimationJSON returns the normalized Lottie JSON of a gift's base sticker,
|
||||
// used by the admin preview player.
|
||||
func BaseAnimationJSON(prep Preparer, id int) ([]byte, bool, error) {
|
||||
spec, _, ok := specByID(id)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
anim, err := prepare(prep, spec.title, spec.base)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return anim.JSON, true, nil
|
||||
}
|
||||
|
||||
// BuildBundle assembles the complete catalog+collectible write for one demo
|
||||
// gift. The bundle carries no official provenance (OfficialGiftID stays 0), so
|
||||
// these import as ordinary locally-authored catalog entries.
|
||||
func BuildBundle(prep Preparer, id, sortOrder int) (domain.StarGiftCatalogBundleWrite, string, error) {
|
||||
spec, _, ok := specByID(id)
|
||||
if !ok {
|
||||
return domain.StarGiftCatalogBundleWrite{}, "", fmt.Errorf("unknown demo gift id %d", id)
|
||||
}
|
||||
write, err := buildBundle(prep, spec, sortOrder)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleWrite{}, "", err
|
||||
}
|
||||
return write, spec.title, nil
|
||||
}
|
||||
|
||||
// CatalogReader is the read side used to skip gifts already present by title.
|
||||
type CatalogReader interface {
|
||||
Catalog(ctx context.Context) ([]domain.StarGift, error)
|
||||
}
|
||||
|
||||
// PresentTitles returns the set of demo gift titles already in the catalog, so
|
||||
// callers can import idempotently.
|
||||
func PresentTitles(ctx context.Context, reader CatalogReader) (map[string]struct{}, error) {
|
||||
existing, err := reader.Catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
demo := map[string]struct{}{}
|
||||
for _, spec := range demoGifts() {
|
||||
demo[spec.title] = struct{}{}
|
||||
}
|
||||
present := map[string]struct{}{}
|
||||
for _, gift := range existing {
|
||||
if _, ok := demo[gift.Title]; ok {
|
||||
present[gift.Title] = struct{}{}
|
||||
}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
func buildBundle(prep Preparer, spec giftSpec, sortOrder int) (domain.StarGiftCatalogBundleWrite, error) {
|
||||
baseAnim, err := prepare(prep, spec.title, spec.base)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleWrite{}, err
|
||||
}
|
||||
catalog := domain.StarGiftCatalogWrite{
|
||||
Title: spec.title,
|
||||
Stars: spec.stars,
|
||||
ConvertStars: spec.convert,
|
||||
Enabled: true,
|
||||
SortOrder: sortOrder,
|
||||
Animation: baseAnim,
|
||||
Actor: seedActor,
|
||||
CommandID: seedCommandID,
|
||||
}
|
||||
if spec.limited {
|
||||
catalog.Limited = true
|
||||
catalog.AvailabilityTotal = spec.availability
|
||||
catalog.AvailabilityRemains = spec.availability
|
||||
}
|
||||
catalog.RequirePremium = spec.requirePremium
|
||||
catalog.Birthday = spec.birthday
|
||||
|
||||
write := domain.StarGiftCatalogBundleWrite{Catalog: catalog}
|
||||
if spec.upgrade == nil {
|
||||
return write, nil
|
||||
}
|
||||
|
||||
up := spec.upgrade
|
||||
models, err := buildAttributes(prep, up.models, domain.StarGiftCollectibleModel)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleWrite{}, err
|
||||
}
|
||||
patterns, err := buildAttributes(prep, up.patterns, domain.StarGiftCollectiblePattern)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogBundleWrite{}, err
|
||||
}
|
||||
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, len(up.backdrops))
|
||||
for i, b := range up.backdrops {
|
||||
backdrops = append(backdrops, domain.StarGiftCollectibleAttribute{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: b.name, BackdropID: i + 1,
|
||||
CenterColor: b.center, EdgeColor: b.edge, PatternColor: b.pattern, TextColor: b.text,
|
||||
RarityKind: domain.StarGiftRarityPermille, RarityPermille: b.permille, SortOrder: i,
|
||||
})
|
||||
}
|
||||
write.Collectible = &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: up.upgradeStars,
|
||||
SupplyTotal: up.supplyTotal,
|
||||
SlugPrefix: up.slug,
|
||||
Models: models,
|
||||
Patterns: patterns,
|
||||
Backdrops: backdrops,
|
||||
Actor: seedActor,
|
||||
CommandID: seedCommandID,
|
||||
}
|
||||
return write, nil
|
||||
}
|
||||
|
||||
func buildAttributes(prep Preparer, specs []attrSpec, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
out := make([]domain.StarGiftCollectibleAttribute, 0, len(specs))
|
||||
for i, s := range specs {
|
||||
anim, err := prepare(prep, s.name, s.spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attr := domain.StarGiftCollectibleAttribute{
|
||||
Kind: kind, Name: s.name, SortOrder: i, Animation: &anim,
|
||||
}
|
||||
if s.crafted {
|
||||
attr.Crafted = true
|
||||
attr.RarityKind = s.rarity
|
||||
attr.RarityPermille = 0
|
||||
} else {
|
||||
attr.RarityKind = domain.StarGiftRarityPermille
|
||||
attr.RarityPermille = s.permille
|
||||
}
|
||||
out = append(out, attr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func prepare(prep Preparer, name string, spec lottieSpec) (domain.StarGiftAnimation, error) {
|
||||
data, err := renderLottie(spec)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
return prep.PrepareAnimation(name+".json", data)
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
package giftdemo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *fakeBlob) Name() string { return "localfs" }
|
||||
func (b *fakeBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *fakeBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func newService() *stargifts.Service {
|
||||
return stargifts.NewService(memory.NewStarGiftStore(), &fakeBlob{data: map[string][]byte{}}, 2)
|
||||
}
|
||||
|
||||
func TestListDescribesFullGiftSurface(t *testing.T) {
|
||||
list := List()
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("List has %d gifts, want 3", len(list))
|
||||
}
|
||||
upgradeable, craftable, limited, premium := 0, 0, 0, 0
|
||||
for _, g := range list {
|
||||
if g.ID < 1 || g.Title == "" || g.Stars <= 0 {
|
||||
t.Fatalf("bad gift info: %+v", g)
|
||||
}
|
||||
if g.Upgradeable {
|
||||
upgradeable++
|
||||
}
|
||||
if g.Craftable {
|
||||
craftable++
|
||||
}
|
||||
if g.Limited {
|
||||
limited++
|
||||
}
|
||||
if g.RequirePremium {
|
||||
premium++
|
||||
}
|
||||
}
|
||||
// One plain (Spark), one upgradeable-only (Star), one craftable (Coin).
|
||||
if upgradeable != 2 || craftable != 1 || limited != 0 || premium != 0 {
|
||||
t.Fatalf("surface counts upgradeable=%d craftable=%d limited=%d premium=%d", upgradeable, craftable, limited, premium)
|
||||
}
|
||||
}
|
||||
|
||||
// Every demo gift must build into a valid catalog+collectible bundle and
|
||||
// import cleanly through the real service (which materializes and validates
|
||||
// the whole pool). Limited/premium flags must survive onto the stored gift.
|
||||
func TestBuildBundleImportsEndToEnd(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newService()
|
||||
|
||||
for _, info := range List() {
|
||||
write, title, err := BuildBundle(svc, info.ID, info.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("build %q: %v", info.Title, err)
|
||||
}
|
||||
if title != info.Title {
|
||||
t.Fatalf("title mismatch %q != %q", title, info.Title)
|
||||
}
|
||||
if _, err := svc.CreateCatalogBundle(ctx, write); err != nil {
|
||||
t.Fatalf("import %q: %v", info.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := svc.Catalog(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(catalog) != 3 {
|
||||
t.Fatalf("catalog has %d, want 3", len(catalog))
|
||||
}
|
||||
limited, premium, upgradeable := 0, 0, 0
|
||||
for _, g := range catalog {
|
||||
if g.Limited {
|
||||
limited++
|
||||
}
|
||||
if g.RequirePremium {
|
||||
premium++
|
||||
}
|
||||
if g.UpgradeStars > 0 {
|
||||
upgradeable++
|
||||
}
|
||||
}
|
||||
if limited != 0 || premium != 0 || upgradeable != 2 {
|
||||
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAnimationJSONRenders(t *testing.T) {
|
||||
svc := newService()
|
||||
data, ok, err := BaseAnimationJSON(svc, 1)
|
||||
if err != nil || !ok || len(data) == 0 {
|
||||
t.Fatalf("base animation id=1: ok=%v err=%v len=%d", ok, err, len(data))
|
||||
}
|
||||
if _, ok, _ := BaseAnimationJSON(svc, 99); ok {
|
||||
t.Fatalf("id=99 should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentTitles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newService()
|
||||
write, _, err := BuildBundle(svc, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.CreateCatalogBundle(ctx, write); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
present, err := PresentTitles(ctx, svc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(present) != 1 {
|
||||
t.Fatalf("present=%v, want exactly the one imported title", present)
|
||||
}
|
||||
if _, ok := present["OwpenGram Spark"]; !ok {
|
||||
t.Fatalf("expected Spark present, got %v", present)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package giftdemo
|
||||
|
||||
import "telesrv/internal/domain"
|
||||
|
||||
// Palette (hex) shared across the demo assets.
|
||||
const (
|
||||
colGold = 0xF5C542
|
||||
colAmber = 0xF59E0B
|
||||
colBlue = 0x2563EB
|
||||
colCyan = 0x38BDF8
|
||||
colViolet = 0x7C3AED
|
||||
colEmerald = 0x10B981
|
||||
colRose = 0xF43F5E
|
||||
colWhite = 0xFFFFFF
|
||||
colSlate = 0x1E293B
|
||||
colMidnight = 0x0B1220
|
||||
colDeepEm = 0x065F46
|
||||
)
|
||||
|
||||
func star(points int, fill, stroke int, strokeW float64, m motion) lottieSpec {
|
||||
return lottieSpec{kind: shapeStar, points: points, fill: fromHex(fill), stroke: fromHex(stroke), strokeW: strokeW, radius: 168, motion: m}
|
||||
}
|
||||
|
||||
func polygon(points, fill, stroke int, strokeW float64, m motion) lottieSpec {
|
||||
return lottieSpec{kind: shapePolygon, points: points, fill: fromHex(fill), stroke: fromHex(stroke), strokeW: strokeW, radius: 150, motion: m}
|
||||
}
|
||||
|
||||
func ring(fill, inner int, m motion) lottieSpec {
|
||||
return lottieSpec{kind: shapeRing, fill: fromHex(fill), stroke: fromHex(inner), radius: 150, motion: m}
|
||||
}
|
||||
|
||||
func burst(points, fill int, m motion) lottieSpec {
|
||||
return lottieSpec{kind: shapeBurst, points: points, fill: fromHex(fill), radius: 150, motion: m}
|
||||
}
|
||||
|
||||
// Four colour-only backdrops reused across every upgradeable gift (backdrops
|
||||
// carry no animation asset, so sharing them is free).
|
||||
func demoBackdrops() []backdropSpec {
|
||||
return []backdropSpec{
|
||||
{name: "Midnight", center: colSlate, edge: colMidnight, pattern: colCyan, text: colWhite, permille: 400},
|
||||
{name: "Sunset", center: colAmber, edge: colRose, pattern: colWhite, text: colSlate, permille: 300},
|
||||
{name: "Emerald", center: colEmerald, edge: colDeepEm, pattern: colWhite, text: colWhite, permille: 200},
|
||||
{name: "Royal", center: colViolet, edge: colBlue, pattern: colGold, text: colWhite, permille: 100},
|
||||
}
|
||||
}
|
||||
|
||||
// demoGifts returns the three demo gifts in display order, one per capability
|
||||
// tier: plain (not upgradeable), upgradeable (no crafting), and craftable.
|
||||
func demoGifts() []giftSpec {
|
||||
return []giftSpec{
|
||||
{
|
||||
// #1 — Звичайний: cheapest, plain, not upgradeable.
|
||||
title: "OwpenGram Spark",
|
||||
stars: 15,
|
||||
convert: 15,
|
||||
base: burst(8, colGold, motionPulse),
|
||||
},
|
||||
{
|
||||
// #2 — Апгрейдебл: standard upgradeable, no crafting.
|
||||
title: "OwpenGram Star",
|
||||
stars: 50,
|
||||
convert: 50,
|
||||
base: star(5, colBlue, colCyan, 10, motionSpin),
|
||||
upgrade: &upgradeSpec{
|
||||
upgradeStars: 200,
|
||||
supplyTotal: 10000,
|
||||
slug: "owg-star",
|
||||
models: []attrSpec{
|
||||
{name: "Sapphire", spec: star(5, colBlue, colCyan, 12, motionSpin), permille: 600},
|
||||
{name: "Frost", spec: star(6, colCyan, colWhite, 10, motionSpin), permille: 400},
|
||||
},
|
||||
patterns: []attrSpec{
|
||||
{name: "Halo", spec: burst(12, colCyan, motionPulse), permille: 700},
|
||||
{name: "Drift", spec: burst(8, colBlue, motionPulse), permille: 300},
|
||||
},
|
||||
backdrops: demoBackdrops(),
|
||||
},
|
||||
},
|
||||
{
|
||||
// #3 — Крафтабл: upgradeable + craftable.
|
||||
title: "OwpenGram Coin",
|
||||
stars: 100,
|
||||
convert: 75,
|
||||
base: ring(colAmber, colSlate, motionSpin),
|
||||
upgrade: &upgradeSpec{
|
||||
upgradeStars: 400,
|
||||
supplyTotal: 8000,
|
||||
slug: "owg-coin",
|
||||
models: []attrSpec{
|
||||
{name: "Bronze", spec: ring(colAmber, colSlate, motionSpin), permille: 600},
|
||||
{name: "Silver", spec: ring(colWhite, colSlate, motionSpin), permille: 400},
|
||||
{name: "Molten", spec: ring(colRose, colAmber, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityRare},
|
||||
},
|
||||
patterns: []attrSpec{
|
||||
{name: "Gleam", spec: burst(10, colGold, motionPulse), permille: 700},
|
||||
{name: "Ember", spec: burst(6, colAmber, motionPulse), permille: 300},
|
||||
},
|
||||
backdrops: demoBackdrops(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountRatingStore owns the composite rating read model and its contribution
|
||||
// ledger.
|
||||
//
|
||||
// The read model is derived: SaveAccountRating writes a recomputed projection,
|
||||
// AccountRatingSignals gathers the raw inputs from the contributing tables, and
|
||||
// the ledger keeps manual adjustments that must survive a recompute.
|
||||
type AccountRatingStore interface {
|
||||
// AccountRating returns the stored projection. Missing rows return
|
||||
// domain.ErrAccountRatingNotFound so callers can distinguish "never computed"
|
||||
// from "computed as zero".
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
// AccountRatingBatch resolves several users in one round trip. Users without
|
||||
// a row are absent from the map.
|
||||
AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error)
|
||||
// SaveAccountRating upserts the projection using optimistic concurrency on
|
||||
// the stored version; a stale version reports changed=false.
|
||||
SaveAccountRating(ctx context.Context, rating domain.AccountRating) (stored domain.AccountRating, changed bool, err error)
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user,
|
||||
// including the manual total carried from the ledger.
|
||||
AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error)
|
||||
// AdjustAccountRating appends a manual adjustment. Replaying the same
|
||||
// CommandKey returns the recorded event and applied=false.
|
||||
AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (event domain.AccountRatingEvent, applied bool, err error)
|
||||
// ListAccountRatings is the admin leaderboard query with keyset paging.
|
||||
ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
// AccountRatingEvents returns the ledger for one user, newest first.
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
// StaleAccountRatings returns user ids whose projection is older than the
|
||||
// given horizon, for the background recompute worker.
|
||||
StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error)
|
||||
// UnratedAccounts returns user ids that have no projection at all, oldest
|
||||
// account first, for the same worker.
|
||||
//
|
||||
// Without this the read model can never populate itself: StaleAccountRatings
|
||||
// walks account_rating, so it can only refresh rows that already exist, and the
|
||||
// very first row for a user would have to come from an operator recomputing
|
||||
// that user by hand. Seeding is what makes the local admin leaderboard useful.
|
||||
//
|
||||
// Deleted accounts and bots are excluded: neither has a rating to show.
|
||||
UnratedAccounts(ctx context.Context, limit int) ([]int64, error)
|
||||
}
|
||||
|
|
@ -78,7 +78,6 @@ type ChannelStore interface {
|
|||
ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error)
|
||||
GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error)
|
||||
SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
|
||||
AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error)
|
||||
GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
|
||||
VoteChannelMessagePoll(ctx context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
||||
CloseChannelMessagePoll(ctx context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
||||
|
|
@ -205,8 +204,6 @@ type ChannelStore interface {
|
|||
// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道
|
||||
// pts),Recipients 为活跃成员(rpc 据此扇出 updateNewChannelMessage)。
|
||||
AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error)
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照,不插入频道历史、不推进 pts。
|
||||
AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error
|
||||
}
|
||||
|
||||
// ChannelIDAllocator allocates channel IDs.
|
||||
|
|
|
|||
|
|
@ -1,392 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Default page sizes for the rating reads, so an unset limit resolves to a
|
||||
// finite page the way the PostgreSQL LIMIT does.
|
||||
const (
|
||||
defaultAccountRatingListLimit = 50
|
||||
defaultAccountRatingEventLimit = 50
|
||||
defaultAccountRatingStaleLimit = 50
|
||||
)
|
||||
|
||||
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
|
||||
// It reproduces the invariants migration 0151 encodes:
|
||||
//
|
||||
// - account_rating is keyed by user_id, so one projection row per user.
|
||||
// - the version CHECK plus optimistic concurrency: a write is applied only when
|
||||
// it carries the successor of the stored version, which is exactly what
|
||||
// domain.ResolveAccountRatingPending produces.
|
||||
// - the pending pair CHECK: a pending delta and its date exist together or not
|
||||
// at all.
|
||||
// - the component CHECKs: stars/activity/penalty components and level are
|
||||
// non-negative, and next_level_stars is either absent or above
|
||||
// current_level_stars.
|
||||
// - account_rating_events_command_idx: a replayed command key never appends a
|
||||
// second adjustment.
|
||||
type AccountRatingStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
// ratings is the account_rating read model.
|
||||
ratings map[int64]domain.AccountRating
|
||||
// events is the append-only contribution ledger in insertion order.
|
||||
events []domain.AccountRatingEvent
|
||||
// commands maps an adjustment command key onto the ledger row it created.
|
||||
commands map[string]int64
|
||||
// signals holds the raw contribution snapshot per user.
|
||||
//
|
||||
// PostgreSQL aggregates it from stars_transactions, message counts, saved
|
||||
// gifts and moderation cases. In memory those live in unrelated store types
|
||||
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
|
||||
// store has no handle on, and wiring them in would make the rating depend on
|
||||
// which stores a test happens to construct. The snapshot is therefore
|
||||
// injected -- deterministic, and identical for a unit test and for the
|
||||
// recompute worker, which is what domain.AccountRatingSignals promises. Only
|
||||
// the manual total is derived here, from the ledger, because the ledger is
|
||||
// this store's own data.
|
||||
signals map[int64]domain.AccountRatingSignals
|
||||
// accounts is the account universe UnratedAccounts seeds from, in declaration
|
||||
// order.
|
||||
//
|
||||
// PostgreSQL reads it from the users table. This store has no users table and
|
||||
// inventing one from whichever ids happen to appear in the ledger would be
|
||||
// circular -- an account with no rating and no adjustment is exactly the case
|
||||
// seeding exists for. So the universe is declared, like signals above.
|
||||
accounts []int64
|
||||
}
|
||||
|
||||
// NewAccountRatingStore creates an empty rating store.
|
||||
func NewAccountRatingStore() *AccountRatingStore {
|
||||
return &AccountRatingStore{
|
||||
nextID: 1,
|
||||
ratings: make(map[int64]domain.AccountRating),
|
||||
commands: make(map[string]int64),
|
||||
signals: make(map[int64]domain.AccountRatingSignals),
|
||||
}
|
||||
}
|
||||
|
||||
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
|
||||
// packages; see the signals field for why they are injected rather than derived.
|
||||
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
|
||||
for _, item := range signals {
|
||||
s.setAccountRatingSignals(item)
|
||||
}
|
||||
}
|
||||
|
||||
// setAccountRatingSignals is the same hook for this package's tests.
|
||||
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
|
||||
if signals.UserID <= 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.signals[signals.UserID] = signals
|
||||
}
|
||||
|
||||
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
|
||||
// when the user was never computed.
|
||||
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rating, ok := s.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// AccountRatingBatch resolves several users at once; users without a row are
|
||||
// absent from the map.
|
||||
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if rating, ok := s.ratings[userID]; ok {
|
||||
out[userID] = rating
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAccountRating upserts the projection under optimistic concurrency: the
|
||||
// incoming Version must be the successor of the stored one, which is what
|
||||
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
|
||||
// the stored row untouched and reports changed=false, so a caller that lost a
|
||||
// race can re-read and retry.
|
||||
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
if rating.UserID <= 0 {
|
||||
// account_rating.user_id references users(id): there is no row to write.
|
||||
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stored, exists := s.ratings[rating.UserID]
|
||||
if rating.Version != stored.Version+1 {
|
||||
if !exists {
|
||||
return domain.AccountRating{}, false, nil
|
||||
}
|
||||
return stored, false, nil
|
||||
}
|
||||
next := normalizeAccountRating(rating)
|
||||
s.ratings[next.UserID] = next
|
||||
return next, true, nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals returns the injected raw snapshot with the manual total
|
||||
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
|
||||
// contributions reports zeros rather than an error, because the aggregate has no
|
||||
// "missing row" state.
|
||||
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if userID <= 0 {
|
||||
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
signals := s.signals[userID]
|
||||
signals.UserID = userID
|
||||
signals.Manual += s.manualTotalLocked(userID)
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
|
||||
// the recorded event with applied=false and appends nothing.
|
||||
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if req.CommandKey != "" {
|
||||
if id, ok := s.commands[req.CommandKey]; ok {
|
||||
for _, event := range s.events {
|
||||
if event.ID == id {
|
||||
return event, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
event := domain.AccountRatingEvent{
|
||||
ID: s.nextID,
|
||||
UserID: req.UserID,
|
||||
Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandKey: req.CommandKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
s.nextID++
|
||||
s.events = append(s.events, event)
|
||||
if event.CommandKey != "" {
|
||||
s.commands[event.CommandKey] = event.ID
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
|
||||
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
|
||||
// names the last row of the previous page.
|
||||
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cursor, hasCursor := s.ratings[filter.BeforeID]
|
||||
out := make([]domain.AccountRating, 0, len(s.ratings))
|
||||
for _, rating := range s.ratings {
|
||||
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
if filter.UserID > 0 && rating.UserID != filter.UserID {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case filter.BeforeID <= 0:
|
||||
case hasCursor:
|
||||
// Keyset paging over the leaderboard order.
|
||||
if !accountRatingLess(cursor, rating) {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
// The cursor row is gone; fall back to the id tiebreak alone so paging
|
||||
// still terminates.
|
||||
if rating.UserID <= filter.BeforeID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountRatingEvents returns the ledger for one user, newest first.
|
||||
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingEventLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.AccountRatingEvent, 0, limit)
|
||||
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
if s.events[i].UserID != userID {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.events[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StaleAccountRatings returns the users whose projection predates the horizon,
|
||||
// oldest first, which is the order account_rating_stale_idx serves.
|
||||
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingStaleLimit
|
||||
}
|
||||
horizon := time.Unix(olderThanUnix, 0).UTC()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stale := make([]domain.AccountRating, 0, len(s.ratings))
|
||||
for _, rating := range s.ratings {
|
||||
if rating.ComputedAt.Before(horizon) {
|
||||
stale = append(stale, rating)
|
||||
}
|
||||
}
|
||||
sort.Slice(stale, func(i, j int) bool {
|
||||
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
|
||||
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
|
||||
}
|
||||
return stale[i].UserID < stale[j].UserID
|
||||
})
|
||||
if len(stale) > limit {
|
||||
stale = stale[:limit]
|
||||
}
|
||||
out := make([]int64, 0, len(stale))
|
||||
for _, rating := range stale {
|
||||
out = append(out, rating.UserID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
|
||||
// id is a no-op, so a test can declare accounts as it creates them.
|
||||
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
known := make(map[int64]struct{}, len(s.accounts))
|
||||
for _, id := range s.accounts {
|
||||
known[id] = struct{}{}
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[id]; ok {
|
||||
continue
|
||||
}
|
||||
known[id] = struct{}{}
|
||||
s.accounts = append(s.accounts, id)
|
||||
}
|
||||
}
|
||||
|
||||
// UnratedAccounts returns declared accounts that have no projection yet, in
|
||||
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
|
||||
// walk. A store nobody seeded reports no candidates rather than erroring: the
|
||||
// worker treats that as "nothing to seed", which is the truth.
|
||||
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingStaleLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]int64, 0, limit)
|
||||
for _, id := range s.accounts {
|
||||
if _, rated := s.ratings[id]; rated {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
|
||||
// recompute.
|
||||
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
|
||||
var total int64
|
||||
for _, event := range s.events {
|
||||
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
|
||||
total += event.Amount
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
|
||||
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
|
||||
// a domain error, so the memory store folds the impossible shapes onto the
|
||||
// closest representable one instead of inventing an error the RPC layer would
|
||||
// then have to handle only in tests.
|
||||
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
|
||||
if rating.Level < 0 {
|
||||
rating.Level = 0
|
||||
}
|
||||
if rating.Level > domain.MaxAccountRatingLevel {
|
||||
rating.Level = domain.MaxAccountRatingLevel
|
||||
}
|
||||
if rating.CurrentLevelStars < 0 {
|
||||
rating.CurrentLevelStars = 0
|
||||
}
|
||||
if rating.StarsComponent < 0 {
|
||||
rating.StarsComponent = 0
|
||||
}
|
||||
if rating.ActivityComponent < 0 {
|
||||
rating.ActivityComponent = 0
|
||||
}
|
||||
if rating.PenaltyComponent < 0 {
|
||||
rating.PenaltyComponent = 0
|
||||
}
|
||||
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
|
||||
rating.HasNextLevel = false
|
||||
rating.NextLevelStars = 0
|
||||
}
|
||||
// The pending delta and its date only exist together.
|
||||
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
|
||||
rating.PendingStars = 0
|
||||
rating.PendingDate = time.Time{}
|
||||
}
|
||||
return rating
|
||||
}
|
||||
|
||||
// accountRatingLess is the leaderboard order: highest level first, then the
|
||||
// larger score, then the lower user id as a stable tiebreak.
|
||||
func accountRatingLess(a, b domain.AccountRating) bool {
|
||||
if a.Level != b.Level {
|
||||
return a.Level > b.Level
|
||||
}
|
||||
if a.Stars != b.Stars {
|
||||
return a.Stars > b.Stars
|
||||
}
|
||||
return a.UserID < b.UserID
|
||||
}
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
|
||||
|
||||
// accountRatingFixture builds a projection the table's CHECK constraints accept.
|
||||
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
|
||||
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
|
||||
return domain.AccountRating{
|
||||
UserID: userID,
|
||||
Level: level,
|
||||
Stars: stars,
|
||||
CurrentLevelStars: current,
|
||||
NextLevelStars: next,
|
||||
HasNextLevel: hasNext,
|
||||
StarsComponent: stars,
|
||||
ComputedAt: computedAt,
|
||||
UpdatedAt: computedAt,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAccountRating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
seed []domain.AccountRating
|
||||
input domain.AccountRating
|
||||
wantErr error
|
||||
wantChanged bool
|
||||
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
|
||||
}{
|
||||
{
|
||||
name: "insert",
|
||||
input: accountRatingFixture(11, 450, 1, now),
|
||||
wantChanged: true,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
|
||||
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
read, err := s.AccountRating(ctx, 11)
|
||||
if err != nil || read != stored {
|
||||
t.Fatalf("read=%+v err=%v", read, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successor version applied",
|
||||
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
||||
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
|
||||
wantChanged: true,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replayed version is stale",
|
||||
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
||||
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
|
||||
wantChanged: false,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
// The loser of the race gets the current row back, untouched.
|
||||
if stored.Stars != 450 || stored.Version != 1 {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
read, err := s.AccountRating(ctx, 11)
|
||||
if err != nil || read.Stars != 450 || read.Version != 1 {
|
||||
t.Fatalf("read=%+v err=%v", read, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version from the future is rejected",
|
||||
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
||||
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
|
||||
wantChanged: false,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored.Stars != 450 || stored.Version != 1 {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insert must carry version one",
|
||||
input: accountRatingFixture(11, 450, 3, now),
|
||||
wantChanged: false,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored != (domain.AccountRating{}) {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("row was written: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no user",
|
||||
input: accountRatingFixture(0, 450, 1, now),
|
||||
wantErr: domain.ErrAccountRatingNotFound,
|
||||
},
|
||||
{
|
||||
name: "pending delta without a date is dropped",
|
||||
input: func() domain.AccountRating {
|
||||
rating := accountRatingFixture(11, 450, 1, now)
|
||||
rating.PendingStars = 120
|
||||
return rating
|
||||
}(),
|
||||
wantChanged: true,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
if _, ok := stored.PendingLevel(); ok {
|
||||
t.Fatalf("pending projection survived: %+v", stored)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending pair is kept",
|
||||
input: func() domain.AccountRating {
|
||||
rating := accountRatingFixture(11, 450, 1, now)
|
||||
rating.PendingStars = 500
|
||||
rating.PendingDate = now.Add(time.Hour)
|
||||
return rating
|
||||
}(),
|
||||
wantChanged: true,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
pending, ok := stored.PendingLevel()
|
||||
if !ok || pending.Stars != 950 || pending.Level != 3 {
|
||||
t.Fatalf("pending=%+v ok=%v", pending, ok)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "impossible components are folded",
|
||||
input: func() domain.AccountRating {
|
||||
rating := accountRatingFixture(11, 450, 1, now)
|
||||
rating.Level = -3
|
||||
rating.StarsComponent = -10
|
||||
rating.ActivityComponent = -1
|
||||
rating.PenaltyComponent = -7
|
||||
rating.CurrentLevelStars = -5
|
||||
rating.NextLevelStars = -9
|
||||
rating.HasNextLevel = true
|
||||
return rating
|
||||
}(),
|
||||
wantChanged: true,
|
||||
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
||||
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
|
||||
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
if stored.HasNextLevel || stored.NextLevelStars != 0 {
|
||||
t.Fatalf("next level survived: %+v", stored)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewAccountRatingStore()
|
||||
for _, seed := range tc.seed {
|
||||
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
|
||||
t.Fatalf("seed changed=%v err=%v", changed, err)
|
||||
}
|
||||
}
|
||||
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("err=%v want %v", err, tc.wantErr)
|
||||
}
|
||||
if changed != tc.wantChanged {
|
||||
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
|
||||
}
|
||||
if tc.wantErr != nil {
|
||||
return
|
||||
}
|
||||
if tc.check != nil {
|
||||
tc.check(t, s, stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
s := NewAccountRatingStore()
|
||||
for _, rating := range []domain.AccountRating{
|
||||
accountRatingFixture(11, 2500, 1, now),
|
||||
accountRatingFixture(12, 450, 1, now),
|
||||
accountRatingFixture(13, 2500, 1, now),
|
||||
accountRatingFixture(14, 0, 1, now),
|
||||
} {
|
||||
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
|
||||
if err != nil || len(batch) != 2 {
|
||||
t.Fatalf("batch=%+v err=%v", batch, err)
|
||||
}
|
||||
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
|
||||
t.Fatalf("batch=%+v", batch)
|
||||
}
|
||||
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
|
||||
t.Fatalf("empty batch=%+v err=%v", empty, err)
|
||||
}
|
||||
|
||||
// level desc, stars desc, user id asc.
|
||||
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
|
||||
if err != nil || len(board) != 4 {
|
||||
t.Fatalf("board=%+v err=%v", board, err)
|
||||
}
|
||||
want := []int64{11, 13, 12, 14}
|
||||
for i, userID := range want {
|
||||
if board[i].UserID != userID {
|
||||
t.Fatalf("board order=%+v want %v", board, want)
|
||||
}
|
||||
}
|
||||
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
|
||||
if err != nil || len(page) != 2 || page[1].UserID != 13 {
|
||||
t.Fatalf("page=%+v err=%v", page, err)
|
||||
}
|
||||
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
||||
BeforeID: page[len(page)-1].UserID, Limit: 2,
|
||||
})
|
||||
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
|
||||
t.Fatalf("next=%+v err=%v", next, err)
|
||||
}
|
||||
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
|
||||
if err != nil || len(filtered) != 2 {
|
||||
t.Fatalf("filtered=%+v err=%v", filtered, err)
|
||||
}
|
||||
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
|
||||
if err != nil || len(single) != 1 || single[0].UserID != 12 {
|
||||
t.Fatalf("single=%+v err=%v", single, err)
|
||||
}
|
||||
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("unknown user err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewAccountRatingStore()
|
||||
req := domain.AdjustAccountRatingRequest{
|
||||
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
|
||||
}
|
||||
|
||||
event, applied, err := s.AdjustAccountRating(ctx, req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
|
||||
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
|
||||
t.Fatalf("event=%+v", event)
|
||||
}
|
||||
|
||||
// Replaying the command key returns the recorded row and appends nothing.
|
||||
replay, applied, err := s.AdjustAccountRating(ctx, req)
|
||||
if err != nil || applied {
|
||||
t.Fatalf("replay applied=%v err=%v", applied, err)
|
||||
}
|
||||
if replay != event {
|
||||
t.Fatalf("replay=%+v want %+v", replay, event)
|
||||
}
|
||||
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
|
||||
if err != nil || len(ledger) != 1 {
|
||||
t.Fatalf("ledger=%+v err=%v", ledger, err)
|
||||
}
|
||||
|
||||
second := req
|
||||
second.Amount = -200
|
||||
second.CommandKey = "cmd-adjust-2"
|
||||
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
|
||||
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
// Newest first, and other users are not mixed in.
|
||||
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: 12, Amount: 5, CommandKey: "cmd-other",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("other user applied=%v err=%v", applied, err)
|
||||
}
|
||||
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
|
||||
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
|
||||
t.Fatalf("ledger=%+v err=%v", ledger, err)
|
||||
}
|
||||
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
|
||||
capped[0].Amount != -200 {
|
||||
t.Fatalf("capped=%+v err=%v", capped, err)
|
||||
}
|
||||
|
||||
// An unkeyed adjustment is always appended.
|
||||
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: 11, Amount: 10,
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
|
||||
}
|
||||
|
||||
for _, invalid := range []domain.AdjustAccountRatingRequest{
|
||||
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
|
||||
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
|
||||
} {
|
||||
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
|
||||
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The manual total is carried out of the ledger into the signal snapshot.
|
||||
signals, err := s.AccountRatingSignals(ctx, 11)
|
||||
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
|
||||
t.Fatalf("signals=%+v err=%v", signals, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountRatingSignals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewAccountRatingStore()
|
||||
|
||||
// A user with no contributions reports zeros rather than an error.
|
||||
signals, err := s.AccountRatingSignals(ctx, 11)
|
||||
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
|
||||
t.Fatalf("signals=%+v err=%v", signals, err)
|
||||
}
|
||||
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("missing user err=%v", err)
|
||||
}
|
||||
|
||||
s.setAccountRatingSignals(domain.AccountRatingSignals{
|
||||
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
|
||||
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
|
||||
})
|
||||
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
signals, err = s.AccountRatingSignals(ctx, 11)
|
||||
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
|
||||
signals.ModerationCases != 1 || signals.Manual != 250 {
|
||||
t.Fatalf("signals=%+v err=%v", signals, err)
|
||||
}
|
||||
|
||||
// The snapshot feeds the domain formula, and the result round-trips.
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
stored, changed, err := s.SaveAccountRating(ctx, computed)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
||||
stored.ManualComponent != 250 {
|
||||
t.Fatalf("stored=%+v computed=%+v", stored, computed)
|
||||
}
|
||||
|
||||
// A recompute uses the pending resolution the domain owns.
|
||||
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
|
||||
signals, err = s.AccountRatingSignals(ctx, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recomputed := domain.ResolveAccountRatingPending(stored,
|
||||
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
|
||||
24*time.Hour, now.Add(time.Hour))
|
||||
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
|
||||
if err != nil || !changed || saved.Version != stored.Version+1 {
|
||||
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
|
||||
}
|
||||
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
|
||||
t.Fatalf("pending was not parked: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleAccountRatings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
s := NewAccountRatingStore()
|
||||
for _, rating := range []domain.AccountRating{
|
||||
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
|
||||
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
|
||||
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
|
||||
accountRatingFixture(14, 100, 1, now),
|
||||
} {
|
||||
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
|
||||
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
|
||||
t.Fatalf("stale=%v err=%v", stale, err)
|
||||
}
|
||||
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
|
||||
len(limited) != 1 || limited[0] != 11 {
|
||||
t.Fatalf("limited=%v err=%v", limited, err)
|
||||
}
|
||||
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
|
||||
t.Fatalf("none=%v err=%v", none, err)
|
||||
}
|
||||
// A recompute refreshes computed_at and takes the row out of the horizon.
|
||||
refreshed := accountRatingFixture(11, 100, 2, now)
|
||||
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
|
||||
t.Fatalf("refresh changed=%v err=%v", changed, err)
|
||||
}
|
||||
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
|
||||
if err != nil || len(stale) != 1 || stale[0] != 12 {
|
||||
t.Fatalf("stale=%v err=%v", stale, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,42 +26,6 @@ func (s *ChannelStore) AppendCallServiceMessage(_ context.Context, channelID, se
|
|||
return s.appendServiceMessageLocked(channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions,不进入频道消息历史。
|
||||
func (s *ChannelStore) AppendStarGiftAdminLog(_ context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.channels[channelID]
|
||||
if !ok || ch.Deleted {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
messageID := int(savedID)
|
||||
if savedID > int64(domain.MaxMessageBoxID) {
|
||||
messageID = domain.MaxMessageBoxID
|
||||
}
|
||||
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: ch.Broadcast,
|
||||
Action: &action,
|
||||
Pts: ch.Pts,
|
||||
}
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: senderUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogSendMessage,
|
||||
Message: &msg,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) appendServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -69,14 +69,6 @@ func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelM
|
|||
v := *in.Hidden
|
||||
out.Hidden = &v
|
||||
}
|
||||
if in.StarGift != nil {
|
||||
g := *in.StarGift
|
||||
if in.StarGift.Sticker != nil {
|
||||
sticker := *in.StarGift.Sticker
|
||||
g.Sticker = &sticker
|
||||
}
|
||||
out.StarGift = &g
|
||||
}
|
||||
if in.SuggestedPostPrice != nil {
|
||||
price := *in.SuggestedPostPrice
|
||||
out.SuggestedPostPrice = &price
|
||||
|
|
|
|||
|
|
@ -323,13 +323,6 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla
|
|||
Duplicate: true,
|
||||
ReplayDeleteEvent: replayDelete,
|
||||
}
|
||||
if first.PaidMessageStars > 0 {
|
||||
balance, ok := s.starsBalances[first.SenderUserID]
|
||||
if !ok {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory paid-message replay has no sender balance")
|
||||
}
|
||||
result.SenderStarsBalance = &domain.StarsBalance{UserID: first.SenderUserID, Balance: balance, Granted: true}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +361,6 @@ func (s *ChannelStore) nextChannelMessageIDLocked(channelID int64) int {
|
|||
func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) {
|
||||
channel := s.channels[channelID]
|
||||
msgID := s.nextChannelMessageIDLocked(channelID)
|
||||
action = channelServiceActionForMessage(channelID, msgID, action)
|
||||
pts := s.nextChannelPtsLocked(channelID)
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: channelID,
|
||||
|
|
@ -395,20 +387,6 @@ func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID
|
|||
return msg, event
|
||||
}
|
||||
|
||||
func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction {
|
||||
if action.Type == domain.ChannelActionStarGift && action.StarGift != nil {
|
||||
g := *action.StarGift
|
||||
if g.PeerChannelID == 0 {
|
||||
g.PeerChannelID = channelID
|
||||
}
|
||||
if g.SavedID == 0 {
|
||||
g.SavedID = int64(msgID)
|
||||
}
|
||||
action.StarGift = &g
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool {
|
||||
return canSendChannelMessageWithBoost(channel, member, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
var fingerprint []byte
|
||||
var err error
|
||||
if req.RandomID != 0 {
|
||||
|
|
@ -73,27 +70,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
// telesrv has no Stars economy: Direct Messages are always free, so no
|
||||
// balance is ever checked or debited here regardless of any stale
|
||||
// per-channel price.
|
||||
paidMessageStars := int64(0)
|
||||
balanceAfter := int64(0)
|
||||
if !isAdmin && channel.SendPaidMessagesStars > 0 {
|
||||
if channel.SendPaidMessagesStars != parent.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
|
||||
}
|
||||
current, ok := s.starsBalances[req.SenderUserID]
|
||||
if !ok {
|
||||
current = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
if current < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
paidMessageStars = channel.SendPaidMessagesStars
|
||||
balanceAfter = current - paidMessageStars
|
||||
senderBalance = &domain.StarsBalance{UserID: req.SenderUserID, Balance: balanceAfter, Granted: true}
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
if isAdmin {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
|
|
@ -143,10 +123,6 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
SenderUserID: req.SenderUserID,
|
||||
}
|
||||
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
|
||||
if paidMessageStars > 0 {
|
||||
s.starsBalances[req.SenderUserID] = balanceAfter
|
||||
s.channelStarsBalances[parent.ID] += paidMessageStars * paidMessageChannelCommissionPermille / 1000
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
|
||||
s.sendSnapshots[replayKey] = sendSnapshot
|
||||
|
|
@ -162,7 +138,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
recipients = append(recipients, userID)
|
||||
}
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0)}, nil
|
||||
}
|
||||
|
||||
// findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。
|
||||
|
|
|
|||
|
|
@ -315,73 +315,3 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPaidMonoforumMessageLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Paid DM", Broadcast: true, Date: 1_700_002_000})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable paid DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
baseMessages := len(store.messages[monoID])
|
||||
|
||||
low := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3001, Message: "too low", AllowPaidStars: 9, Date: 1_700_002_001}
|
||||
var required *domain.StarsPaymentRequiredError
|
||||
if _, err := store.SendMonoforumMessage(ctx, low); !errors.As(err, &required) || required.Stars != 10 {
|
||||
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
|
||||
}
|
||||
if len(store.messages[monoID]) != baseMessages {
|
||||
t.Fatalf("low authorization wrote a message")
|
||||
}
|
||||
|
||||
store.starsBalances[42] = 25
|
||||
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3002, Message: "paid", AllowPaidStars: 99, Date: 1_700_002_002}
|
||||
paid, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid send: %v", err)
|
||||
}
|
||||
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("ledger sender/channel = %d/%d, want 15/8", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
duplicate, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid replay: %v", err)
|
||||
}
|
||||
if !duplicate.Duplicate || duplicate.Message.ID != paid.Message.ID || duplicate.SenderStarsBalance == nil || duplicate.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid replay = %+v, want original message and balance 15", duplicate)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("paid replay double charged: sender/channel=%d/%d", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
admin, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 3003, Message: "free admin reply", AllowPaidStars: 100, Date: 1_700_002_003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("admin reply charged: message=%+v balance=%+v channel=%d", admin.Message, admin.SenderStarsBalance, store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
store.starsBalances[99] = 5
|
||||
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 3004, Message: "insufficient", AllowPaidStars: 10, Date: 1_700_002_004,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
if store.starsBalances[99] != 5 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("insufficient send mutated ledger: sender/channel=%d/%d", store.starsBalances[99], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,100 +168,6 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
|
|||
}, nil
|
||||
}
|
||||
|
||||
type memoryPaidReaction struct {
|
||||
stars int64
|
||||
anonymous bool
|
||||
date int
|
||||
}
|
||||
|
||||
// AddChannelMessagePaidReaction 累计 viewer 对一条广播频道消息的付费 reaction 星数(内存镜像)。
|
||||
func (s *ChannelStore) AddChannelMessagePaidReaction(_ context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, err
|
||||
}
|
||||
if !channel.Broadcast || channel.Megagroup {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrReactionInvalid
|
||||
}
|
||||
msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if s.paidReactions[req.ChannelID] == nil {
|
||||
s.paidReactions[req.ChannelID] = make(map[int]map[int64]memoryPaidReaction)
|
||||
}
|
||||
if s.paidReactions[req.ChannelID][req.MessageID] == nil {
|
||||
s.paidReactions[req.ChannelID][req.MessageID] = make(map[int64]memoryPaidReaction)
|
||||
}
|
||||
prev := s.paidReactions[req.ChannelID][req.MessageID][req.UserID]
|
||||
s.paidReactions[req.ChannelID][req.MessageID][req.UserID] = memoryPaidReaction{
|
||||
stars: prev.stars + req.Stars,
|
||||
anonymous: req.Anonymous,
|
||||
date: req.Date,
|
||||
}
|
||||
paid := s.aggregatePaidReactionsLocked(req.ChannelID, req.MessageID, req.UserID)
|
||||
outMsg := cloneChannelMessage(msg)
|
||||
reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID)
|
||||
outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
return domain.ChannelMessagePaidReactionResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Message: outMsg,
|
||||
Paid: paid,
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) aggregatePaidReactionsLocked(channelID int64, messageID int, viewerUserID int64) domain.ChannelMessagePaidReactions {
|
||||
byUser := s.paidReactions[channelID][messageID]
|
||||
reactors := make([]domain.PaidReactor, 0, len(byUser))
|
||||
var out domain.ChannelMessagePaidReactions
|
||||
for userID, entry := range byUser {
|
||||
r := domain.PaidReactor{UserID: userID, Stars: entry.stars, Anonymous: entry.anonymous, My: userID == viewerUserID}
|
||||
out.TotalStars += entry.stars
|
||||
if r.My {
|
||||
out.MyStars = entry.stars
|
||||
out.MyAnonymous = entry.anonymous
|
||||
}
|
||||
reactors = append(reactors, r)
|
||||
}
|
||||
sort.Slice(reactors, func(i, j int) bool {
|
||||
if reactors[i].Stars != reactors[j].Stars {
|
||||
return reactors[i].Stars > reactors[j].Stars
|
||||
}
|
||||
return reactors[i].UserID < reactors[j].UserID
|
||||
})
|
||||
myInTop := false
|
||||
for i, r := range reactors {
|
||||
if i >= domain.MaxPaidReactionTopReactors {
|
||||
break
|
||||
}
|
||||
out.TopReactors = append(out.TopReactors, r)
|
||||
if r.My {
|
||||
myInTop = true
|
||||
}
|
||||
}
|
||||
if out.MyStars > 0 && !myInTop {
|
||||
for _, r := range reactors {
|
||||
if r.My {
|
||||
out.TopReactors = append(out.TopReactors, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteChannelParticipantReaction(_ context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 {
|
||||
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
|
||||
|
|
@ -867,10 +773,6 @@ func (s *ChannelStore) channelMessageReactionsLocked(viewerUserID int64, channel
|
|||
Results: []domain.ChannelMessageReactionCount{},
|
||||
Recent: []domain.ChannelMessagePeerReaction{},
|
||||
}
|
||||
// 付费 reaction 与普通 reaction 分表:即便无普通 reaction 也要回显 ReactionPaid。
|
||||
if paid := s.aggregatePaidReactionsLocked(channel.ID, messageID, viewerUserID); paid.TotalStars > 0 {
|
||||
out.Paid = &paid
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return out
|
||||
}
|
||||
|
|
@ -1012,11 +914,6 @@ func cloneChannelMessageReactionsPtr(in *domain.ChannelMessageReactions) *domain
|
|||
func cloneChannelMessageReactions(in domain.ChannelMessageReactions) domain.ChannelMessageReactions {
|
||||
in.Results = append([]domain.ChannelMessageReactionCount(nil), in.Results...)
|
||||
in.Recent = cloneChannelPeerReactions(in.Recent)
|
||||
if in.Paid != nil {
|
||||
paid := *in.Paid
|
||||
paid.TopReactors = append([]domain.PaidReactor(nil), in.Paid.TopReactors...)
|
||||
in.Paid = &paid
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,24 +63,22 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
|
|||
|
||||
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
|
||||
type ChannelStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
// historyClearDates is the no-PTS recovery timestamp for a future
|
||||
// owner-local clear, keyed by channel then user. The member remains the
|
||||
// absolute boundary authority; this map only makes account difference
|
||||
|
|
@ -134,7 +132,6 @@ func NewChannelStore() *ChannelStore {
|
|||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
|
|
|
|||
|
|
@ -117,26 +117,9 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
|
|||
return base, nil
|
||||
}
|
||||
|
||||
starsBalance, tonBalance, enough := s.reserveSuggestedPostPaymentLocked(original.SavedPeer.ID, parent.ID, price)
|
||||
if !enough {
|
||||
if exists {
|
||||
out := cloneSuggestedPostResult(approval.lastResult)
|
||||
out.PayerStarsBalance, out.PayerTONBalance = starsBalance, tonBalance
|
||||
out.Duplicate = true
|
||||
return out, nil
|
||||
}
|
||||
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
|
||||
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
|
||||
})
|
||||
base.Monoforum = cloneChannel(s.channels[mono.ID])
|
||||
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
|
||||
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
|
||||
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: scheduleDate, lastResult: cloneSuggestedPostResult(base)}
|
||||
s.suggestedPostApprovals[key] = approval
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// telesrv has no Stars economy: a suggested post is approved for free
|
||||
// regardless of any price attached to it, so the balance-check/collect
|
||||
// step and its "balance too low" retry state are skipped entirely.
|
||||
original.SuggestedPost.Accepted = true
|
||||
original.SuggestedPost.Rejected = false
|
||||
effectivePublishDate := scheduleDate
|
||||
|
|
@ -150,19 +133,16 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
|
|||
})
|
||||
base.Monoforum, base.OriginalMessage, base.OriginalEvent = cloneChannel(s.channels[mono.ID]), cloneChannelMessage(original), cloneChannelEvent(edit)
|
||||
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
|
||||
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
|
||||
base.State = domain.SuggestedPostStateScheduled
|
||||
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: effectivePublishDate}
|
||||
if effectivePublishDate <= req.Date {
|
||||
published := s.publishSuggestedPostLocked(parent, original, req.UserID, req.Date)
|
||||
base.Published = &published
|
||||
approval.publishedMessageID = published.Message.ID
|
||||
if price == nil {
|
||||
base.State = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
base.State = domain.SuggestedPostStatePublished
|
||||
approval.settlementDue = req.Date + suggestedPostSettlementAge
|
||||
}
|
||||
// telesrv has no Stars economy: nothing was ever charged, so a
|
||||
// published post goes straight to Completed -- there is no
|
||||
// settlement window regardless of any attached price.
|
||||
base.State = domain.SuggestedPostStateCompleted
|
||||
approval.state = base.State
|
||||
}
|
||||
approval.lastResult = cloneSuggestedPostResult(base)
|
||||
|
|
@ -212,55 +192,21 @@ func (s *ChannelStore) ProcessSuggestedPostLifecycle(_ context.Context, req doma
|
|||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: cloneChannel(mono), Parent: cloneChannel(parent), SavedPeer: approval.savedPeer, State: approval.state, Recipients: s.monoforumRecipientsLocked(parent.ID, approval.savedPeer.ID)}
|
||||
changed := false
|
||||
// telesrv has no Stars economy: nothing was ever charged, so a deleted
|
||||
// scheduled post is simply dropped (Refunded is the closest existing
|
||||
// terminal state, reused here so downstream event handling stays
|
||||
// uniform) and a published post is Completed immediately -- there is
|
||||
// no settlement window and no refund path to run.
|
||||
if approval.state == domain.SuggestedPostStateScheduled && original.Deleted {
|
||||
if approval.price != nil {
|
||||
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
}
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
}
|
||||
if approval.state == domain.SuggestedPostStateScheduled && approval.scheduleDate <= req.Now {
|
||||
published := s.publishSuggestedPostLocked(parent, original, approval.actorUserID, req.Now)
|
||||
result.Published = &published
|
||||
approval.publishedMessageID = published.Message.ID
|
||||
if approval.price == nil {
|
||||
approval.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
approval.state = domain.SuggestedPostStatePublished
|
||||
approval.settlementDue = req.Now + suggestedPostSettlementAge
|
||||
}
|
||||
approval.state = domain.SuggestedPostStateCompleted
|
||||
result.State, changed = approval.state, true
|
||||
}
|
||||
if approval.state == domain.SuggestedPostStatePublished {
|
||||
if approval.price == nil || approval.publishedMessageID <= 0 || approval.settlementDue <= 0 {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: incomplete published state %d/%d", mono.ID, key.messageID)
|
||||
}
|
||||
deleted := false
|
||||
publishedFound := false
|
||||
for _, message := range s.messages[parent.ID] {
|
||||
if message.ID == approval.publishedMessageID {
|
||||
deleted = message.Deleted
|
||||
publishedFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !publishedFound {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: missing published message %d/%d", parent.ID, approval.publishedMessageID)
|
||||
}
|
||||
deleteDate := s.channelMessageDeleteDateLocked(parent.ID, approval.publishedMessageID)
|
||||
if deleted && (deleteDate == 0 || deleteDate < approval.settlementDue) {
|
||||
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
} else if approval.settlementDue <= req.Now {
|
||||
s.settleSuggestedPostPaymentLocked(parent.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPostPrice(approval.price)})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateCompleted, domain.SuggestedPostStateCompleted, true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
result.Monoforum, result.Parent = cloneChannel(s.channels[mono.ID]), cloneChannel(s.channels[parent.ID])
|
||||
approval.lastResult = cloneSuggestedPostResult(result)
|
||||
|
|
@ -286,61 +232,6 @@ func (s *ChannelStore) channelMessageDeleteDateLocked(channelID int64, messageID
|
|||
return 0
|
||||
}
|
||||
|
||||
func (s *ChannelStore) reserveSuggestedPostPaymentLocked(payerID, parentID int64, price *domain.SuggestedPostPrice) (*domain.StarsBalance, *int64, bool) {
|
||||
if price == nil {
|
||||
return nil, nil, true
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
current, ok := s.starsBalances[payerID]
|
||||
if !ok {
|
||||
current = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
balance := &domain.StarsBalance{UserID: payerID, Balance: current, Granted: true}
|
||||
if price.Nanos != 0 || current < price.Amount {
|
||||
return balance, nil, false
|
||||
}
|
||||
current -= price.Amount
|
||||
s.starsBalances[payerID] = current
|
||||
balance.Balance = current
|
||||
return balance, nil, true
|
||||
case domain.SuggestedPostPriceTON:
|
||||
current := s.tonBalances[payerID]
|
||||
balance := current
|
||||
if current < price.Amount {
|
||||
return nil, &balance, false
|
||||
}
|
||||
current -= price.Amount
|
||||
s.tonBalances[payerID] = current
|
||||
balance = current
|
||||
return nil, &balance, true
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) refundSuggestedPostPaymentLocked(payerID int64, price *domain.SuggestedPostPrice) {
|
||||
if price == nil {
|
||||
return
|
||||
}
|
||||
if price.Kind == domain.SuggestedPostPriceStars {
|
||||
s.starsBalances[payerID] += price.Amount
|
||||
} else if price.Kind == domain.SuggestedPostPriceTON {
|
||||
s.tonBalances[payerID] += price.Amount
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) settleSuggestedPostPaymentLocked(parentID int64, price *domain.SuggestedPostPrice) {
|
||||
if price == nil {
|
||||
return
|
||||
}
|
||||
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
|
||||
if price.Kind == domain.SuggestedPostPriceStars {
|
||||
s.channelStarsBalances[parentID] += credit
|
||||
} else if price.Kind == domain.SuggestedPostPriceTON {
|
||||
s.channelTONBalances[parentID] += credit
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) appendSuggestedPostServiceLocked(mono, parent domain.Channel, actor int64, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) {
|
||||
pts := s.nextChannelPtsLocked(mono.ID)
|
||||
|
|
@ -406,13 +297,5 @@ func cloneSuggestedPostResult(in domain.ToggleSuggestedPostApprovalResult) domai
|
|||
p.Recipients = append([]int64(nil), p.Recipients...)
|
||||
in.Published = &p
|
||||
}
|
||||
if in.PayerStarsBalance != nil {
|
||||
b := *in.PayerStarsBalance
|
||||
in.PayerStarsBalance = &b
|
||||
}
|
||||
if in.PayerTONBalance != nil {
|
||||
b := *in.PayerTONBalance
|
||||
in.PayerTONBalance = &b
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,10 +76,9 @@ func TestMonoforumManagerRequiresManageDirectMessages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostStarsApprovalRefundAndSettlement(t *testing.T) {
|
||||
func TestSuggestedPostApprovalRefundAndSettlement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 100
|
||||
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 11, Message: "publish me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
|
|
@ -89,22 +88,22 @@ func TestSuggestedPostStarsApprovalRefundAndSettlement(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approved.State != domain.SuggestedPostStatePublished || approved.OriginalEvent.Type != domain.ChannelUpdateEditMessage || approved.ServiceMessage.Action == nil || approved.ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostApproval || approved.Published == nil {
|
||||
if approved.State != domain.SuggestedPostStateCompleted || approved.OriginalEvent.Type != domain.ChannelUpdateEditMessage || approved.ServiceMessage.Action == nil || approved.ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostApproval || approved.Published == nil {
|
||||
t.Fatalf("approval result=%+v", approved)
|
||||
}
|
||||
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
|
||||
t.Fatalf("immediate approval dates original/action=%d/%d, want commit date", approved.OriginalMessage.SuggestedPost.ScheduleDate, approved.ServiceMessage.Action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 90 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("escrow/channel balances=%d/%d, want 90/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
duplicate, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
|
||||
if err != nil || !duplicate.Duplicate || store.starsBalances[subscriber.ID] != 90 {
|
||||
t.Fatalf("duplicate=%+v err=%v balance=%d", duplicate, err, store.starsBalances[subscriber.ID])
|
||||
if err != nil || !duplicate.Duplicate {
|
||||
t.Fatalf("duplicate=%+v err=%v", duplicate, err)
|
||||
}
|
||||
if duplicate.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || duplicate.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
|
||||
t.Fatalf("duplicate changed immediate approval date: %+v", duplicate)
|
||||
}
|
||||
// An immediate approval is already terminal (Completed): there is nothing
|
||||
// left to settle, so deleting the published post afterwards must not
|
||||
// surface it again through the lifecycle worker.
|
||||
store.mu.Lock()
|
||||
for i := range store.messages[parent.ID] {
|
||||
if store.messages[parent.ID][i].ID == approved.Published.Message.ID {
|
||||
|
|
@ -112,54 +111,59 @@ func TestSuggestedPostStarsApprovalRefundAndSettlement(t *testing.T) {
|
|||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
lifecycle, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
|
||||
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateRefunded || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
|
||||
t.Fatalf("refund lifecycle=%+v err=%v", lifecycle, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 100 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("refund balances=%d/%d, want 100/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
if lifecycle, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10}); err != nil || len(lifecycle) != 0 {
|
||||
t.Fatalf("already-completed post must not be revisited: lifecycle=%+v err=%v", lifecycle, err)
|
||||
}
|
||||
|
||||
second, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 12, Message: "settle me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 20}}, Date: 1_700_000_400})
|
||||
// A scheduled (not-yet-due) approval still refunds via the lifecycle
|
||||
// worker if the suggestion is deleted before its publish date.
|
||||
second, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 12, Message: "cancel scheduled", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 20}}, Date: 1_700_000_400})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settling, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: second.Message.ID, Date: 1_700_000_500})
|
||||
if err != nil || settling.State != domain.SuggestedPostStatePublished {
|
||||
t.Fatalf("second approval=%+v err=%v", settling, err)
|
||||
scheduled, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: second.Message.ID, ScheduleDate: 1_700_000_900, Date: 1_700_000_401})
|
||||
if err != nil || scheduled.State != domain.SuggestedPostStateScheduled {
|
||||
t.Fatalf("scheduled approval=%+v err=%v", scheduled, err)
|
||||
}
|
||||
lifecycle, err = store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_500 + suggestedPostSettlementAge, Limit: 10})
|
||||
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateCompleted || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
|
||||
t.Fatalf("success lifecycle=%+v err=%v", lifecycle, err)
|
||||
store.mu.Lock()
|
||||
for i := range store.messages[mono.ID] {
|
||||
if store.messages[mono.ID][i].ID == second.Message.ID {
|
||||
store.messages[mono.ID][i].Deleted = true
|
||||
}
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 80 || store.channelStarsBalances[parent.ID] != 17 {
|
||||
t.Fatalf("settled balances=%d/%d, want 80/17", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
store.mu.Unlock()
|
||||
lifecycle, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_500, Limit: 10})
|
||||
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateRefunded {
|
||||
t.Fatalf("refund lifecycle=%+v err=%v", lifecycle, err)
|
||||
}
|
||||
|
||||
third, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 13, Message: "settle me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 20}}, Date: 1_700_000_600})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settling, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: third.Message.ID, Date: 1_700_000_700})
|
||||
if err != nil || settling.State != domain.SuggestedPostStateCompleted {
|
||||
t.Fatalf("third approval=%+v err=%v", settling, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
|
||||
func TestSuggestedPostScheduleRetryAndRoleMatrix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 5
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 21, Message: "later", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_001_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
low, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_000})
|
||||
if err != nil || low.State != domain.SuggestedPostStateBalanceLow || low.ServiceMessage.Action == nil || !low.ServiceMessage.Action.SuggestedPostBalanceTooLow {
|
||||
t.Fatalf("low=%+v err=%v", low, err)
|
||||
}
|
||||
again, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_001})
|
||||
if err != nil || !again.Duplicate {
|
||||
t.Fatalf("low retry=%+v err=%v", again, err)
|
||||
}
|
||||
store.starsBalances[subscriber.ID] = 20
|
||||
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_050})
|
||||
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
|
||||
t.Fatalf("scheduled=%+v err=%v", accepted, err)
|
||||
}
|
||||
again, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_051})
|
||||
if err != nil || !again.Duplicate {
|
||||
t.Fatalf("schedule retry=%+v err=%v", again, err)
|
||||
}
|
||||
due, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_001_400, Limit: 10})
|
||||
if err != nil || len(due) != 1 || due[0].Published == nil || due[0].State != domain.SuggestedPostStatePublished {
|
||||
if err != nil || len(due) != 1 || due[0].Published == nil || due[0].State != domain.SuggestedPostStateCompleted {
|
||||
t.Fatalf("due=%+v err=%v", due, err)
|
||||
}
|
||||
|
||||
|
|
@ -277,8 +281,7 @@ func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
|
|||
|
||||
func TestScheduledSuggestedPostDeletionRefundsBeforePublication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 30
|
||||
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 41, Message: "cancel scheduled", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_004_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -298,9 +301,6 @@ func TestScheduledSuggestedPostDeletionRefundsBeforePublication(t *testing.T) {
|
|||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].Published != nil {
|
||||
t.Fatalf("resolved=%+v err=%v", resolved, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 30 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("balances=%d/%d", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostLifecycleFailsFastOnCorruptAcceptedState(t *testing.T) {
|
||||
|
|
@ -332,28 +332,9 @@ func TestSuggestedPostLifecycleFailsFastOnCorruptAcceptedState(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostDeletedAfterMinimumAgeStillSettles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 30
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 51, Message: "late delete", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_005_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvedAt := 1_700_005_100
|
||||
approved, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: approvedAt})
|
||||
if err != nil || approved.Published == nil {
|
||||
t.Fatalf("approved=%+v err=%v", approved, err)
|
||||
}
|
||||
due := approvedAt + suggestedPostSettlementAge
|
||||
if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: 1, ChannelID: parent.ID, IDs: []int{approved.Published.Message.ID}, Date: due + 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
|
||||
t.Fatalf("resolved=%+v err=%v", resolved, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 20 || store.channelStarsBalances[parent.ID] != 8 {
|
||||
t.Fatalf("balances=%d/%d, want 20/8", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
}
|
||||
// An immediately-approved suggested post is terminal (Completed) the moment
|
||||
// it is published -- there is no settlement window anymore (telesrv has no
|
||||
// Stars economy, so nothing is ever collected that would need settling).
|
||||
// Deleting the published post afterwards is therefore a no-op for the
|
||||
// suggested-post lifecycle; see TestSuggestedPostApprovalRefundAndSettlement
|
||||
// for that assertion.
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func seedBroadcastPost(t *testing.T, st *ChannelStore, creator int64, broadcast bool) (channelID int64, msgID int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
created, err := st.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: creator,
|
||||
Title: "Paid",
|
||||
Broadcast: broadcast,
|
||||
Megagroup: !broadcast,
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
sent, err := st.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: creator,
|
||||
ChannelID: created.Channel.ID,
|
||||
Message: "post",
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
return created.Channel.ID, sent.Message.ID
|
||||
}
|
||||
|
||||
// 付费 reaction 累计 + 聚合:同一 reactor 多次增投累加,TopReactors 含本人带 My。
|
||||
func TestAddChannelMessagePaidReactionAccumulates(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, true)
|
||||
|
||||
res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 100, Date: 1700000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 100 || res.Paid.MyStars != 100 {
|
||||
t.Fatalf("after 100 = total %d my %d, want 100/100", res.Paid.TotalStars, res.Paid.MyStars)
|
||||
}
|
||||
|
||||
res, err = st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 50, Date: 1700000002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 150 || res.Paid.MyStars != 150 {
|
||||
t.Fatalf("after +50 = total %d my %d, want 150/150 (accumulated)", res.Paid.TotalStars, res.Paid.MyStars)
|
||||
}
|
||||
if len(res.Paid.TopReactors) != 1 || res.Paid.TopReactors[0].Stars != 150 || !res.Paid.TopReactors[0].My {
|
||||
t.Fatalf("top reactors = %+v, want one My 150", res.Paid.TopReactors)
|
||||
}
|
||||
}
|
||||
|
||||
// 多 reactor:TopReactors 按星数降序,本人始终在列。
|
||||
func TestAddChannelMessagePaidReactionTopReactors(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, true)
|
||||
// 让另外两个用户成为成员并增投(直接写 store 累计,绕过成员校验仅测聚合)。
|
||||
for _, c := range []struct {
|
||||
user int64
|
||||
stars int64
|
||||
}{{creator, 30}, {2000000002, 200}, {2000000003, 80}} {
|
||||
// 仅 creator 经正式路径;其他用户直接累计以构造排行。
|
||||
if c.user == creator {
|
||||
if _, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: c.user, ChannelID: channelID, MessageID: msgID, Stars: c.stars, Date: 1700000010,
|
||||
}); err != nil {
|
||||
t.Fatalf("creator paid reaction: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.paidReactions[channelID][msgID][c.user] = memoryPaidReaction{stars: c.stars, date: 1700000010}
|
||||
st.mu.Unlock()
|
||||
}
|
||||
res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 0 + 1, Date: 1700000011,
|
||||
})
|
||||
// creator 现在 31+? 重新算:creator 30 + 这次 1 = 31。
|
||||
if err != nil {
|
||||
t.Fatalf("paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 31+200+80 {
|
||||
t.Fatalf("total = %d, want %d", res.Paid.TotalStars, 31+200+80)
|
||||
}
|
||||
// 降序:200, 80, 31。
|
||||
if len(res.Paid.TopReactors) != 3 || res.Paid.TopReactors[0].Stars != 200 || res.Paid.TopReactors[1].Stars != 80 || res.Paid.TopReactors[2].Stars != 31 {
|
||||
t.Fatalf("top reactors = %+v, want 200/80/31 desc", res.Paid.TopReactors)
|
||||
}
|
||||
if !res.Paid.TopReactors[2].My {
|
||||
t.Fatalf("creator (31) must carry My flag, got %+v", res.Paid.TopReactors[2])
|
||||
}
|
||||
}
|
||||
|
||||
// 非广播频道拒绝付费 reaction。
|
||||
func TestAddChannelMessagePaidReactionRejectsMegagroup(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, false)
|
||||
_, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 10, Date: 1700000001,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrReactionInvalid) {
|
||||
t.Fatalf("megagroup paid reaction err = %v, want ErrReactionInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,938 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// StarGiftStore 是 store.StarGiftStore 的内存实现。
|
||||
type StarGiftStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
nextGiftID int64
|
||||
nextRevID int64
|
||||
gifts []domain.SavedStarGift // 追加序
|
||||
catalog map[int64]domain.StarGift
|
||||
revisions map[int64]domain.StarGift
|
||||
enabled map[int64]bool
|
||||
sortOrder map[int64]int
|
||||
animations map[int64][]byte
|
||||
collectibles map[int64]domain.StarGiftCollectibleRevision
|
||||
uniqueByID map[int64]domain.UniqueStarGift
|
||||
uniqueBySlug map[string]int64
|
||||
collections map[domain.Peer][]domain.StarGiftCollection
|
||||
nextAttributeID int64
|
||||
nextCollectionID int
|
||||
}
|
||||
|
||||
// NewStarGiftStore 创建内存 StarGiftStore。
|
||||
func NewStarGiftStore() *StarGiftStore {
|
||||
return &StarGiftStore{
|
||||
catalog: make(map[int64]domain.StarGift), revisions: make(map[int64]domain.StarGift),
|
||||
enabled: make(map[int64]bool), sortOrder: make(map[int64]int), animations: make(map[int64][]byte),
|
||||
collectibles: make(map[int64]domain.StarGiftCollectibleRevision),
|
||||
uniqueByID: make(map[int64]domain.UniqueStarGift), uniqueBySlug: make(map[string]int64),
|
||||
collections: make(map[domain.Peer][]domain.StarGiftCollection),
|
||||
}
|
||||
}
|
||||
|
||||
// SeedCatalog installs valid immutable catalog snapshots for tests.
|
||||
func (s *StarGiftStore) SeedCatalog(gifts []domain.StarGift) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, gift := range gifts {
|
||||
if gift.RevisionID == 0 {
|
||||
s.nextRevID++
|
||||
gift.RevisionID = s.nextRevID
|
||||
}
|
||||
if gift.ID > s.nextGiftID {
|
||||
s.nextGiftID = gift.ID
|
||||
}
|
||||
if gift.RevisionID > s.nextRevID {
|
||||
s.nextRevID = gift.RevisionID
|
||||
}
|
||||
s.catalog[gift.ID] = gift
|
||||
s.revisions[gift.RevisionID] = gift
|
||||
s.enabled[gift.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Catalog(_ context.Context) ([]domain.StarGift, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.StarGift, 0, len(s.catalog))
|
||||
for id, gift := range s.catalog {
|
||||
if s.enabled[id] {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if s.sortOrder[out[i].ID] == s.sortOrder[out[j].ID] {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return s.sortOrder[out[i].ID] < s.sortOrder[out[j].ID]
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogGift(_ context.Context, giftID int64) (domain.StarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gift, ok := s.catalog[giftID]
|
||||
return gift, ok && s.enabled[giftID], nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gift, ok := s.revisions[revisionID]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
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++
|
||||
giftID = s.nextGiftID
|
||||
} else if _, ok := s.catalog[giftID]; !ok {
|
||||
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,
|
||||
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
|
||||
s.sortOrder[giftID] = write.SortOrder
|
||||
s.animations[giftID] = append([]byte(nil), write.Animation.JSON...)
|
||||
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()
|
||||
if _, ok := s.catalog[giftID]; !ok {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
changed := s.enabled[giftID] != enabled
|
||||
s.enabled[giftID] = enabled
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogSortOrder(_ context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.catalog[giftID]; !ok {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
changed := s.sortOrder[giftID] != sortOrder
|
||||
s.sortOrder[giftID] = sortOrder
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) AnimationJSON(_ context.Context, giftID int64) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.animations[giftID]
|
||||
return append([]byte(nil), raw...), ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
previous := s.collectibles[write.GiftID]
|
||||
revision := domain.StarGiftCollectibleRevision{
|
||||
ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1,
|
||||
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
|
||||
}
|
||||
revision.Models = s.allocateCollectibleAttributes(write.Models, revision.ID)
|
||||
revision.Patterns = s.allocateCollectibleAttributes(write.Patterns, revision.ID)
|
||||
revision.Backdrops = s.allocateCollectibleAttributes(write.Backdrops, revision.ID)
|
||||
s.collectibles[write.GiftID] = revision
|
||||
gift := s.catalog[write.GiftID]
|
||||
gift.UpgradeStars = revision.UpgradeStars
|
||||
gift.UpgradeTotal = revision.SupplyTotal
|
||||
gift.UpgradeIssued = revision.Issued
|
||||
s.catalog[write.GiftID] = gift
|
||||
return cloneCollectibleRevision(revision), nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) allocateCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, revisionID int64) []domain.StarGiftCollectibleAttribute {
|
||||
out := make([]domain.StarGiftCollectibleAttribute, len(in))
|
||||
for i, attribute := range in {
|
||||
s.nextAttributeID++
|
||||
attribute.ID = s.nextAttributeID
|
||||
attribute.CollectibleRevisionID = revisionID
|
||||
out[i] = cloneCollectibleAttribute(attribute)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
return cloneCollectibleRevision(revision), ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleProjection(_ context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, false, nil
|
||||
}
|
||||
projection := cloneCollectibleRevision(revision)
|
||||
projection.Models = projectCollectibleAttributes(projection.Models, domain.StarGiftCollectibleModel, samplePerKind)
|
||||
projection.Patterns = projectCollectibleAttributes(projection.Patterns, domain.StarGiftCollectiblePattern, samplePerKind)
|
||||
projection.Backdrops = projectCollectibleAttributes(projection.Backdrops, domain.StarGiftCollectibleBackdrop, samplePerKind)
|
||||
return projection, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
||||
for _, giftID := range giftIDs {
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok || !revision.Published {
|
||||
continue
|
||||
}
|
||||
out[giftID] = domain.StarGiftCollectibleAvailability{
|
||||
UpgradeStars: revision.UpgradeStars,
|
||||
SupplyTotal: revision.SupplyTotal,
|
||||
Issued: revision.Issued,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAnimationJSON(_ context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
var attributes []domain.StarGiftCollectibleAttribute
|
||||
switch kind {
|
||||
case domain.StarGiftCollectibleModel:
|
||||
attributes = revision.Models
|
||||
case domain.StarGiftCollectiblePattern:
|
||||
attributes = revision.Patterns
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
for _, attribute := range attributes {
|
||||
if attribute.ID == attributeID && attribute.Animation != nil {
|
||||
return append([]byte(nil), attribute.Animation.JSON...), true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(slug))]
|
||||
if !ok {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
unique, ok := s.uniqueByID[id]
|
||||
return unique, ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByID(_ context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
unique, ok := s.uniqueByID[uniqueGiftID]
|
||||
return unique, ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
|
||||
for _, id := range uniqueGiftIDs {
|
||||
if gift, ok := s.uniqueByID[id]; ok {
|
||||
out[id] = gift
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if owner.ID <= 0 || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.UniqueStarGift, 0, min(limit, len(s.uniqueByID)))
|
||||
for _, gift := range s.uniqueByID {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if !validSavedStarGift(gift) {
|
||||
return 0, domain.ErrStarGiftInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
gift.ID = s.nextID
|
||||
if gift.Owner.Type == domain.PeerTypeChannel && gift.SavedID == 0 {
|
||||
gift.SavedID = gift.ID
|
||||
}
|
||||
gift.Converted = false
|
||||
gift.LifecycleStatus = domain.StarGiftLifecycleActive
|
||||
s.gifts = append(s.gifts, gift)
|
||||
return gift.ID, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
return s.ListByOwnerFiltered(context.Background(), domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
|
||||
if !validStarGiftOwner(owner) {
|
||||
return domain.SavedStarGiftPage{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
|
||||
limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
matched := make([]domain.SavedStarGift, 0)
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner != owner || !g.LifecycleStatus.Live() {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnsaved && g.Unsaved {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeSaved && !g.Unsaved {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnique && g.UniqueGiftID != 0 {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnlimited && g.UniqueGiftID == 0 {
|
||||
continue
|
||||
}
|
||||
upgradable := false
|
||||
if g.UniqueGiftID == 0 {
|
||||
if gift, ok := s.catalog[g.GiftID]; ok {
|
||||
upgradable = gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal
|
||||
}
|
||||
}
|
||||
if filter.ExcludeUpgradable && upgradable {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnupgradable && !upgradable {
|
||||
continue
|
||||
}
|
||||
if filter.CollectionID > 0 && !containsInt(g.CollectionIDs, filter.CollectionID) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, g)
|
||||
}
|
||||
profileOrder := filter.CollectionID == 0
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
if profileOrder {
|
||||
iPinned := matched[i].PinnedOrder > 0
|
||||
jPinned := matched[j].PinnedOrder > 0
|
||||
if iPinned != jPinned {
|
||||
return iPinned
|
||||
}
|
||||
if iPinned && matched[i].PinnedOrder != matched[j].PinnedOrder {
|
||||
return matched[i].PinnedOrder < matched[j].PinnedOrder
|
||||
}
|
||||
}
|
||||
return matched[i].ID > matched[j].ID
|
||||
})
|
||||
page := domain.SavedStarGiftPage{Count: len(matched)}
|
||||
cursor, hasCursor := domain.DecodeSavedStarGiftListCursor(offset)
|
||||
out := make([]domain.SavedStarGift, 0, limit+1)
|
||||
for _, g := range matched {
|
||||
if hasCursor {
|
||||
if profileOrder {
|
||||
if cursor.PinnedOrder > 0 {
|
||||
if g.PinnedOrder > 0 && (g.PinnedOrder < cursor.PinnedOrder ||
|
||||
g.PinnedOrder == cursor.PinnedOrder && g.ID >= cursor.ID) {
|
||||
continue
|
||||
}
|
||||
} else if g.PinnedOrder > 0 || g.ID >= cursor.ID {
|
||||
continue
|
||||
}
|
||||
} else if g.ID >= cursor.ID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, g)
|
||||
if len(out) == limit+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
last := out[len(out)-1]
|
||||
pinnedOrder := 0
|
||||
if profileOrder {
|
||||
pinnedOrder = last.PinnedOrder
|
||||
}
|
||||
page.NextOffset = domain.EncodeSavedStarGiftListCursor(pinnedOrder, last.ID)
|
||||
}
|
||||
page.Gifts = out
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]int64, 0, len(refs))
|
||||
seen := make(map[int64]struct{}, len(refs))
|
||||
for _, ref := range refs {
|
||||
if ref.Owner != owner || !ref.Valid() {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
var id int64
|
||||
for _, gift := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(gift, ref) && gift.LifecycleStatus.Live() {
|
||||
id = gift.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, g := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(g, ref) {
|
||||
return g, true, nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ResolveUserMessageRef(_ context.Context, _ int64, _ int) (domain.SavedStarGiftRef, bool, error) {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
|
||||
if !validStarGiftOwner(owner) {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner == owner && g.LifecycleStatus.Live() && !g.Unsaved {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
||||
if !ref.Valid() {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
if unsaved && s.gifts[i].PinnedOrder > 0 {
|
||||
removedOrder := s.gifts[i].PinnedOrder
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for j := range s.gifts {
|
||||
if s.gifts[j].Owner == ref.Owner && s.gifts[j].PinnedOrder > removedOrder {
|
||||
s.gifts[j].PinnedOrder--
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.gifts[i].UniqueGiftID != 0 {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
if s.gifts[i].Converted {
|
||||
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] {
|
||||
collection := &s.collections[ref.Owner][collectionIndex]
|
||||
next := collection.GiftIDs[:0]
|
||||
for _, giftID := range collection.GiftIDs {
|
||||
if giftID != s.gifts[i].ID {
|
||||
next = append(next, giftID)
|
||||
}
|
||||
}
|
||||
collection.GiftIDs = next
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
}
|
||||
s.refreshCollectionMembershipsLocked(ref.Owner)
|
||||
return s.gifts[i], nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListCollections(_ context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return cloneStarGiftCollections(s.collections[owner]), nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCollection(_ context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if !validStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.collections[owner]) >= domain.MaxStarGiftCollectionsPerPeer {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionsFull
|
||||
}
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollection{}, err
|
||||
}
|
||||
s.nextCollectionID++
|
||||
collection := domain.StarGiftCollection{Owner: owner, CollectionID: s.nextCollectionID, Title: title, GiftIDs: ids, SortOrder: len(s.collections[owner])}
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
s.collections[owner] = append(s.collections[owner], collection)
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UpdateCollection(_ context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
index := -1
|
||||
for i := range collections {
|
||||
if collections[i].CollectionID == collectionID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionNotFound
|
||||
}
|
||||
collection := collections[index]
|
||||
if patch.Title != nil {
|
||||
title := strings.TrimSpace(*patch.Title)
|
||||
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
collection.Title = title
|
||||
}
|
||||
deleteSet := make(map[int64]struct{}, len(patch.DeleteIDs))
|
||||
for _, id := range patch.DeleteIDs {
|
||||
deleteSet[id] = struct{}{}
|
||||
}
|
||||
next := make([]int64, 0, len(collection.GiftIDs)+len(patch.AddIDs))
|
||||
for _, id := range collection.GiftIDs {
|
||||
if _, deleted := deleteSet[id]; !deleted {
|
||||
next = append(next, id)
|
||||
}
|
||||
}
|
||||
add, err := s.validCollectionGiftIDsLocked(owner, patch.AddIDs)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollection{}, err
|
||||
}
|
||||
next = appendUniqueInt64(next, add...)
|
||||
if patch.Order != nil {
|
||||
order, err := s.validCollectionGiftIDsLocked(owner, patch.Order)
|
||||
if err != nil || !sameInt64Set(order, next) {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
next = order
|
||||
}
|
||||
if len(next) > domain.MaxStarGiftCollectionItems {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
collection.GiftIDs = next
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
collections[index] = collection
|
||||
s.collections[owner] = collections
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) DeleteCollection(_ context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
for i := range collections {
|
||||
if collections[i].CollectionID == collectionID {
|
||||
collections = append(collections[:i], collections[i+1:]...)
|
||||
for j := range collections {
|
||||
collections[j].SortOrder = j
|
||||
}
|
||||
s.collections[owner] = collections
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
if len(collectionIDs) != len(collections) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
byID := make(map[int]domain.StarGiftCollection, len(collections))
|
||||
for _, collection := range collections {
|
||||
byID[collection.CollectionID] = collection
|
||||
}
|
||||
next := make([]domain.StarGiftCollection, 0, len(collections))
|
||||
for order, id := range collectionIDs {
|
||||
collection, ok := byID[id]
|
||||
if !ok {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
delete(byID, id)
|
||||
collection.SortOrder = order
|
||||
next = append(next, collection)
|
||||
}
|
||||
s.collections[owner] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(savedGiftIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
order := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
order[id] = i + 1
|
||||
}
|
||||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner == owner {
|
||||
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
|
||||
if s.gifts[i].PinnedOrder > 0 {
|
||||
s.gifts[i].Unsaved = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCollectionMembershipsLocked keeps the in-memory saved-gift projection
|
||||
// equivalent to the PostgreSQL join projection. Callers must hold s.mu.
|
||||
func (s *StarGiftStore) refreshCollectionMembershipsLocked(owner domain.Peer) {
|
||||
memberships := make(map[int64][]int)
|
||||
for _, collection := range s.collections[owner] {
|
||||
for _, giftID := range collection.GiftIDs {
|
||||
memberships[giftID] = append(memberships[giftID], collection.CollectionID)
|
||||
}
|
||||
}
|
||||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner != owner {
|
||||
continue
|
||||
}
|
||||
s.gifts[i].CollectionIDs = append([]int(nil), memberships[s.gifts[i].ID]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []int64) ([]int64, error) {
|
||||
if len(ids) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
valid := false
|
||||
for _, gift := range s.gifts {
|
||||
if gift.ID == id && gift.Owner == owner && gift.LifecycleStatus.Live() {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendUniqueInt64(dst []int64, values ...int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(dst)+len(values))
|
||||
for _, id := range dst {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range values {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
dst = append(dst, id)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func sameInt64Set(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int64]int, len(a))
|
||||
for _, id := range a {
|
||||
seen[id]++
|
||||
}
|
||||
for _, id := range b {
|
||||
seen[id]--
|
||||
if seen[id] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.StarGiftCollectibleAttribute {
|
||||
out := in
|
||||
if in.Document != nil {
|
||||
document := *in.Document
|
||||
out.Document = &document
|
||||
}
|
||||
if in.Animation != nil {
|
||||
animation := *in.Animation
|
||||
animation.JSON = append([]byte(nil), in.Animation.JSON...)
|
||||
animation.TGS = append([]byte(nil), in.Animation.TGS...)
|
||||
animation.SHA256 = append([]byte(nil), in.Animation.SHA256...)
|
||||
out.Animation = &animation
|
||||
}
|
||||
if in.Blob != nil {
|
||||
blob := *in.Blob
|
||||
out.Blob = &blob
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
copy[i] = cloneCollectibleAttribute(attribute)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
out.Models = clone(in.Models)
|
||||
out.Patterns = clone(in.Patterns)
|
||||
out.Backdrops = clone(in.Backdrops)
|
||||
return out
|
||||
}
|
||||
|
||||
func projectCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, kind domain.StarGiftCollectibleAttributeKind, samplePerKind int) []domain.StarGiftCollectibleAttribute {
|
||||
out := in
|
||||
if samplePerKind > 0 {
|
||||
out = make([]domain.StarGiftCollectibleAttribute, 0, len(in))
|
||||
for _, attribute := range in {
|
||||
if attribute.RarityKind != domain.StarGiftRarityPermille || attribute.RarityPermille <= 0 ||
|
||||
(kind == domain.StarGiftCollectibleModel && attribute.Crafted) {
|
||||
continue
|
||||
}
|
||||
out = append(out, attribute)
|
||||
}
|
||||
for i := 0; i < len(out) && i < samplePerKind; i++ {
|
||||
j := i + rand.IntN(len(out)-i)
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
if len(out) > samplePerKind {
|
||||
out = out[:samplePerKind]
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].Animation != nil {
|
||||
out[i].Animation.JSON = nil
|
||||
out[i].Animation.TGS = nil
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
|
||||
out := make([]domain.StarGiftCollection, len(in))
|
||||
for i, collection := range in {
|
||||
out[i] = collection
|
||||
out[i].GiftIDs = append([]int64(nil), collection.GiftIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validSavedStarGift(g domain.SavedStarGift) bool {
|
||||
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
return false
|
||||
}
|
||||
switch g.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return g.MsgID > 0 && g.SavedID == 0
|
||||
case domain.PeerTypeChannel:
|
||||
return g.MsgID == 0 && g.SavedID >= 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validStarGiftOwner(owner domain.Peer) bool {
|
||||
return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
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
|
||||
case domain.PeerTypeChannel:
|
||||
return g.SavedID == ref.SavedID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
store := NewStarGiftStore()
|
||||
ids := make([]int64, 4)
|
||||
for i := range ids {
|
||||
id, err := store.Create(ctx, domain.SavedStarGift{
|
||||
Owner: owner, GiftID: 8001, RevisionID: 9001, MsgID: 100 + i, Date: 1700000000 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create gift %d: %v", i, err)
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
|
||||
t.Fatalf("set pinned: %v", err)
|
||||
}
|
||||
|
||||
want := []int64{ids[0], ids[2], ids[3], ids[1]}
|
||||
var got []int64
|
||||
offset := ""
|
||||
for pageNumber := 0; ; pageNumber++ {
|
||||
page, err := store.ListByOwner(ctx, owner, false, offset, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("list page %d: %v", pageNumber, err)
|
||||
}
|
||||
if page.Count != len(ids) || len(page.Gifts) != 1 {
|
||||
t.Fatalf("page %d = %+v, want count=%d and one gift", pageNumber, page, len(ids))
|
||||
}
|
||||
got = append(got, page.Gifts[0].ID)
|
||||
if page.NextOffset == "" {
|
||||
break
|
||||
}
|
||||
offset = page.NextOffset
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("paged order = %v, want %v", got, want)
|
||||
}
|
||||
if ok, err := store.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}, true); err != nil || !ok {
|
||||
t.Fatalf("hide pinned gift = %v err %v", ok, err)
|
||||
}
|
||||
hidden, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || !hidden.Unsaved || hidden.PinnedOrder != 0 {
|
||||
t.Fatalf("hidden pinned gift = %+v found %v err %v", hidden, found, err)
|
||||
}
|
||||
remaining, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 102})
|
||||
if err != nil || !found || remaining.PinnedOrder != 1 {
|
||||
t.Fatalf("remaining pin = %+v found %v err %v", remaining, found, err)
|
||||
}
|
||||
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
|
||||
t.Fatalf("repin hidden gift: %v", err)
|
||||
}
|
||||
repinned, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
|
||||
t.Fatalf("repinned gift = %+v found %v err %v", repinned, found, err)
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, nil); err != nil {
|
||||
t.Fatalf("clear pinned: %v", err)
|
||||
}
|
||||
page, err := store.ListByOwner(ctx, owner, false, "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list after clear: %v", err)
|
||||
}
|
||||
want = []int64{ids[3], ids[2], ids[1], ids[0]}
|
||||
got = got[:0]
|
||||
for _, gift := range page.Gifts {
|
||||
got = append(got, gift.ID)
|
||||
if gift.PinnedOrder != 0 {
|
||||
t.Fatalf("gift %d pinned_order=%d after clear", gift.ID, gift.PinnedOrder)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("order after clear = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// StarsStore 是 store.StarsStore 的内存实现,复刻 postgres 版的原子语义
|
||||
// (在单个互斥锁下完成读-检查-写,等价于 SELECT ... FOR UPDATE)。
|
||||
type StarsStore struct {
|
||||
mu sync.Mutex
|
||||
states map[int64]*starsState
|
||||
nextID int64
|
||||
}
|
||||
|
||||
type starsState struct {
|
||||
balance int64
|
||||
granted bool
|
||||
txns []domain.StarsTransaction // 追加序,读时倒序
|
||||
}
|
||||
|
||||
// NewStarsStore 创建内存 StarsStore。
|
||||
func NewStarsStore() *StarsStore {
|
||||
return &StarsStore{states: make(map[int64]*starsState)}
|
||||
}
|
||||
|
||||
func (s *StarsStore) GetBalance(_ context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsBalance{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
return domain.StarsBalance{UserID: userID}, nil
|
||||
}
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) EnsureGrant(_ context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsBalance{}, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
st = &starsState{}
|
||||
s.states[userID] = st
|
||||
}
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, false, nil
|
||||
}
|
||||
if st.granted {
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, false, nil
|
||||
}
|
||||
st.balance += amount
|
||||
st.granted = true
|
||||
s.appendTxn(st, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "")
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, true, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||||
if userID == 0 || amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
st = &starsState{}
|
||||
s.states[userID] = st
|
||||
}
|
||||
st.balance += amount
|
||||
s.appendTxn(st, userID, amount, reason, peer, date, title, desc)
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) Debit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||||
if userID == 0 || amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil || st.balance < amount {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
st.balance -= amount
|
||||
s.appendTxn(st, userID, -amount, reason, peer, date, title, desc)
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
page := domain.StarsTransactionPage{Balance: st.balance}
|
||||
cursor, hasCursor := domain.DecodeStarsCursor(query.Offset)
|
||||
out := make([]domain.StarsTransaction, 0, query.Limit+1)
|
||||
appendMatch := func(t domain.StarsTransaction) bool {
|
||||
if hasCursor {
|
||||
if query.Ascending && t.ID <= cursor {
|
||||
return false
|
||||
}
|
||||
if !query.Ascending && t.ID >= cursor {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !query.Direction.IncludesAmount(t.Amount) {
|
||||
return false
|
||||
}
|
||||
out = append(out, t)
|
||||
return len(out) > query.Limit
|
||||
}
|
||||
if query.Ascending {
|
||||
for i := 0; i < len(st.txns) && len(out) <= query.Limit; i++ {
|
||||
appendMatch(st.txns[i])
|
||||
}
|
||||
} else {
|
||||
for i := len(st.txns) - 1; i >= 0 && len(out) <= query.Limit; i-- {
|
||||
appendMatch(st.txns[i])
|
||||
}
|
||||
}
|
||||
if len(out) > query.Limit {
|
||||
out = out[:query.Limit]
|
||||
page.NextOffset = domain.EncodeStarsCursor(out[len(out)-1].ID)
|
||||
}
|
||||
page.Transactions = out
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) appendTxn(st *starsState, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) {
|
||||
s.nextID++
|
||||
st.txns = append(st.txns, domain.StarsTransaction{
|
||||
ID: s.nextID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
Amount: amount,
|
||||
Date: date,
|
||||
Reason: reason,
|
||||
Title: title,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
|
@ -411,7 +411,7 @@ func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status do
|
|||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
return domain.User{}, domain.ErrEmojiStatusCollectibleInvalid
|
||||
}
|
||||
u.EmojiStatusDocumentID = status.DocumentID
|
||||
u.EmojiStatusUntil = status.Until
|
||||
|
|
|
|||
|
|
@ -1,557 +0,0 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// AccountRatingStore is the PostgreSQL implementation of the composite account
|
||||
// rating read model and its contribution ledger.
|
||||
//
|
||||
// account_rating is derived state: it is always rebuildable from the contributing
|
||||
// tables plus the 'manual' rows of account_rating_events, which is exactly what
|
||||
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
|
||||
// version so a background recompute and an admin adjustment cannot silently
|
||||
// overwrite each other.
|
||||
type AccountRatingStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewAccountRatingStore builds the store on a pgx pool or transaction.
|
||||
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
|
||||
return &AccountRatingStore{db: db}
|
||||
}
|
||||
|
||||
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
|
||||
|
||||
const (
|
||||
defaultAccountRatingListLimit = 50
|
||||
maxAccountRatingListLimit = 200
|
||||
)
|
||||
|
||||
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version`
|
||||
|
||||
// accountRatingColumnsQualified is the same projection for queries that join, so
|
||||
// the shared column names stay unambiguous.
|
||||
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
|
||||
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
|
||||
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
|
||||
|
||||
// AccountRating returns the stored projection, distinguishing "never computed"
|
||||
// from "computed as zero".
|
||||
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// AccountRatingBatch resolves several users in one round trip.
|
||||
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
filtered := make([]int64, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID > 0 {
|
||||
filtered = append(filtered, userID)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating batch: %w", err)
|
||||
}
|
||||
out[rating.UserID] = rating
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAccountRating upserts the projection under optimistic concurrency: the
|
||||
// caller submits the version it intends to write (prev.Version + 1, which is what
|
||||
// domain.ResolveAccountRatingPending produces), and the update only lands when the
|
||||
// stored row is still one version behind. A stale write reports changed=false and
|
||||
// returns the row that won, so the caller can recompute instead of retrying blind.
|
||||
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
|
||||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
|
||||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
|
||||
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if rating.Version <= 0 {
|
||||
rating.Version = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if rating.ComputedAt.IsZero() {
|
||||
rating.ComputedAt = now
|
||||
}
|
||||
if rating.UpdatedAt.IsZero() {
|
||||
rating.UpdatedAt = now
|
||||
}
|
||||
// The schema pairs pending_stars with pending_date; a half-filled pending
|
||||
// record is normalised away rather than rejected by the CHECK at runtime.
|
||||
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
|
||||
rating.PendingStars = 0
|
||||
rating.PendingDate = time.Time{}
|
||||
}
|
||||
var nextLevelStars any
|
||||
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
|
||||
nextLevelStars = rating.NextLevelStars
|
||||
}
|
||||
var pendingDate any
|
||||
if rating.PendingStars != 0 {
|
||||
pendingDate = rating.PendingDate.UTC()
|
||||
}
|
||||
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
INSERT INTO account_rating (
|
||||
user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
level = EXCLUDED.level,
|
||||
stars = EXCLUDED.stars,
|
||||
current_level_stars = EXCLUDED.current_level_stars,
|
||||
next_level_stars = EXCLUDED.next_level_stars,
|
||||
stars_component = EXCLUDED.stars_component,
|
||||
activity_component = EXCLUDED.activity_component,
|
||||
penalty_component = EXCLUDED.penalty_component,
|
||||
manual_component = EXCLUDED.manual_component,
|
||||
pending_stars = EXCLUDED.pending_stars,
|
||||
pending_date = EXCLUDED.pending_date,
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
version = EXCLUDED.version
|
||||
WHERE account_rating.version = EXCLUDED.version - 1
|
||||
RETURNING `+accountRatingColumns,
|
||||
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
|
||||
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
|
||||
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The guard rejected the write; report the row that is actually stored.
|
||||
current, getErr := s.AccountRating(ctx, rating.UserID)
|
||||
if getErr != nil {
|
||||
return domain.AccountRating{}, false, getErr
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
|
||||
}
|
||||
return stored, true, nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user.
|
||||
//
|
||||
// Sources, and why each one:
|
||||
//
|
||||
// stars received / spent stars_transactions, split by the sign of amount: that
|
||||
// ledger is the single authoritative record of Stars
|
||||
// movement for a user, and stars_transactions_user_id_idx
|
||||
// (user_id, id DESC) bounds the scan to the user's rows.
|
||||
// gifts received peer_star_gifts for the user peer, restricted to
|
||||
// lifecycle_status = 'active' -- the weight rewards gifts
|
||||
// actually held, not ones converted, burned or exported
|
||||
// away. peer_star_gifts_owner_profile_order_idx is the
|
||||
// partial index on exactly that predicate and leads with
|
||||
// (owner_peer_type, owner_peer_id).
|
||||
// moderation cases moderation_cases against this user peer, restricted to
|
||||
// the statuses that follow a *violation* decision:
|
||||
// 'action_pending' (violation decided, actions running),
|
||||
// 'action_failed' (violation decided, action delivery
|
||||
// broke) and 'resolved' (violation decided and actions
|
||||
// applied). 'dismissed' covers both no_violation and a
|
||||
// granted appeal, and 'open'/'in_review'/'appeal_review'
|
||||
// are undecided, so none of them penalise the account.
|
||||
// scam / fake users.scam / users.fake, the peer flags 0136 added.
|
||||
// account age users.created_at, floored to whole days.
|
||||
// messages sent message_boxes, counted through
|
||||
// message_boxes_private_sender_live_idx
|
||||
// (message_sender_id, private_message_id) WHERE NOT
|
||||
// deleted. This is the cheapest trustworthy source: the
|
||||
// index leads with the sender and is coverable, so the
|
||||
// count needs no heap access. Every private message
|
||||
// materialises one box per participant, hence
|
||||
// count(DISTINCT private_message_id) rather than
|
||||
// count(*). Channel posts are deliberately excluded --
|
||||
// channel_messages has no sender-leading index, so
|
||||
// attributing them would cost a full table scan.
|
||||
// manual sum of account_rating_events.amount where
|
||||
// kind = 'manual', which is the part of the score that
|
||||
// must survive a full recompute.
|
||||
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
signals := domain.AccountRatingSignals{UserID: userID}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
|
||||
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
|
||||
COALESCE((
|
||||
SELECT count(DISTINCT private_message_id) FROM message_boxes
|
||||
WHERE message_sender_id = u.id AND NOT deleted
|
||||
), 0),
|
||||
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
|
||||
COALESCE((
|
||||
SELECT count(*) FROM peer_star_gifts
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
|
||||
), 0),
|
||||
COALESCE((
|
||||
SELECT count(*) FROM moderation_cases
|
||||
WHERE target_peer_type = 'user' AND target_peer_id = u.id
|
||||
AND status IN ('action_pending', 'action_failed', 'resolved')
|
||||
), 0),
|
||||
u.scam,
|
||||
u.fake,
|
||||
COALESCE((
|
||||
SELECT sum(amount) FROM account_rating_events
|
||||
WHERE user_id = u.id AND kind = 'manual'
|
||||
), 0)
|
||||
FROM users u
|
||||
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
|
||||
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
|
||||
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
|
||||
&signals.Scam, &signals.Fake, &signals.Manual,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
|
||||
}
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
|
||||
// recompute the projection: the caller pairs it with SaveAccountRating so the new
|
||||
// manual total is folded in through the same formula as every other signal.
|
||||
// Replaying the same CommandKey returns the recorded event and applied=false.
|
||||
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
var event domain.AccountRatingEvent
|
||||
applied := false
|
||||
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
|
||||
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
event = domain.AccountRatingEvent{
|
||||
UserID: req.UserID,
|
||||
Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandKey: req.CommandKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
// DO NOTHING on the partial command_key index closes the window between the
|
||||
// replay lookup and the insert: a concurrent retry of the same command
|
||||
// records nothing and falls back to reading the row that won.
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
|
||||
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
|
||||
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
|
||||
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
|
||||
Scan(&event.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
|
||||
}
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert account rating adjustment: %w", err)
|
||||
}
|
||||
applied = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
return event, applied, nil
|
||||
}
|
||||
|
||||
// ListAccountRatings is the admin leaderboard query. The order matches
|
||||
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
|
||||
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
|
||||
// stays consistent across the compound order instead of only over user ids.
|
||||
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH cursor_row AS (
|
||||
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
|
||||
FROM account_rating WHERE $3 <> 0 AND user_id = $3
|
||||
)
|
||||
SELECT `+accountRatingColumnsQualified+`
|
||||
FROM account_rating r
|
||||
LEFT JOIN cursor_row c ON true
|
||||
WHERE r.level >= $1
|
||||
AND ($2 = 0 OR r.user_id = $2)
|
||||
AND (
|
||||
c.c_user_id IS NULL
|
||||
OR r.level < c.c_level
|
||||
OR (r.level = c.c_level AND r.stars < c.c_stars)
|
||||
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
|
||||
)
|
||||
ORDER BY r.level DESC, r.stars DESC, r.user_id
|
||||
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRating, 0, limit)
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating: %w", err)
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountRatingEvents returns the ledger for one user, newest first, over
|
||||
// account_rating_events_user_idx (user_id, id DESC).
|
||||
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account rating events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRatingEvent, 0, limit)
|
||||
for rows.Next() {
|
||||
event, err := scanAccountRatingEvent(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating event: %w", err)
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account rating events: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StaleAccountRatings returns the users whose projection is older than the
|
||||
// horizon, ordered so the walk follows account_rating_stale_idx
|
||||
// (computed_at, user_id) exactly.
|
||||
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if olderThanUnix <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id FROM account_rating
|
||||
WHERE computed_at < to_timestamp($1)
|
||||
ORDER BY computed_at, user_id
|
||||
LIMIT $2`, olderThanUnix, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list stale account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan stale account rating: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnratedAccounts returns accounts that have no projection yet, oldest account
|
||||
// first so the walk is stable and every account is eventually reached.
|
||||
//
|
||||
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
|
||||
// not transact on their own behalf; the built-in service accounts, which are
|
||||
// infrastructure -- and note that the platform account is not flagged is_bot, so
|
||||
// excluding bots alone would still have seeded it; and deleted accounts, which are
|
||||
// tombstones whose every profile field has already been cleared.
|
||||
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT u.id FROM users u
|
||||
WHERE NOT u.is_bot
|
||||
AND u.deleted_at IS NULL
|
||||
AND u.id <> ALL($2::bigint[])
|
||||
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
|
||||
ORDER BY u.created_at, u.id
|
||||
LIMIT $1`, limit, domain.SystemUserIDs())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list unrated accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan unrated account: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
|
||||
if commandKey == "" {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events WHERE command_key = $1`, commandKey))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
|
||||
var rating domain.AccountRating
|
||||
var nextLevelStars pgtype.Int8
|
||||
var pendingDate pgtype.Timestamptz
|
||||
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
|
||||
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
|
||||
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
|
||||
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if nextLevelStars.Valid {
|
||||
rating.NextLevelStars = nextLevelStars.Int64
|
||||
rating.HasNextLevel = true
|
||||
}
|
||||
if pendingDate.Valid {
|
||||
rating.PendingDate = pendingDate.Time.UTC()
|
||||
}
|
||||
rating.ComputedAt = rating.ComputedAt.UTC()
|
||||
rating.UpdatedAt = rating.UpdatedAt.UTC()
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
|
||||
var event domain.AccountRatingEvent
|
||||
var kind string
|
||||
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
|
||||
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
|
||||
return domain.AccountRatingEvent{}, err
|
||||
}
|
||||
event.Kind = domain.AccountRatingEventKind(kind)
|
||||
event.CreatedAt = event.CreatedAt.UTC()
|
||||
return event, nil
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ratingTestUser inserts a user row with an explicit creation date so the account
|
||||
// age signal is deterministic.
|
||||
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
|
||||
VALUES ($1, $2, 'rating test', $3, $3)
|
||||
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
|
||||
t.Fatalf("insert rating test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
|
||||
// rows can satisfy their catalog revision foreign key.
|
||||
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
docID := time.Now().UnixNano() & 0x7fffffffffffffff
|
||||
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 50, Enabled: true,
|
||||
Document: domain.Document{
|
||||
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{
|
||||
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
|
||||
MimeType: "application/x-tgsticker",
|
||||
},
|
||||
Animation: domain.StarGiftAnimation{
|
||||
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
|
||||
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
|
||||
},
|
||||
Actor: "test", CommandID: "rating-star-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog revision: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
|
||||
entry.Gift.ID).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("read catalog revision id: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
|
||||
})
|
||||
return revisionID, entry.Gift.ID
|
||||
}
|
||||
|
||||
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
|
||||
// creates the row, a stale version is refused without an error, and the next
|
||||
// version wins.
|
||||
func TestAccountRatingSaveVersionConflict(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
|
||||
|
||||
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
|
||||
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
stored, changed, err := store.SaveAccountRating(ctx, computed)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
||||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
|
||||
t.Fatalf("stored = %+v want %+v", stored, computed)
|
||||
}
|
||||
read, err := store.AccountRating(ctx, userID)
|
||||
if err != nil || read != stored {
|
||||
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
|
||||
}
|
||||
|
||||
// A second writer that still believes version 0 is stale: no error, no write.
|
||||
stale := computed
|
||||
stale.Stars = 999_999
|
||||
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("stale save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
|
||||
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
|
||||
}
|
||||
|
||||
// The recompute that carries the right version applies, including a pending
|
||||
// delta that must round-trip through the paired pending_stars/pending_date.
|
||||
next := domain.ResolveAccountRatingPending(stored,
|
||||
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
|
||||
time.Hour, now)
|
||||
if next.PendingStars == 0 || next.PendingDate.IsZero() {
|
||||
t.Fatalf("expected a parked pending delta, got %+v", next)
|
||||
}
|
||||
applied, changed, err := store.SaveAccountRating(ctx, next)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("versioned save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
|
||||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
|
||||
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
|
||||
}
|
||||
pending, ok := applied.PendingLevel()
|
||||
if !ok || pending.Stars <= applied.Stars {
|
||||
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
|
||||
}
|
||||
|
||||
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
|
||||
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
|
||||
t.Fatalf("batch = %+v err=%v", batch, err)
|
||||
}
|
||||
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
|
||||
if err != nil || len(list) != 1 || list[0].UserID != userID {
|
||||
t.Fatalf("list = %+v err=%v", list, err)
|
||||
}
|
||||
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
if !containsInt64(stale2, userID) {
|
||||
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
|
||||
}
|
||||
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale fresh: %v", err)
|
||||
}
|
||||
if containsInt64(fresh, userID) {
|
||||
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
|
||||
// command key records nothing new and the manual total feeds the recompute.
|
||||
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
|
||||
key := fmt.Sprintf("adjust-%d", seed)
|
||||
|
||||
req := domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
|
||||
}
|
||||
event, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
|
||||
event.CommandKey != key {
|
||||
t.Fatalf("event = %+v", event)
|
||||
}
|
||||
replay, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || applied || replay.ID != event.ID {
|
||||
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
|
||||
}
|
||||
// A second, distinct adjustment accumulates rather than replacing.
|
||||
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
|
||||
CommandKey: key + "-b",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
events, err := store.AccountRatingEvents(ctx, userID, 10)
|
||||
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 0, CommandKey: key + "-c",
|
||||
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
|
||||
}
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.Manual != 500 {
|
||||
t.Fatalf("manual signal = %d, want 500", signals.Manual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingSignalsSources pins where each contribution comes from: the
|
||||
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
|
||||
// account age and the private-message count.
|
||||
func TestAccountRatingSignalsSources(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
createdAt := time.Now().UTC().AddDate(0, 0, -45)
|
||||
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stars_transactions (user_id, amount, reason, date)
|
||||
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed stars: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
|
||||
})
|
||||
revisionID, giftID := ratingTestCatalogRevision(t, pool)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
|
||||
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
|
||||
('user', $1, 2, $2, 0, $3, 'active', false),
|
||||
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
|
||||
t.Fatalf("seed gifts: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
|
||||
})
|
||||
// Only statuses that follow a violation decision count; 'dismissed' (which
|
||||
// covers no_violation and a granted appeal) and undecided states do not.
|
||||
now := time.Now().UTC()
|
||||
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO moderation_cases (
|
||||
target_peer_type, target_peer_id, status, severity, report_count,
|
||||
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
|
||||
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
|
||||
t.Fatalf("seed moderation case %s: %v", status, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
|
||||
t.Fatalf("set scam: %v", err)
|
||||
}
|
||||
// Two private messages, each materialised as the sender's and the recipient's
|
||||
// box: the distinct count must report two, not four.
|
||||
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
|
||||
for i := 1; i <= 2; i++ {
|
||||
var messageID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
|
||||
VALUES ($1, $2, 0, 'hi')
|
||||
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
|
||||
t.Fatalf("seed private message: %v", err)
|
||||
}
|
||||
for _, box := range []struct {
|
||||
owner int64
|
||||
peer int64
|
||||
outgoing bool
|
||||
}{{userID, peerID, true}, {peerID, userID, false}} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
|
||||
from_user_id, message_date, outgoing, body
|
||||
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
|
||||
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
|
||||
t.Fatalf("seed message box: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
|
||||
})
|
||||
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
|
||||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
|
||||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
|
||||
t.Fatalf("signals = %+v", signals)
|
||||
}
|
||||
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
|
||||
t.Fatalf("computed rating = %+v", rating)
|
||||
}
|
||||
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
|
||||
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingLeaderboardPaging covers the keyset walk over
|
||||
// (level DESC, stars DESC, user_id).
|
||||
func TestAccountRatingLeaderboardPaging(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
now := time.Now().UTC()
|
||||
scores := []int64{100, 400, 900, 1600}
|
||||
ids := make([]int64, 0, len(scores))
|
||||
for i, score := range scores {
|
||||
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
|
||||
ids = append(ids, userID)
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
|
||||
domain.DefaultAccountRatingWeights(), now)
|
||||
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
|
||||
if err != nil || len(page) != 2 {
|
||||
t.Fatalf("first page = %+v err=%v", page, err)
|
||||
}
|
||||
if page[0].Level < page[1].Level {
|
||||
t.Fatalf("leaderboard must be level-descending: %+v", page)
|
||||
}
|
||||
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
||||
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
for _, item := range next {
|
||||
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
|
||||
t.Fatalf("keyset page repeated user %d", item.UserID)
|
||||
}
|
||||
if item.Level > page[len(page)-1].Level {
|
||||
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
|
||||
}
|
||||
}
|
||||
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("negative level filter must be rejected")
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
|
||||
// bootstrap pass: only accounts with no projection are returned, bots and deleted
|
||||
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
|
||||
// the candidate set the moment a projection exists.
|
||||
//
|
||||
// Without this query the read model can never populate itself -- StaleAccountRatings
|
||||
// walks account_rating and so cannot return a user who is not in it.
|
||||
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
|
||||
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
|
||||
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
|
||||
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
|
||||
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
|
||||
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
|
||||
t.Fatalf("mark bot: %v", err)
|
||||
}
|
||||
// A deleted account is a tombstone: every profile field is already cleared, so
|
||||
// there is no rating to show and no reason to compute one.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, premium_expires_at = NULL,
|
||||
emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0,
|
||||
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
|
||||
WHERE id = $1`, deleted); err != nil {
|
||||
t.Fatalf("mark deleted: %v", err)
|
||||
}
|
||||
|
||||
// The table is shared with every other account in the test database, so assert
|
||||
// on relative order and membership rather than on an exact page.
|
||||
positions := func(t *testing.T) (map[int64]int, []int64) {
|
||||
t.Helper()
|
||||
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts: %v", err)
|
||||
}
|
||||
index := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
index[id] = i
|
||||
}
|
||||
return index, ids
|
||||
}
|
||||
|
||||
index, ids := positions(t)
|
||||
for _, id := range []int64{oldest, middle, newest} {
|
||||
if _, ok := index[id]; !ok {
|
||||
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
|
||||
}
|
||||
}
|
||||
if _, ok := index[bot]; ok {
|
||||
t.Fatalf("bot %d was offered as a rating candidate", bot)
|
||||
}
|
||||
if _, ok := index[deleted]; ok {
|
||||
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
|
||||
}
|
||||
// The service accounts are infrastructure. The platform account in particular is
|
||||
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
|
||||
// is exactly how it got a rating.
|
||||
for _, serviceID := range domain.SystemUserIDs() {
|
||||
if _, ok := index[serviceID]; ok {
|
||||
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
|
||||
}
|
||||
}
|
||||
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
|
||||
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
|
||||
index[oldest], index[middle], index[newest])
|
||||
}
|
||||
|
||||
// Seeding one account removes it from the candidate set, so the pass converges
|
||||
// instead of offering the same user every cycle.
|
||||
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
|
||||
UserID: middle, Level: 1, Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
ComputedAt: time.Now().UTC(), Version: 1,
|
||||
}); err != nil || !changed {
|
||||
t.Fatalf("seed projection = %v changed=%v", err, changed)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
|
||||
})
|
||||
index, _ = positions(t)
|
||||
if _, ok := index[middle]; ok {
|
||||
t.Fatalf("account %d is still a candidate after being seeded", middle)
|
||||
}
|
||||
if _, ok := index[oldest]; !ok {
|
||||
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
|
||||
}
|
||||
|
||||
// The limit is honoured, so one cycle can never walk the whole users table.
|
||||
capped, err := store.UnratedAccounts(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts with a limit: %v", err)
|
||||
}
|
||||
if len(capped) != 2 {
|
||||
t.Fatalf("limited candidates = %d, want 2", len(capped))
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -36,61 +34,6 @@ func (s *ChannelStore) AppendCallServiceMessage(ctx context.Context, channelID,
|
|||
return s.appendServiceMessage(ctx, "call", channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions,不插入 channel_messages。
|
||||
func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return fmt.Errorf("append star gift admin log: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin star gift admin log: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = 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
|
||||
}
|
||||
messageID := int(savedID)
|
||||
if savedID > int64(domain.MaxMessageBoxID) {
|
||||
messageID = domain.MaxMessageBoxID
|
||||
}
|
||||
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,
|
||||
}
|
||||
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) {
|
||||
if channelID == 0 || senderUserID == 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
|
|
|
|||
|
|
@ -499,14 +499,6 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
|
|||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}
|
||||
if first.PaidMessageStars > 0 {
|
||||
balance := domain.StarsBalance{UserID: first.SenderUserID}
|
||||
if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID).
|
||||
Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err)
|
||||
}
|
||||
result.SenderStarsBalance = &balance
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +507,6 @@ func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, chan
|
|||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err)
|
||||
}
|
||||
action = channelServiceActionForMessage(channel.ID, msgID, action)
|
||||
pts, err := s.reserveChannelPts(ctx, tx, channel.ID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err)
|
||||
|
|
@ -552,20 +543,6 @@ func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, chan
|
|||
return msg, event, nil
|
||||
}
|
||||
|
||||
func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction {
|
||||
if action.Type == domain.ChannelActionStarGift && action.StarGift != nil {
|
||||
g := *action.StarGift
|
||||
if g.PeerChannelID == 0 {
|
||||
g.PeerChannelID = channelID
|
||||
}
|
||||
if g.SavedID == 0 {
|
||||
g.SavedID = int64(msgID)
|
||||
}
|
||||
action.StarGift = &g
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error {
|
||||
return insertChannelMessageWithFingerprintTx(ctx, tx, msg, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
requestFingerprint, err := store.MonoforumSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
|
|
@ -103,47 +100,10 @@ FOR SHARE OF m, p`, channel.ID).Scan(
|
|||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
// Direct Messages are always free: telesrv has no Stars economy, so the
|
||||
// per-channel paid-messages price (if a stale admin setting still has one)
|
||||
// is never charged.
|
||||
paidMessageStars := int64(0)
|
||||
if !isAdmin && channel.SendPaidMessagesStars > 0 {
|
||||
if req.AllowPaidStars < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
|
||||
}
|
||||
balance := domain.StarsBalance{UserID: req.SenderUserID}
|
||||
if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID).
|
||||
Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err)
|
||||
}
|
||||
if balance.Balance < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
paidMessageStars = channel.SendPaidMessagesStars
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE stars_balances
|
||||
SET balance = balance - $2, updated_at = now()
|
||||
WHERE user_id = $1
|
||||
RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err)
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage,
|
||||
domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000
|
||||
if channelCredit > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_stars_balances(channel_id, balance)
|
||||
VALUES($1, $2)
|
||||
ON CONFLICT(channel_id) DO UPDATE
|
||||
SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err)
|
||||
}
|
||||
}
|
||||
senderBalance = &balance
|
||||
}
|
||||
if req.ReplyTo != nil {
|
||||
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
|
|
@ -266,7 +226,7 @@ ORDER BY user_id`, parent.ID)
|
|||
committed = true
|
||||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0)}, nil
|
||||
}
|
||||
|
||||
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
|
||||
|
|
|
|||
|
|
@ -432,117 +432,3 @@ WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID)
|
|||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"})
|
||||
if err != nil {
|
||||
t.Fatalf("create sub: %v", err)
|
||||
}
|
||||
other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable paid DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID})
|
||||
})
|
||||
stars := NewStarsStore(pool)
|
||||
if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil {
|
||||
t.Fatalf("grant subscriber stars: %v", err)
|
||||
}
|
||||
if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil {
|
||||
t.Fatalf("grant other stars: %v", err)
|
||||
}
|
||||
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
|
||||
var beforeMessages int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil {
|
||||
t.Fatalf("count messages before paid send: %v", err)
|
||||
}
|
||||
lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001}
|
||||
var required *domain.StarsPaymentRequiredError
|
||||
if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 {
|
||||
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
|
||||
}
|
||||
var afterLowMessages int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages {
|
||||
t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages)
|
||||
}
|
||||
|
||||
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002}
|
||||
paid, err := channels.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid send: %v", err)
|
||||
}
|
||||
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
|
||||
}
|
||||
var senderBalance, channelBalance, persistedPaid int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil {
|
||||
t.Fatalf("load sender balance: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil {
|
||||
t.Fatalf("load channel balance: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil {
|
||||
t.Fatalf("load persisted paid stars: %v", err)
|
||||
}
|
||||
if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 {
|
||||
t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid)
|
||||
}
|
||||
|
||||
replay, err := channels.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid replay: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid replay = %+v, want exact original and balance 15", replay)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 {
|
||||
t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
|
||||
t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err)
|
||||
}
|
||||
|
||||
admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil {
|
||||
t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance)
|
||||
}
|
||||
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
var otherBalance int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 {
|
||||
t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
|
||||
t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,70 +199,6 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
|
|||
recentRows.Close()
|
||||
}
|
||||
|
||||
// 3) 付费 reaction(Stars):跨频道一次取所有 reactor 行,Go 内按 (channel,message) 聚合
|
||||
// 总星数 + viewer 自身 + top reactors,挂到 message.Reactions.Paid(tg 转换注入 ReactionPaid)。
|
||||
// 绝大多数消息无付费 reaction(索引扫描即返回空);总星数须含全部 reactor 故取全行。
|
||||
if err := populateChannelMessagesPaidReactions(ctx, db, viewerUserID, channelsByID, indexes, messages, pairChannels, pairMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func populateChannelMessagesPaidReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channelsByID map[int64]domain.Channel, indexes map[channelReactionMessageKey][]int, messages []domain.ChannelMessage, pairChannels []int64, pairMessages []int32) error {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT channel_id, message_id, reactor_user_id, stars, anonymous
|
||||
FROM channel_message_paid_reactions
|
||||
WHERE (channel_id, message_id) IN (SELECT * FROM unnest($1::bigint[], $2::int[]))
|
||||
ORDER BY channel_id ASC, message_id ASC, stars DESC, reactor_user_id ASC`, pairChannels, pairMessages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load channel message paid reactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
aggByKey := make(map[channelReactionMessageKey]*domain.ChannelMessagePaidReactions)
|
||||
for rows.Next() {
|
||||
var channelID int64
|
||||
var msgID int
|
||||
var r domain.PaidReactor
|
||||
if err := rows.Scan(&channelID, &msgID, &r.UserID, &r.Stars, &r.Anonymous); err != nil {
|
||||
return err
|
||||
}
|
||||
key := channelReactionMessageKey{channelID: channelID, messageID: msgID}
|
||||
agg := aggByKey[key]
|
||||
if agg == nil {
|
||||
agg = &domain.ChannelMessagePaidReactions{}
|
||||
aggByKey[key] = agg
|
||||
}
|
||||
agg.TotalStars += r.Stars
|
||||
r.My = r.UserID == viewerUserID
|
||||
if r.My {
|
||||
agg.MyStars = r.Stars
|
||||
agg.MyAnonymous = r.Anonymous
|
||||
}
|
||||
// top reactors 取前 N(已按 stars DESC);viewer 自身若不在前 N 也补一条(始终在列)。
|
||||
if len(agg.TopReactors) < domain.MaxPaidReactionTopReactors {
|
||||
agg.TopReactors = append(agg.TopReactors, r)
|
||||
} else if r.My {
|
||||
agg.TopReactors = append(agg.TopReactors, r)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for key, agg := range aggByKey {
|
||||
if agg.TotalStars <= 0 {
|
||||
continue
|
||||
}
|
||||
ch := channelsByID[key.channelID]
|
||||
for _, idx := range indexes[key] {
|
||||
if messages[idx].Reactions == nil {
|
||||
reactions := emptyChannelMessageReactions(ch)
|
||||
messages[idx].Reactions = &reactions
|
||||
}
|
||||
paidCopy := *agg
|
||||
paidCopy.TopReactors = append([]domain.PaidReactor(nil), agg.TopReactors...)
|
||||
messages[idx].Reactions.Paid = &paidCopy
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,125 +182,6 @@ DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_d
|
|||
}, nil
|
||||
}
|
||||
|
||||
// AddChannelMessagePaidReaction 为一条广播频道消息增投付费 reaction 星数(累计),返回聚合
|
||||
// 状态供 rpc 投影与扇出。扣费在 rpc 层经 Stars 账本 Debit 完成,本方法只负责累计与聚合。
|
||||
func (s *ChannelStore) AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Date <= 0 {
|
||||
req.Date = nowUnix()
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("add channel paid reaction: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("begin add channel paid reaction: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, err
|
||||
}
|
||||
// 付费 reaction 仅用于广播频道帖子(官方语义)。
|
||||
if !channel.Broadcast || channel.Megagroup {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrReactionInvalid
|
||||
}
|
||||
msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, err
|
||||
}
|
||||
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_message_paid_reactions (channel_id, message_id, reactor_user_id, stars, anonymous, reaction_date)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (channel_id, message_id, reactor_user_id)
|
||||
DO UPDATE SET stars = channel_message_paid_reactions.stars + EXCLUDED.stars,
|
||||
anonymous = EXCLUDED.anonymous,
|
||||
reaction_date = EXCLUDED.reaction_date`,
|
||||
req.ChannelID, req.MessageID, req.UserID, req.Stars, req.Anonymous, req.Date); err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("upsert channel paid reaction: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("commit add channel paid reaction: %w", err)
|
||||
}
|
||||
committed = true
|
||||
messages := []domain.ChannelMessage{msg}
|
||||
if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, err
|
||||
}
|
||||
msg = messages[0]
|
||||
paid, err := s.aggregateChannelPaidReactions(ctx, req.ChannelID, req.MessageID, req.UserID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactionResult{}, err
|
||||
}
|
||||
recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout)
|
||||
if err != nil || len(recipients) == 0 {
|
||||
recipients = []int64{req.UserID}
|
||||
}
|
||||
return domain.ChannelMessagePaidReactionResult{
|
||||
Channel: channel,
|
||||
Message: msg,
|
||||
Paid: paid,
|
||||
Recipients: recipients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// aggregateChannelPaidReactions 汇总一条消息的付费 reaction:总星数 + viewer 自身 + top reactors。
|
||||
func (s *ChannelStore) aggregateChannelPaidReactions(ctx context.Context, channelID int64, messageID int, viewerUserID int64) (domain.ChannelMessagePaidReactions, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT reactor_user_id, stars, anonymous
|
||||
FROM channel_message_paid_reactions
|
||||
WHERE channel_id = $1 AND message_id = $2
|
||||
ORDER BY stars DESC, reactor_user_id ASC`, channelID, messageID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePaidReactions{}, fmt.Errorf("aggregate channel paid reactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out domain.ChannelMessagePaidReactions
|
||||
var myReactor domain.PaidReactor
|
||||
myInTop := false
|
||||
for rows.Next() {
|
||||
var r domain.PaidReactor
|
||||
if err := rows.Scan(&r.UserID, &r.Stars, &r.Anonymous); err != nil {
|
||||
return domain.ChannelMessagePaidReactions{}, err
|
||||
}
|
||||
out.TotalStars += r.Stars
|
||||
r.My = r.UserID == viewerUserID
|
||||
if r.My {
|
||||
out.MyStars = r.Stars
|
||||
out.MyAnonymous = r.Anonymous
|
||||
myReactor = r
|
||||
}
|
||||
if len(out.TopReactors) < domain.MaxPaidReactionTopReactors {
|
||||
out.TopReactors = append(out.TopReactors, r)
|
||||
if r.My {
|
||||
myInTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelMessagePaidReactions{}, err
|
||||
}
|
||||
// viewer 自身始终出现在 top reactors(官方:你的条目总在列表里,带 My 标志)。
|
||||
if out.MyStars > 0 && !myInTop {
|
||||
out.TopReactors = append(out.TopReactors, myReactor)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 {
|
||||
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue