removed all "paid" features - no more stars, gifts, or grams

This commit is contained in:
onysd 2026-08-07 01:50:10 +03:00
parent d4451d753c
commit 21d8e91756
165 changed files with 318 additions and 40948 deletions

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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 != "" {

View file

@ -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
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -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 {

View file

@ -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 = "*"

View file

@ -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)

View file

@ -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
}

View file

@ -611,11 +611,6 @@ const (
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice
// 广播频道 Direct Messages 开关/价格变更的服务消息。
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUniquecollectible
// 升级、转赠等所有权变更只进入 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 是付费 reactionStars聚合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.

View file

@ -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 快照)。

View file

@ -1,46 +0,0 @@
package domain
// 频道帖子付费 reactionmessages.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

View file

@ -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)
}
})
}
}

View file

@ -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)
}
}
}

View file

@ -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 // 单调递增账本 idkeyset 游标)
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
}

View file

@ -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 的起始值。
//

View file

@ -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 &copy
}
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
}

View file

@ -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)
}
})
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 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)
}

View file

@ -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()

View file

@ -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
}

View file

@ -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 reactorsMy/chosen 由 in.Paid 的视角数据驱动,
// 调用方对他人视角已抹除 My/MyStars。统一在此注入覆盖所有频道消息读路径。
if in.Paid != nil {
injectPaidReaction(out, *in.Paid)
}
if out.Results == nil {
out.Results = []tg.ReactionCount{}
}

View file

@ -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)

View file

@ -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.ErrStarsInsufficientrpc 经 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

View file

@ -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 {

View file

@ -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 {

View file

@ -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/MyStarsmin 语义防串视角)。
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 注入 MessageReactionsReactionPaid 计数置首位、填充
// 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
}

View file

@ -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")
}
}

View file

@ -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{}
}

View file

@ -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)
})

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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.* RPCStars 本地账本(余额/流水真实化)+ 其余
// 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.starsStatusbalance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balanceDrKLO 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_offsetflag 不置),否则 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.starsStatuschats/users 非空 vector 但可空)。
@ -529,119 +130,3 @@ func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
Users: []tg.UserClass{},
}
}
// tgStarsTransactions 把账本流水投影为 tg.StarsTransactionamount 带符号:借记为负)。
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其余兜底 UnsupportedPeer 字段必填,不可为 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()
}
}

View file

@ -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)
}
}
})
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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])
}
}

View file

@ -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-flagGetTon 读 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")
}
}

View file

@ -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
}

View file

@ -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}}},

View file

@ -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{}) {

View file

@ -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)
}
}

View file

@ -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.BirthdayYear 可选0 表示不含年份)。
func tgBirthday(b domain.Birthday) tg.Birthday {

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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(),
},
},
}
}

View file

@ -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)
}

View file

@ -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带频道
// ptsRecipients 为活跃成员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.

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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

View file

@ -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)
}

View file

@ -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 子会话内的重发消息。

View file

@ -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])
}
}

View file

@ -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
}

View file

@ -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),

View file

@ -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
}

View file

@ -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.

View file

@ -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)
}
}
// 多 reactorTopReactors 按星数降序,本人始终在列。
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)
}
}

View file

@ -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 &copy
}
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
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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,
})
}

View file

@ -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

View file

@ -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
}

View file

@ -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))
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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 倒序分页。

View file

@ -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)
}
}

View file

@ -199,70 +199,6 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
recentRows.Close()
}
// 3) 付费 reactionStars跨频道一次取所有 reactor 行Go 内按 (channel,message) 聚合
// 总星数 + viewer 自身 + top reactors挂到 message.Reactions.Paidtg 转换注入 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 DESCviewer 自身若不在前 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
}

View file

@ -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