removed all "paid" features - no more stars, gifts, or grams
This commit is contained in:
parent
d4451d753c
commit
21d8e91756
165 changed files with 318 additions and 40948 deletions
52
.env.example
52
.env.example
|
|
@ -242,11 +242,6 @@ TELESRV_BOT_VERIFICATION_ENABLED=true
|
|||
# up (nothing is deleted), so setting this to false re-enables it. Default
|
||||
# true -- leave it alone unless you are specifically testing this feature.
|
||||
TELESRV_HIDE_THIRD_PARTY_VERIFICATION=true
|
||||
# Local composite account-rating score (stars/activity/moderation-based).
|
||||
# Shown to every viewer via userFull.stars_rating, not admin-only. Scoring
|
||||
# weights and recompute timing live in the Advanced section below.
|
||||
TELESRV_RATING_ENABLED=true
|
||||
|
||||
## Storage & Media -- Where uploaded media is stored, and how the server reacts to running low on space.
|
||||
|
||||
# Where uploaded media (photos, documents, stickers) is physically stored.
|
||||
|
|
@ -403,7 +398,6 @@ TELESRV_MAPTILE_CACHE_DIR=data/maptiles
|
|||
|
||||
# Data directories the server seeds/serves media and content from.
|
||||
TELESRV_LANGPACK_SEED_DIR=data/langpack
|
||||
TELESRV_OFFICIAL_GIFTS_DIR=data/official-gifts
|
||||
TELESRV_BLOB_DIR=data/blobs
|
||||
TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
||||
# Caps how many built-in sticker sets get imported on startup; <=0 means no limit.
|
||||
|
|
@ -428,9 +422,8 @@ TELESRV_STORAGE_USAGE_REFRESH_INTERVAL=1m
|
|||
# TELESRV_RETENTION_INTERVAL/TELESRV_RETENTION_BATCH cadence above.
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE=720h
|
||||
|
||||
# New-account perks: free Telegram Premium months and starting Stars balance.
|
||||
# New-account perks: free Telegram Premium months.
|
||||
TELESRV_PREMIUM_GRANT_MONTHS=3
|
||||
TELESRV_STARS_STARTING_GRANT=1000
|
||||
TELESRV_PREMIUM_SWEEP_INTERVAL=1m
|
||||
TELESRV_PREMIUM_SWEEP_BATCH=500
|
||||
|
||||
|
|
@ -438,49 +431,6 @@ TELESRV_PREMIUM_SWEEP_BATCH=500
|
|||
# (the server usually can't predict a mobile app's origin ahead of time).
|
||||
TELESRV_PASSKEY_ALLOWED_ORIGINS=
|
||||
|
||||
# Star Gifts: unique collectible gifts, transfer/resale, crafting/upgrades, auctions. TON
|
||||
# values are a purely local ledger inside the server -- no real wallet, Fragment, or
|
||||
# blockchain is ever contacted.
|
||||
TELESRV_STARGIFT_SWEEP_INTERVAL=15s
|
||||
TELESRV_STARGIFT_SWEEP_BATCH=1000
|
||||
# Internal nanoton granted once per user on first local-ledger access.
|
||||
TELESRV_STARGIFT_TON_STARTING_GRANT=10000000000
|
||||
TELESRV_STARGIFT_TRANSFER_STARS=25
|
||||
TELESRV_STARGIFT_DROP_DETAILS_STARS=25
|
||||
TELESRV_STARGIFT_OFFER_MIN_STARS=1
|
||||
TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE=1000
|
||||
TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE=1000
|
||||
TELESRV_STARGIFT_EXPORT_DELAY=0s
|
||||
TELESRV_STARGIFT_TRANSFER_DELAY=0s
|
||||
TELESRV_STARGIFT_RESELL_DELAY=0s
|
||||
TELESRV_STARGIFT_CRAFT_DELAY=0s
|
||||
TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250
|
||||
|
||||
# Tuning for the account-rating score; the on/off switch
|
||||
# (TELESRV_RATING_ENABLED) is in the Moderation/Verification/Rating section above.
|
||||
# A local rating increase is parked for this long before it becomes the visible
|
||||
# level; a decrease always applies immediately. 0 applies every change at once.
|
||||
TELESRV_RATING_PENDING_DELAY=24h
|
||||
# Background recompute worker: the rating derives from signals owned by other
|
||||
# subsystems, so freshness is a worker property rather than a write-path one.
|
||||
TELESRV_RATING_RECOMPUTE_INTERVAL=15m
|
||||
TELESRV_RATING_RECOMPUTE_BATCH=500
|
||||
TELESRV_RATING_STALE_AFTER=6h
|
||||
# Integer composite weights; the defaults below are exactly the shipped domain
|
||||
# formula. Penalties are magnitudes that the formula subtracts, so every value is
|
||||
# non-negative and a negative one fails startup.
|
||||
TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE=1000
|
||||
TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE=250
|
||||
TELESRV_RATING_WEIGHT_MESSAGE_SENT=1
|
||||
TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY=2
|
||||
TELESRV_RATING_WEIGHT_GIFT_RECEIVED=25
|
||||
TELESRV_RATING_WEIGHT_MODERATION_CASE=150
|
||||
TELESRV_RATING_WEIGHT_SCAM_PENALTY=5000
|
||||
TELESRV_RATING_WEIGHT_FAKE_PENALTY=5000
|
||||
# Upper bound of the activity component so activity alone cannot outweigh Stars
|
||||
# and moderation; 0 leaves it uncapped.
|
||||
TELESRV_RATING_ACTIVITY_CAP=5000
|
||||
|
||||
# Landing URL recorded on a minted collectible (NFT) username when the mint
|
||||
# command carries no explicit URL. Empty derives
|
||||
# <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>. A template may carry the
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
// Command giftcheck 是 Star 礼物端到端验证工具(开发用,非生产组件)。
|
||||
//
|
||||
// 以用户身份登录本地 telesrv,验证 star gift 全链路对 live 服务端:
|
||||
// 1. payments.getStarGifts —— 打印礼物目录。
|
||||
// 2. 从 dialogs 找一个收礼用户(或 -to 指定)。
|
||||
// 3. payments.getPaymentForm(inputInvoiceStarGift) —— 必须返 paymentFormStarGift(XTR+非空 prices)。
|
||||
// 4. payments.sendStarsForm —— 必须返 paymentResult{updates}(含礼物服务消息 + updateStarsBalance)。
|
||||
// 5. payments.getStarsStatus 前后对比验证扣费。
|
||||
//
|
||||
// 用法: go run ./cmd/giftcheck -phone "+8618800000001"
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/mtproxy"
|
||||
"github.com/iamxvbaba/td/mtproxy/obfuscator"
|
||||
"github.com/iamxvbaba/td/proto/codec"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
||||
type obfuscatedResolver struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obf := obfuscator.Obfuscated2(rand.Reader, conn)
|
||||
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
|
||||
}
|
||||
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
|
||||
tc, err := proto.Handshake(obf)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("transport handshake: %w", err)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
|
||||
dcID := flag.Int("dc", 2, "DC id")
|
||||
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
|
||||
apiID := flag.Int("api-id", 1, "api_id")
|
||||
apiHash := flag.String("api-hash", "hash", "api_hash")
|
||||
phone := flag.String("phone", "", "登录手机号")
|
||||
code := flag.String("code", "12345", "开发登录码")
|
||||
toID := flag.Int64("to", 0, "收礼用户 id(0=自动从 dialogs 找)")
|
||||
toHash := flag.Int64("to-hash", 0, "收礼用户 access_hash")
|
||||
flag.Parse()
|
||||
if *phone == "" {
|
||||
fmt.Fprintln(os.Stderr, "缺少 -phone")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
|
||||
if err != nil {
|
||||
logger.Fatal("load RSA key failed", zap.Error(err))
|
||||
}
|
||||
host, portStr, _ := net.SplitHostPort(*addr)
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
|
||||
Resolver: obfuscatedResolver{host: host, port: port},
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
|
||||
Logger: logzap.New(logger.Named("client")),
|
||||
})
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash, Settings: tg.CodeSettings{}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendCode: %w", err)
|
||||
}
|
||||
sc, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("sentCode 类型 = %T", sent)
|
||||
}
|
||||
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: *phone, PhoneCodeHash: sc.PhoneCodeHash, PhoneCode: *code})
|
||||
if err != nil {
|
||||
return fmt.Errorf("signIn: %w", err)
|
||||
}
|
||||
self := authz.(*tg.AuthAuthorization).User.(*tg.User)
|
||||
fmt.Printf("==== 登录: id=%d name=%q ====\n", self.ID, self.FirstName)
|
||||
|
||||
// 1. 目录。
|
||||
gifts := giftCatalog(ctx, raw)
|
||||
if len(gifts) == 0 {
|
||||
return fmt.Errorf("礼物目录为空(animated_emoji 未 seed?)")
|
||||
}
|
||||
gift := gifts[0]
|
||||
fmt.Printf(" 目录 %d 个礼物;选第一个: id=%d stars=%d title=%q sticker=%T\n", len(gifts), gift.ID, gift.Stars, giftTitle(gift), gift.Sticker)
|
||||
|
||||
balBefore := starsBalance(ctx, raw, "扣费前")
|
||||
|
||||
// 2. 收礼用户(-to 指定 id 时从 dialogs 解析其 access_hash;否则取首个 user)。
|
||||
var to *tg.InputPeerUser
|
||||
if *toID != 0 && *toHash != 0 {
|
||||
to = &tg.InputPeerUser{UserID: *toID, AccessHash: *toHash}
|
||||
} else {
|
||||
to = findRecipient(ctx, raw, self.ID, *toID)
|
||||
}
|
||||
if to == nil {
|
||||
return fmt.Errorf("找不到收礼用户(用 -to/-to-hash 指定)")
|
||||
}
|
||||
fmt.Printf(" 收礼用户: id=%d\n", to.UserID)
|
||||
|
||||
inv := &tg.InputInvoiceStarGift{Peer: to, GiftID: gift.ID}
|
||||
|
||||
// 3. getPaymentForm → paymentFormStarGift。
|
||||
formRes, err := raw.PaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getPaymentForm: %w", err)
|
||||
}
|
||||
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
|
||||
if !ok {
|
||||
fmt.Printf(" [FAIL] getPaymentForm 返回 %T,want *PaymentsPaymentFormStarGift(TDesktop 单分支 match)\n", formRes)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" getPaymentForm: paymentFormStarGift form_id=%d currency=%s prices=%d\n", form.FormID, form.Invoice.Currency, len(form.Invoice.Prices))
|
||||
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) == 0 {
|
||||
fmt.Printf(" [FAIL] invoice 须 XTR + 非空 prices\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. sendStarsForm → paymentResult。
|
||||
payRes, err := raw.PaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendStarsForm: %w", err)
|
||||
}
|
||||
pay, ok := payRes.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
fmt.Printf(" [FAIL] sendStarsForm 返回 %T,want *PaymentsPaymentResult(DrKLO 强转)\n", payRes)
|
||||
return nil
|
||||
}
|
||||
inspectGiftUpdates(pay.Updates)
|
||||
|
||||
balAfter := starsBalance(ctx, raw, "扣费后")
|
||||
fmt.Printf("==== 扣费: %d -> %d,差 %d(期望 -%d)====\n", balBefore, balAfter, balBefore-balAfter, gift.Stars)
|
||||
if balBefore-balAfter == gift.Stars {
|
||||
fmt.Println("==== ✅ PASS:star gift 扣费正确 ====")
|
||||
} else {
|
||||
fmt.Println("==== ❌ FAIL:扣费金额不符 ====")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Fatal("run failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func giftCatalog(ctx context.Context, raw *tg.Client) []*tg.StarGift {
|
||||
res, err := raw.PaymentsGetStarGifts(ctx, 0)
|
||||
if err != nil {
|
||||
fmt.Printf(" getStarGifts 失败: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
full, ok := res.(*tg.PaymentsStarGifts)
|
||||
if !ok {
|
||||
fmt.Printf(" getStarGifts 返回 %T\n", res)
|
||||
return nil
|
||||
}
|
||||
out := make([]*tg.StarGift, 0, len(full.Gifts))
|
||||
for _, g := range full.Gifts {
|
||||
if sg, ok := g.(*tg.StarGift); ok {
|
||||
out = append(out, sg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func giftTitle(g *tg.StarGift) string {
|
||||
t, _ := g.GetTitle()
|
||||
return t
|
||||
}
|
||||
|
||||
func starsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
|
||||
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
|
||||
return -1
|
||||
}
|
||||
var bal int64
|
||||
if amt, ok := status.Balance.(*tg.StarsAmount); ok {
|
||||
bal = amt.Amount
|
||||
}
|
||||
fmt.Printf(" [%s] 余额=%d stars\n", label, bal)
|
||||
return bal
|
||||
}
|
||||
|
||||
func findRecipient(ctx context.Context, raw *tg.Client, selfID, targetID int64) *tg.InputPeerUser {
|
||||
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var users []tg.UserClass
|
||||
switch v := dlgs.(type) {
|
||||
case *tg.MessagesDialogs:
|
||||
users = v.Users
|
||||
case *tg.MessagesDialogsSlice:
|
||||
users = v.Users
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
for _, uc := range users {
|
||||
u, ok := uc.(*tg.User)
|
||||
if !ok || u.ID == selfID || u.Bot || u.Self {
|
||||
continue
|
||||
}
|
||||
if targetID != 0 {
|
||||
if u.ID == targetID {
|
||||
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectGiftUpdates(res tg.UpdatesClass) {
|
||||
var ups []tg.UpdateClass
|
||||
switch v := res.(type) {
|
||||
case *tg.Updates:
|
||||
ups = v.Updates
|
||||
case *tg.UpdatesCombined:
|
||||
ups = v.Updates
|
||||
case *tg.UpdateShort:
|
||||
ups = []tg.UpdateClass{v.Update}
|
||||
default:
|
||||
fmt.Printf(" [警告] paymentResult.updates 非 Updates 子类型: %T\n", res)
|
||||
return
|
||||
}
|
||||
hasGiftMsg, hasBalance := false, false
|
||||
for _, up := range ups {
|
||||
switch u := up.(type) {
|
||||
case *tg.UpdateNewMessage:
|
||||
if svc, ok := u.Message.(*tg.MessageService); ok {
|
||||
if a, ok := svc.Action.(*tg.MessageActionStarGift); ok {
|
||||
hasGiftMsg = true
|
||||
gid := int64(0)
|
||||
if sg, ok := a.Gift.(*tg.StarGift); ok {
|
||||
gid = sg.ID
|
||||
}
|
||||
fmt.Printf(" messageActionStarGift: msg_id=%d gift_id=%d\n", u.Message.(*tg.MessageService).ID, gid)
|
||||
}
|
||||
}
|
||||
case *tg.UpdateStarsBalance:
|
||||
hasBalance = true
|
||||
if amt, ok := u.Balance.(*tg.StarsAmount); ok {
|
||||
fmt.Printf(" updateStarsBalance: %d\n", amt.Amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" paymentResult: 含礼物服务消息=%v 含 updateStarsBalance=%v\n", hasGiftMsg, hasBalance)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,255 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestHasRenderableStickerAttribute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []tg.DocumentAttributeClass
|
||||
want bool
|
||||
}{
|
||||
{name: "sticker", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeSticker{}}, want: true},
|
||||
{name: "custom emoji", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeCustomEmoji{}}, want: true},
|
||||
{name: "ordinary file", attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeFilename{}}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hasRenderableStickerAttribute(&tg.Document{Attributes: test.attributes}); got != test.want {
|
||||
t.Fatalf("hasRenderableStickerAttribute() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{name: "gift.tgs", mime: "application/octet-stream", want: ".tgs"},
|
||||
{name: "", mime: "application/x-tgsticker", want: ".tgs"},
|
||||
{name: "unsafe.exe", mime: "video/webm", want: ".webm"},
|
||||
{name: "", mime: "application/octet-stream", want: ".bin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := documentExtension(test.name, test.mime); got != test.want {
|
||||
t.Errorf("documentExtension(%q, %q) = %q, want %q", test.name, test.mime, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedBuffer(t *testing.T) {
|
||||
buffer := &boundedBuffer{max: 4}
|
||||
if _, err := buffer.Write([]byte("abc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written, err := buffer.Write([]byte("def")); err == nil || written != 1 {
|
||||
t.Fatalf("overflow write = (%d, %v), want (1, error)", written, err)
|
||||
}
|
||||
if !bytes.Equal(buffer.Bytes(), []byte("abcd")) {
|
||||
t.Fatalf("buffer = %q, want abcd", buffer.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
size int64
|
||||
want int
|
||||
}{
|
||||
{size: 1, want: 4 << 10},
|
||||
{size: (4 << 10) - 1, want: 4 << 10},
|
||||
{size: 4 << 10, want: 8 << 10},
|
||||
{size: 48_632, want: 64 << 10},
|
||||
{size: (512 << 10) - 1, want: 512 << 10},
|
||||
{size: 512 << 10, want: 512 << 10},
|
||||
{size: 1 << 20, want: 512 << 10},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := downloadPartSize(test.size); got != test.want {
|
||||
t.Errorf("downloadPartSize(%d) = %d, want %d", test.size, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartSizeUsesNonPreciseChunkLadder(t *testing.T) {
|
||||
const oneMiB = 1 << 20
|
||||
for size := int64(1); size < 512<<10; size += 997 {
|
||||
partSize := downloadPartSize(size)
|
||||
if partSize < 4<<10 || partSize > 512<<10 || partSize%(4<<10) != 0 {
|
||||
t.Fatalf("downloadPartSize(%d) = %d is outside the valid 4 KiB-aligned range", size, partSize)
|
||||
}
|
||||
if oneMiB%partSize != 0 {
|
||||
t.Fatalf("downloadPartSize(%d) = %d does not divide a 1 MiB request window", size, partSize)
|
||||
}
|
||||
if int64(partSize) <= size {
|
||||
t.Fatalf("downloadPartSize(%d) = %d does not cover the known-size single chunk", size, partSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedMissingThumbs(t *testing.T) {
|
||||
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !missingThumbAllowed(allowed, 5417911440709285239, "photo", "m") || !missingThumbAllowed(allowed, 42, "video", "v") {
|
||||
t.Fatalf("allowed = %v", allowed)
|
||||
}
|
||||
for _, invalid := range []string{"bad", "0:photo:m", "1:audio:m", "1:photo:?"} {
|
||||
if _, err := parseAllowedMissingThumbs(invalid); err == nil {
|
||||
t.Errorf("parseAllowedMissingThumbs(%q) succeeded", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "resource.bin"), []byte("gift"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, reused, err := existingArtifact(root, "resource.bin", 4, 16)
|
||||
if err != nil || !reused || string(data) != "gift" {
|
||||
t.Fatalf("existingArtifact(valid) = (%q, %v, %v)", data, reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "resource.bin", 5, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(size mismatch) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
if _, reused, err := existingArtifact(root, "missing.bin", -1, 16); err != nil || reused {
|
||||
t.Fatalf("existingArtifact(missing) = (reused=%v, err=%v)", reused, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTLArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var encoded bin.Buffer
|
||||
if err := (&tg.PaymentsStarGiftUpgradeAttributes{}).Encode(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "attributes.tl"), encoded.Buf, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded tg.PaymentsStarGiftUpgradeAttributes
|
||||
artifact, err := readTLArtifact(root, "attributes.tl", &decoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if artifact.Kind != "tl" || artifact.Size != int64(len(encoded.Buf)) || artifact.SHA256 == "" {
|
||||
t.Fatalf("artifact = %+v", artifact)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "trailing.tl"), append(append([]byte(nil), encoded.Buf...), 0xff), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readTLArtifact(root, "trailing.tl", &tg.PaymentsStarGiftUpgradeAttributes{}); err == nil {
|
||||
t.Fatal("expected trailing-byte error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeableGiftIDs(t *testing.T) {
|
||||
classes := []tg.StarGiftClass{
|
||||
&tg.StarGift{ID: 1, UpgradeStars: 10},
|
||||
&tg.StarGift{ID: 2, UpgradeVariants: 3},
|
||||
&tg.StarGift{ID: 3},
|
||||
&tg.StarGiftUnique{ID: 4, GiftID: 1},
|
||||
}
|
||||
got := collectUpgradeableGiftIDs(classes)
|
||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("collectUpgradeableGiftIDs() = %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributes(t *testing.T) {
|
||||
modelDoc := testGiftDocument(101)
|
||||
patternDoc := testGiftDocument(102)
|
||||
model := &tg.StarGiftAttributeModel{
|
||||
Name: "Crafted model",
|
||||
Document: modelDoc,
|
||||
Rarity: &tg.StarGiftAttributeRarityLegendary{},
|
||||
}
|
||||
model.SetCrafted(true)
|
||||
result := &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
model,
|
||||
&tg.StarGiftAttributePattern{Name: "Pattern", Document: patternDoc, Rarity: &tg.StarGiftAttributeRarity{Permille: 125}},
|
||||
&tg.StarGiftAttributeBackdrop{Name: "Backdrop", BackdropID: 7, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, Rarity: &tg.StarGiftAttributeRarityEpic{}},
|
||||
}}
|
||||
added := make(map[int64]string)
|
||||
set, err := collectUpgradeAttributes(99, result, fileArtifact{Path: "upgrade-attributes/99.tl"}, func(class tg.DocumentClass, purpose string) (*tg.Document, error) {
|
||||
doc, ok := class.(*tg.Document)
|
||||
if !ok {
|
||||
return nil, errors.New("not a document")
|
||||
}
|
||||
added[doc.ID] = purpose
|
||||
return doc, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if set.AttributeCount != 3 || len(set.Models) != 1 || len(set.Patterns) != 1 || len(set.Backdrops) != 1 {
|
||||
t.Fatalf("unexpected attribute counts: %+v", set)
|
||||
}
|
||||
if !set.Models[0].Crafted || set.Models[0].Rarity.Kind != "legendary" {
|
||||
t.Fatalf("model = %+v", set.Models[0])
|
||||
}
|
||||
if set.Patterns[0].Rarity.Permille == nil || *set.Patterns[0].Rarity.Permille != 125 {
|
||||
t.Fatalf("pattern rarity = %+v", set.Patterns[0].Rarity)
|
||||
}
|
||||
if set.Backdrops[0].PatternColor != 3 || set.Backdrops[0].Rarity.Kind != "epic" {
|
||||
t.Fatalf("backdrop = %+v", set.Backdrops[0])
|
||||
}
|
||||
if len(set.DocumentIDs) != 2 || len(added) != 2 {
|
||||
t.Fatalf("document ids = %v, added = %v", set.DocumentIDs, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUpgradeAttributesRejectsInstanceOnlyAttribute(t *testing.T) {
|
||||
_, err := collectUpgradeAttributes(99, &tg.PaymentsStarGiftUpgradeAttributes{Attributes: []tg.StarGiftAttributeClass{
|
||||
&tg.StarGiftAttributeOriginalDetails{},
|
||||
}}, fileArtifact{}, func(tg.DocumentClass, string) (*tg.Document, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported-constructor error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRarityKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
class tg.StarGiftAttributeRarityClass
|
||||
kind string
|
||||
}{
|
||||
{class: &tg.StarGiftAttributeRarityUncommon{}, kind: "uncommon"},
|
||||
{class: &tg.StarGiftAttributeRarityRare{}, kind: "rare"},
|
||||
{class: &tg.StarGiftAttributeRarityEpic{}, kind: "epic"},
|
||||
{class: &tg.StarGiftAttributeRarityLegendary{}, kind: "legendary"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := collectRarity(test.class)
|
||||
if err != nil || got.Kind != test.kind || got.ConstructorID == "" {
|
||||
t.Fatalf("collectRarity(%T) = (%+v, %v)", test.class, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := collectRarity(nil); err == nil {
|
||||
t.Fatal("expected nil-rarity error")
|
||||
}
|
||||
}
|
||||
|
||||
func testGiftDocument(id int64) *tg.Document {
|
||||
return &tg.Document{
|
||||
ID: id,
|
||||
Size: 1,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Attributes: []tg.DocumentAttributeClass{
|
||||
&tg.DocumentAttributeCustomEmoji{Alt: "gift"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
// Command starcheck 是 Stars 付费 reaction 端到端验证工具(开发用,非生产组件)。
|
||||
//
|
||||
// 它以用户身份(开发码 12345)登录本地 telesrv,验证 Stars 本地账本与付费 reaction:
|
||||
// 1. payments.getStarsStatus —— 打印当前余额(Phase 1:惰性首读授予后应 >0)。
|
||||
// 2. 自动从 dialogs 找一个广播频道 + 其顶部消息(或用 -channel-id/-access-hash/-msg-id 指定)。
|
||||
// 3. messages.sendPaidReaction —— 发付费 reaction,打印返回的 Updates 结构
|
||||
// (Phase 2 崩溃约束:必须是 *tg.Updates,应含 updateMessageReactions + updateStarsBalance)。
|
||||
// 4. payments.getStarsStatus —— 再次打印余额,验证已扣 -count。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/starcheck -phone "+8618800000001" -count 50
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/mtproxy"
|
||||
"github.com/iamxvbaba/td/mtproxy/obfuscator"
|
||||
"github.com/iamxvbaba/td/proto/codec"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/mtprotoedge"
|
||||
)
|
||||
|
||||
type obfuscatedResolver struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obf := obfuscator.Obfuscated2(rand.Reader, conn)
|
||||
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
|
||||
}
|
||||
proto := transport.NewProtocol(func() transport.Codec {
|
||||
return codec.NoHeader{Codec: codec.Intermediate{}}
|
||||
})
|
||||
tc, err := proto.Handshake(obf)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("transport handshake: %w", err)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
|
||||
return r.dial(ctx, dc)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
|
||||
dcID := flag.Int("dc", 2, "DC id")
|
||||
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径")
|
||||
apiID := flag.Int("api-id", 1, "api_id")
|
||||
apiHash := flag.String("api-hash", "hash", "api_hash")
|
||||
phone := flag.String("phone", "", "登录手机号,如 +8618800000001")
|
||||
code := flag.String("code", "12345", "开发登录码")
|
||||
count := flag.Int("count", 50, "付费 reaction 星数")
|
||||
channelID := flag.Int64("channel-id", 0, "指定频道 id(0=自动从 dialogs 找广播频道)")
|
||||
accessHash := flag.Int64("access-hash", 0, "指定频道 access_hash")
|
||||
msgID := flag.Int("msg-id", 0, "指定消息 id(0=用频道顶部消息)")
|
||||
flag.Parse()
|
||||
|
||||
if *phone == "" {
|
||||
fmt.Fprintln(os.Stderr, "缺少 -phone")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
|
||||
if err != nil {
|
||||
logger.Fatal("load RSA key failed", zap.Error(err))
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(*addr)
|
||||
if err != nil {
|
||||
logger.Fatal("parse address failed", zap.Error(err))
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
|
||||
Resolver: obfuscatedResolver{host: host, port: port},
|
||||
DCList: dcs.List{Options: []tg.DCOption{
|
||||
{ID: *dcID, IPAddress: host, Port: port, Static: true},
|
||||
}},
|
||||
Logger: logzap.New(logger.Named("client")),
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
|
||||
// 1. 用户登录(开发码)。
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{
|
||||
PhoneNumber: *phone, APIID: *apiID, APIHash: *apiHash,
|
||||
Settings: tg.CodeSettings{},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendCode: %w", err)
|
||||
}
|
||||
sentCode, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("sentCode 类型 = %T", sent)
|
||||
}
|
||||
authz, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{
|
||||
PhoneNumber: *phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: *code,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("signIn: %w", err)
|
||||
}
|
||||
a, ok := authz.(*tg.AuthAuthorization)
|
||||
if !ok {
|
||||
return fmt.Errorf("authorization 类型 = %T", authz)
|
||||
}
|
||||
self, _ := a.User.(*tg.User)
|
||||
fmt.Printf("==== 登录成功: id=%d name=%q phone=%s ====\n", self.ID, self.FirstName, *phone)
|
||||
|
||||
// 2. 余额(before)。
|
||||
balBefore := printStarsBalance(ctx, raw, "扣费前")
|
||||
|
||||
// 3. 解析目标频道 + 消息。
|
||||
var peer *tg.InputPeerChannel
|
||||
mid := *msgID
|
||||
if *channelID != 0 {
|
||||
peer = &tg.InputPeerChannel{ChannelID: *channelID, AccessHash: *accessHash}
|
||||
} else {
|
||||
ch, topMsg, err := findBroadcastChannel(ctx, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peer = &tg.InputPeerChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}
|
||||
if mid == 0 {
|
||||
mid = topMsg
|
||||
}
|
||||
fmt.Printf(" 目标频道: id=%d title=%q access_hash=%d top_msg=%d\n", ch.ID, ch.Title, ch.AccessHash, mid)
|
||||
}
|
||||
if mid == 0 {
|
||||
return fmt.Errorf("无可用消息 id(频道无消息?用 -msg-id 指定)")
|
||||
}
|
||||
|
||||
// 4. 发付费 reaction。
|
||||
rid, _ := randInt64()
|
||||
fmt.Printf(">> sendPaidReaction: channel=%d msg=%d count=%d\n", peer.ChannelID, mid, *count)
|
||||
res, err := raw.MessagesSendPaidReaction(ctx, &tg.MessagesSendPaidReactionRequest{
|
||||
Peer: peer, MsgID: mid, Count: *count, RandomID: rid,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendPaidReaction: %w", err)
|
||||
}
|
||||
inspectPaidReactionUpdates(res)
|
||||
|
||||
// 5. 余额(after)。
|
||||
balAfter := printStarsBalance(ctx, raw, "扣费后")
|
||||
fmt.Printf("==== 扣费校验: %d -> %d,差 %d(期望 -%d)====\n", balBefore, balAfter, balBefore-balAfter, *count)
|
||||
if balBefore-balAfter == int64(*count) {
|
||||
fmt.Println("==== ✅ PASS:付费 reaction 扣费正确 ====")
|
||||
} else {
|
||||
fmt.Println("==== ❌ FAIL:扣费金额不符 ====")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Fatal("run failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func printStarsBalance(ctx context.Context, raw *tg.Client, label string) int64 {
|
||||
status, err := raw.PaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
fmt.Printf(" [%s] getStarsStatus 失败: %v\n", label, err)
|
||||
return -1
|
||||
}
|
||||
amount, _ := status.Balance.(*tg.StarsAmount)
|
||||
var bal int64
|
||||
if amount != nil {
|
||||
bal = amount.Amount
|
||||
}
|
||||
fmt.Printf(" [%s] 余额=%d stars (balance 类型=%T, chats=%d users=%d)\n", label, bal, status.Balance, len(status.Chats), len(status.Users))
|
||||
return bal
|
||||
}
|
||||
|
||||
// findBroadcastChannel 从 dialogs 找第一个广播频道 + 其顶部消息 id。
|
||||
func findBroadcastChannel(ctx context.Context, raw *tg.Client) (*tg.Channel, int, error) {
|
||||
dlgs, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
|
||||
OffsetPeer: &tg.InputPeerEmpty{}, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("getDialogs: %w", err)
|
||||
}
|
||||
var chats []tg.ChatClass
|
||||
var dialogs []tg.DialogClass
|
||||
switch v := dlgs.(type) {
|
||||
case *tg.MessagesDialogs:
|
||||
chats, dialogs = v.Chats, v.Dialogs
|
||||
case *tg.MessagesDialogsSlice:
|
||||
chats, dialogs = v.Chats, v.Dialogs
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("dialogs 类型 = %T", dlgs)
|
||||
}
|
||||
topByChannel := make(map[int64]int)
|
||||
for _, d := range dialogs {
|
||||
dd, ok := d.(*tg.Dialog)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pc, ok := dd.Peer.(*tg.PeerChannel); ok {
|
||||
topByChannel[pc.ChannelID] = dd.TopMessage
|
||||
}
|
||||
}
|
||||
for _, c := range chats {
|
||||
ch, ok := c.(*tg.Channel)
|
||||
if !ok || !ch.Broadcast || ch.Megagroup {
|
||||
continue
|
||||
}
|
||||
return ch, topByChannel[ch.ID], nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("dialogs 中无广播频道")
|
||||
}
|
||||
|
||||
// inspectPaidReactionUpdates 检查返回的 Updates 是否合法且含 updateMessageReactions/updateStarsBalance。
|
||||
func inspectPaidReactionUpdates(res tg.UpdatesClass) {
|
||||
fmt.Printf(" 返回类型=%T\n", res)
|
||||
var ups []tg.UpdateClass
|
||||
switch v := res.(type) {
|
||||
case *tg.Updates:
|
||||
ups = v.Updates
|
||||
case *tg.UpdatesCombined:
|
||||
ups = v.Updates
|
||||
case *tg.UpdateShort:
|
||||
ups = []tg.UpdateClass{v.Update}
|
||||
default:
|
||||
fmt.Printf(" [警告] 返回非 Updates 子类型(DrKLO 会 ClassCastException 崩溃)\n")
|
||||
return
|
||||
}
|
||||
hasReactions, hasBalance := false, false
|
||||
for _, up := range ups {
|
||||
switch u := up.(type) {
|
||||
case *tg.UpdateMessageReactions:
|
||||
hasReactions = true
|
||||
paid := 0
|
||||
for _, rc := range u.Reactions.Results {
|
||||
if _, ok := rc.Reaction.(*tg.ReactionPaid); ok {
|
||||
paid = rc.Count
|
||||
}
|
||||
}
|
||||
reactors, _ := u.Reactions.GetTopReactors()
|
||||
fmt.Printf(" updateMessageReactions: msg=%d paid_count=%d top_reactors=%d\n", u.MsgID, paid, len(reactors))
|
||||
case *tg.UpdateStarsBalance:
|
||||
hasBalance = true
|
||||
amount, _ := u.Balance.(*tg.StarsAmount)
|
||||
var b int64
|
||||
if amount != nil {
|
||||
b = amount.Amount
|
||||
}
|
||||
fmt.Printf(" updateStarsBalance: balance=%d\n", b)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 合法 Updates=true, 含 updateMessageReactions=%v, 含 updateStarsBalance=%v\n", hasReactions, hasBalance)
|
||||
}
|
||||
|
||||
func randInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return time.Now().UnixNano(), err
|
||||
}
|
||||
v := int64(binary.LittleEndian.Uint64(b[:]))
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -24,14 +24,11 @@ const (
|
|||
channelListDefaultLimit = 50
|
||||
channelListMaxLimit = 100
|
||||
messagePageLimit = 100
|
||||
// Collectible username and account rating pages. The bounds mirror the
|
||||
// use-case layer, so a table page costs the same whichever surface asks.
|
||||
// Collectible username pages. The bounds mirror the use-case layer, so a
|
||||
// table page costs the same whichever surface asks.
|
||||
collectibleListDefaultLimit = 50
|
||||
collectibleListMaxLimit = 200
|
||||
collectibleTransferLimit = 50
|
||||
ratingListDefaultLimit = 50
|
||||
ratingListMaxLimit = 200
|
||||
ratingEventLimit = 50
|
||||
// Verification review queue pages. The bounds mirror app/verification, so the
|
||||
// panel and the admin API page the queue identically.
|
||||
verificationListDefaultLimit = 50
|
||||
|
|
@ -123,8 +120,6 @@ type AccountDetail struct {
|
|||
Fake bool
|
||||
Support bool
|
||||
Bot bool
|
||||
StarsBalance int64
|
||||
StarsGranted bool
|
||||
Restriction RestrictionRow
|
||||
HasRestriction bool
|
||||
Authorizations []AuthorizationRow
|
||||
|
|
@ -237,65 +232,10 @@ type ChannelDetail struct {
|
|||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
type StarGiftRow struct {
|
||||
GiftID int64 `json:"GiftID,string"`
|
||||
RevisionID int64 `json:"RevisionID,string"`
|
||||
Revision int
|
||||
Title string
|
||||
Stars int64 `json:"Stars,string"`
|
||||
ConvertStars int64 `json:"ConvertStars,string"`
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
SourceName string
|
||||
SourceFormat string
|
||||
AnimationSHA string
|
||||
AnimationSize int64 `json:"AnimationSize,string"`
|
||||
Width int
|
||||
Height int
|
||||
FrameRate float64
|
||||
ReceivedCount int64 `json:"ReceivedCount,string"`
|
||||
CreatedBy string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *readStore) ListStarGifts(ctx context.Context) ([]StarGiftRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.gift_id, r.id, r.revision, r.title, r.stars, r.convert_stars,
|
||||
c.enabled, c.sort_order, r.document_id, r.source_name, r.source_format,
|
||||
encode(r.animation_sha256, 'hex'), d.size, r.width, r.height, r.frame_rate,
|
||||
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id = c.gift_id),
|
||||
r.created_by, c.updated_at
|
||||
FROM star_gift_catalog c
|
||||
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
|
||||
JOIN documents d ON d.id = r.document_id
|
||||
ORDER BY c.sort_order, c.gift_id
|
||||
LIMIT $1`, domain.MaxStarGiftCatalogSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list star gifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StarGiftRow, 0)
|
||||
for rows.Next() {
|
||||
var row StarGiftRow
|
||||
if err := rows.Scan(
|
||||
&row.GiftID, &row.RevisionID, &row.Revision, &row.Title, &row.Stars, &row.ConvertStars,
|
||||
&row.Enabled, &row.SortOrder, &row.DocumentID, &row.SourceName, &row.SourceFormat,
|
||||
&row.AnimationSHA, &row.AnimationSize, &row.Width, &row.Height, &row.FrameRate,
|
||||
&row.ReceivedCount, &row.CreatedBy, &row.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type StickerSetRow struct {
|
||||
// ID must round-trip through JSON as a string: these are Telegram-style
|
||||
// snowflake ids (18-19 digits), well past JS's 2^53 safe-integer limit, so
|
||||
// a plain JSON number gets silently rounded by the browser (see StarGiftRow
|
||||
// for the same fix applied to gift ids).
|
||||
// a plain JSON number gets silently rounded by the browser.
|
||||
ID int64 `json:"ID,string"`
|
||||
ShortName string
|
||||
Title string
|
||||
|
|
@ -986,17 +926,15 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
u.about, u.last_seen_at, u.verified, u.scam, u.fake, u.support, u.is_bot,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||
FROM users u
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN stars_balances sb ON sb.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
WHERE u.id = $1`, userID).Scan(
|
||||
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
|
||||
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Scam, &out.Fake, &out.Support, &out.Bot,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.StarsBalance, &out.StarsGranted, &out.Account.Username,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username,
|
||||
&out.Account.Collectibles,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -1674,246 +1612,6 @@ LIMIT $2`, collectibleID, collectibleTransferLimit)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AccountRatingRow is one user's composite rating projection with the account
|
||||
// resolved for display. The score and every component are int64 decimal strings
|
||||
// for the same exactness reason as the collectible amounts.
|
||||
type AccountRatingRow struct {
|
||||
UserID int64 `json:"UserID,string"`
|
||||
Username string
|
||||
FirstName string
|
||||
Level int
|
||||
Stars int64 `json:"Stars,string"`
|
||||
CurrentLevelStars int64 `json:"CurrentLevelStars,string"`
|
||||
NextLevelStars int64 `json:"NextLevelStars,string"`
|
||||
HasNextLevel bool
|
||||
StarsComponent int64 `json:"StarsComponent,string"`
|
||||
ActivityComponent int64 `json:"ActivityComponent,string"`
|
||||
PenaltyComponent int64 `json:"PenaltyComponent,string"`
|
||||
ManualComponent int64 `json:"ManualComponent,string"`
|
||||
PendingStars int64 `json:"PendingStars,string"`
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64 `json:"Version,string"`
|
||||
// Computed is false for an account that has no stored projection yet. The
|
||||
// detail view still renders it, so the operator can trigger the first
|
||||
// recompute instead of facing a dead end.
|
||||
Computed bool
|
||||
}
|
||||
|
||||
// AccountRatingEventRow is one contribution ledger entry.
|
||||
type AccountRatingEventRow struct {
|
||||
ID int64 `json:"ID,string"`
|
||||
UserID int64 `json:"UserID,string"`
|
||||
Kind string
|
||||
Amount int64 `json:"Amount,string"`
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AccountRatingDetail is the projection plus the ledger that explains it.
|
||||
type AccountRatingDetail struct {
|
||||
Rating AccountRatingRow
|
||||
Events []AccountRatingEventRow
|
||||
}
|
||||
|
||||
const accountRatingSelectColumns = `r.user_id,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(u.first_name, ''),
|
||||
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`
|
||||
|
||||
const accountRatingJoins = `
|
||||
FROM account_rating r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = r.user_id AND p.editable`
|
||||
|
||||
func scanAccountRatingRow(scan func(dest ...any) error, item *AccountRatingRow) error {
|
||||
// next_level_stars and pending_date are nullable: the first is NULL at the top
|
||||
// level, the second whenever no score is parked.
|
||||
var nextLevelStars *int64
|
||||
var pendingDate *time.Time
|
||||
if err := scan(
|
||||
&item.UserID, &item.Username, &item.FirstName,
|
||||
&item.Level, &item.Stars, &item.CurrentLevelStars, &nextLevelStars,
|
||||
&item.StarsComponent, &item.ActivityComponent, &item.PenaltyComponent, &item.ManualComponent,
|
||||
&item.PendingStars, &pendingDate, &item.ComputedAt, &item.UpdatedAt, &item.Version,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// A NULL next threshold is the maxed-out level: the TL flag is omitted, so the
|
||||
// panel must render "no next level" instead of a next level of zero.
|
||||
item.HasNextLevel = nextLevelStars != nil
|
||||
if nextLevelStars != nil {
|
||||
item.NextLevelStars = *nextLevelStars
|
||||
}
|
||||
if pendingDate != nil {
|
||||
item.PendingDate = pendingDate.UTC()
|
||||
}
|
||||
item.Computed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccountRatings pages the leaderboard. Ordering and the keyset predicate
|
||||
// mirror the rating store exactly -- (level DESC, stars DESC, user_id) with the
|
||||
// cursor row resolved from beforeID -- so both surfaces page identically.
|
||||
// ListAccountRatings pages the leaderboard. query is a free-text operator search:
|
||||
// it matches a username prefix (editable or collectible), a first/last name
|
||||
// prefix, and -- when the term is numeric -- the user id, so an operator can find
|
||||
// an account the same way they do on the accounts tab.
|
||||
func (s *readStore) ListAccountRatings(ctx context.Context, minLevel int, userID, beforeID int64, limit int, query string) ([]AccountRatingRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = ratingListDefaultLimit
|
||||
}
|
||||
if limit > ratingListMaxLimit {
|
||||
limit = ratingListMaxLimit
|
||||
}
|
||||
if minLevel < 0 {
|
||||
minLevel = 0
|
||||
}
|
||||
if minLevel > domain.MaxAccountRatingLevel {
|
||||
minLevel = domain.MaxAccountRatingLevel
|
||||
}
|
||||
query = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(query), "@"))
|
||||
pattern := ""
|
||||
queryUserID := int64(0)
|
||||
if query != "" {
|
||||
pattern = strings.ToLower(escapeLikePattern(query)) + "%"
|
||||
if parsed, err := strconv.ParseInt(query, 10, 64); err == nil && parsed > 0 {
|
||||
queryUserID = parsed
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.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::bigint <> 0 AND user_id = $3
|
||||
)
|
||||
SELECT `+accountRatingSelectColumns+accountRatingJoins+`
|
||||
LEFT JOIN cursor_row c ON true
|
||||
WHERE r.level >= $1
|
||||
AND ($2::bigint = 0 OR r.user_id = $2)
|
||||
AND ($5::text = '' OR (
|
||||
($6::bigint <> 0 AND r.user_id = $6)
|
||||
OR lower(COALESCE(u.username, '')) LIKE $5
|
||||
OR lower(COALESCE(u.first_name, '')) LIKE $5
|
||||
OR lower(COALESCE(u.last_name, '')) LIKE $5
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM peer_usernames pu
|
||||
WHERE pu.peer_type = 'user' AND pu.peer_id = r.user_id
|
||||
AND pu.username_lower LIKE $5
|
||||
)
|
||||
))
|
||||
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`, minLevel, userID, beforeID, limit+1, pattern, queryUserID)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRatingRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item AccountRatingRow
|
||||
if err := scanAccountRatingRow(rows.Scan, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// AccountRatingDetail returns one user's projection with its contribution
|
||||
// ledger.
|
||||
//
|
||||
// An account that exists but was never computed is answered with a zero-valued
|
||||
// projection carrying Computed=false, because the recompute command lives on this
|
||||
// very page: reporting "not found" for a real account would leave the operator
|
||||
// with no way to create the first projection. Only an unknown account is a 404.
|
||||
func (s *readStore) AccountRatingDetail(ctx context.Context, userID int64) (AccountRatingDetail, error) {
|
||||
var out AccountRatingDetail
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT `+accountRatingSelectColumns+accountRatingJoins+`
|
||||
WHERE r.user_id = $1`, userID)
|
||||
err := scanAccountRatingRow(row.Scan, &out.Rating)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
placeholder, uncomputedErr := s.uncomputedAccountRating(ctx, userID)
|
||||
if uncomputedErr != nil {
|
||||
return out, uncomputedErr
|
||||
}
|
||||
out.Rating = placeholder
|
||||
default:
|
||||
return out, fmt.Errorf("get account rating: %w", err)
|
||||
}
|
||||
events, err := s.accountRatingEvents(ctx, userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Events = events
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uncomputedAccountRating renders the projection an account would start from,
|
||||
// derived through the same threshold policy the store persists, so the panel's
|
||||
// level maths does not have to special-case a missing row.
|
||||
func (s *readStore) uncomputedAccountRating(ctx context.Context, userID int64) (AccountRatingRow, error) {
|
||||
var row AccountRatingRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name
|
||||
FROM users u
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
WHERE u.id = $1`, userID).Scan(&row.UserID, &row.Username, &row.FirstName)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, errReadNotFound
|
||||
}
|
||||
return row, fmt.Errorf("get account for rating: %w", err)
|
||||
}
|
||||
level, current, next, hasNext := domain.AccountRatingLevelForStars(0)
|
||||
row.Level = level
|
||||
row.CurrentLevelStars = current
|
||||
row.NextLevelStars = next
|
||||
row.HasNextLevel = hasNext
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *readStore) accountRatingEvents(ctx context.Context, userID int64) ([]AccountRatingEventRow, error) {
|
||||
rows, err := s.pool.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, ratingEventLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account rating events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]AccountRatingEventRow, 0)
|
||||
for rows.Next() {
|
||||
var item AccountRatingEventRow
|
||||
if err := rows.Scan(&item.ID, &item.UserID, &item.Kind, &item.Amount, &item.Reason, &item.Actor, &item.CommandKey, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Official platform verification review queue.
|
||||
//
|
||||
// The application record is the audit subject and is read here directly, with the
|
||||
|
|
|
|||
|
|
@ -69,18 +69,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
|
||||
mux.Handle("GET /api/default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftsAPI)))
|
||||
mux.Handle("GET /api/default-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
|
||||
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/account-ratings", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingsAPI)))
|
||||
mux.Handle("GET /api/account-ratings/{user_id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||
|
|
@ -91,7 +81,6 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
|
||||
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
||||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
|
|
@ -116,14 +105,6 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-default-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportDefaultStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-all-default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllDefaultStarGiftsAPI)))
|
||||
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
|
||||
mux.Handle("POST /api/actions/import-all-official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllOfficialStarGiftsAPI)))
|
||||
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
|
||||
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
|
||||
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
|
||||
|
|
@ -134,13 +115,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/recompute-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleRecomputeAccountRatingAPI)))
|
||||
mux.Handle("POST /api/actions/adjust-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleAdjustAccountRatingAPI)))
|
||||
// Official platform verification. Every route needs verification.review;
|
||||
// clearing an existing badge needs verification.revoke on top of it.
|
||||
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
|
||||
|
|
@ -286,19 +264,6 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListStarGifts(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
|
||||
}
|
||||
|
||||
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -366,86 +331,6 @@ func (s *server) handleEmojiAnimationAPI(w http.ResponseWriter, r *http.Request)
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
fmt.Sprintf("%s/v1/gifts/%d/animation", s.cfg.AdminAPIURL, giftID), nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
|
||||
if err != nil || len(raw) > 4<<20 {
|
||||
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeAPIError(w, resp.StatusCode, string(raw))
|
||||
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) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/default-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.Atoi(id); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
kind := r.PathValue("kind")
|
||||
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 || (kind != "model" && kind != "pattern") {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid collectible animation")
|
||||
return
|
||||
}
|
||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles/%s/%d/animation", giftID, kind, attributeID), 4<<20)
|
||||
}
|
||||
|
||||
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
|
||||
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "private, max-age=30")
|
||||
}
|
||||
|
|
@ -1254,28 +1139,6 @@ func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type grantStarsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func (s *server) handleGrantStarsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body grantStarsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.GrantStarsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-stars"),
|
||||
UserID: body.UserID,
|
||||
Amount: body.Amount,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-stars", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setVerifiedAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -1766,287 +1629,6 @@ func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportStarGiftAPI(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 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body importStarGiftAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(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 {
|
||||
writeAPIError(w, http.StatusBadRequest, "animation file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.ImportStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-gift"),
|
||||
GiftID: body.GiftID,
|
||||
Title: body.Title,
|
||||
Stars: body.Stars,
|
||||
ConvertStars: body.ConvertStars,
|
||||
Enabled: body.Enabled,
|
||||
SortOrder: body.SortOrder,
|
||||
FileName: header.Filename,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/gifts/import", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importDefaultStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importDefaultStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportDefaultStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
|
||||
ID: body.ID,
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importAllDefaultStarGiftsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importAllDefaultStarGiftsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ImportAllDefaultStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importOfficialStarGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SourceGiftID string `json:"source_gift_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars,string"`
|
||||
ConvertStars int64 `json:"convert_stars,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IncludeCollectible bool `json:"include_collectible"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importOfficialStarGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
|
||||
return
|
||||
}
|
||||
req := admin.ImportOfficialStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
|
||||
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
|
||||
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
|
||||
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
|
||||
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type importAllOfficialStarGiftsAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body importAllOfficialStarGiftsAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ImportAllOfficialStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type publishStarGiftCollectiblesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,string"`
|
||||
SupplyTotal int `json:"supply_total"`
|
||||
SlugPrefix string `json:"slug_prefix"`
|
||||
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
|
||||
Patterns []admin.StarGiftCollectibleAnimationUpload `json:"patterns"`
|
||||
Backdrops []admin.StarGiftCollectibleBackdropInput `json:"backdrops"`
|
||||
}
|
||||
|
||||
func (s *server) handlePublishStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
giftID, err := strconv.ParseInt(r.URL.Query().Get("gift_id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body publishStarGiftCollectiblesAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Models)+len(body.Patterns) > 128 {
|
||||
writeAPIError(w, http.StatusBadRequest, "too many collectible animation files")
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(body.Models)+len(body.Patterns))
|
||||
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
|
||||
upload.FileKey = strings.TrimSpace(upload.FileKey)
|
||||
if upload.FileKey == "" {
|
||||
return errors.New("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 body.Models {
|
||||
if err := load(&body.Models[i]); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range body.Patterns {
|
||||
if err := load(&body.Patterns[i]); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
req := admin.PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "publish-gift-collectibles"),
|
||||
GiftID: giftID, UpgradeStars: body.UpgradeStars, SupplyTotal: body.SupplyTotal,
|
||||
SlugPrefix: body.SlugPrefix, Models: body.Models, Patterns: body.Patterns, Backdrops: body.Backdrops,
|
||||
}
|
||||
result, err := s.callAdminCollectibleMultipart(r.Context(), fmt.Sprintf("/v1/gifts/%d/collectibles/publish", giftID), req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setStarGiftEnabledAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStarGiftEnabledAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStarGiftEnabledAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStarGiftEnabledRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-enabled"),
|
||||
GiftID: body.GiftID, Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-enabled", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setStarGiftSortOrderAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setStarGiftSortOrderAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetStarGiftSortOrderRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-sort-order"),
|
||||
GiftID: body.GiftID, SortOrder: body.SortOrder,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-sort-order", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type renameStickerSetAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -2234,7 +1816,7 @@ func (s *server) handleStickerSetDocumentsAPI(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
// handleStickerDocumentAnimationAPI proxies one document's decompressed Lottie
|
||||
// JSON from the real telesrv admin API — mirrors handleStarGiftAnimationAPI.
|
||||
// JSON from the real telesrv admin API.
|
||||
func (s *server) handleStickerDocumentAnimationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || documentID <= 0 {
|
||||
|
|
@ -2319,44 +1901,6 @@ func (s *server) handleSetStickerSetSortOrderAPI(w http.ResponseWriter, r *http.
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type giveGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
HideName bool `json:"hide_name"`
|
||||
Message string `json:"message"`
|
||||
Upgrade bool `json:"upgrade"`
|
||||
ModelAttributeID int64 `json:"model_attribute_id,string"`
|
||||
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
||||
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
||||
}
|
||||
|
||||
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body giveGiftAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.GiveGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "give-gift"),
|
||||
SenderUserID: body.SenderUserID,
|
||||
UserID: body.UserID,
|
||||
ChannelID: body.ChannelID,
|
||||
GiftID: body.GiftID,
|
||||
HideName: body.HideName,
|
||||
Message: body.Message,
|
||||
Upgrade: body.Upgrade,
|
||||
ModelAttributeID: body.ModelAttributeID,
|
||||
PatternAttributeID: body.PatternAttributeID,
|
||||
BackdropAttributeID: body.BackdropAttributeID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// flexInt64 decodes an int64 the panel may send either as a JSON number or as a
|
||||
// decimal string. Ids and nanoton amounts are sent as strings to stay exact past
|
||||
// 2^53, while a picker-supplied peer id arrives as a plain number; an empty
|
||||
|
|
@ -2532,48 +2076,6 @@ func (s *server) handleDeleteCollectibleUsernameAPI(w http.ResponseWriter, r *ht
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type recomputeAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleRecomputeAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body recomputeAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RecomputeAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "recompute-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/recompute", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type adjustAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
Amount flexInt64 `json:"amount"`
|
||||
}
|
||||
|
||||
func (s *server) handleAdjustAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adjustAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.AdjustAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "adjust-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
Amount: body.Amount.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/adjust", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// handleCollectibleUsernamesAPI pages the collectible asset table straight from
|
||||
// PostgreSQL, like every other table view, and echoes the keyset cursor as a
|
||||
// decimal string so an int64 id survives the round trip through the browser.
|
||||
|
|
@ -2648,51 +2150,6 @@ func (s *server) handleCollectibleUsernameDetailAPI(w http.ResponseWriter, r *ht
|
|||
})
|
||||
}
|
||||
|
||||
// handleAccountRatingsAPI pages the leaderboard. next_before_id is the last
|
||||
// user id: the keyset predicate resolves the full (level, stars, user_id) cursor
|
||||
// from it, so one opaque-looking value is enough to continue the page.
|
||||
func (s *server) handleAccountRatingsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
minLevel, err := parseInt(query.Get("min_level"))
|
||||
if err != nil || minLevel < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid min_level")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(query.Get("user_id"))
|
||||
if err != nil || userID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListAccountRatings(r.Context(), minLevel, userID, beforeID, limit, query.Get("q"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].UserID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleStorageStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -2740,31 +2197,6 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
|
|||
})
|
||||
}
|
||||
|
||||
func (s *server) handleAccountRatingDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(r.PathValue("user_id"))
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.AccountRatingDetail(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "account rating not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": detail.Rating,
|
||||
"events": detail.Events,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if confirm && strings.HasPrefix(commandID, "dry-") {
|
||||
|
|
@ -2897,62 +2329,6 @@ func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadat
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) callAdminCollectibleMultipart(ctx context.Context, apiPath string, payload admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
meta, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writer.WriteField("metadata", string(meta)); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
writeUploads := func(uploads []admin.StarGiftCollectibleAnimationUpload) error {
|
||||
for _, upload := range uploads {
|
||||
part, err := writer.CreateFormFile(upload.FileKey, upload.FileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := part.Write(upload.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := writeUploads(payload.Models); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writeUploads(payload.Patterns); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if result.Error == "" {
|
||||
result.Error = resp.Status
|
||||
}
|
||||
return result, errors.New(result.Error)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if err := decodeJSON(r, dst); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
|
|
|
|||
|
|
@ -144,92 +144,6 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(StarGiftRow{
|
||||
GiftID: maxInt64,
|
||||
RevisionID: maxInt64,
|
||||
Stars: maxInt64,
|
||||
ConvertStars: maxInt64,
|
||||
DocumentID: maxInt64,
|
||||
AnimationSize: maxInt64,
|
||||
ReceivedCount: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal star gift row: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal star gift row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"GiftID", "RevisionID", "Stars", "ConvertStars", "DocumentID", "AnimationSize", "ReceivedCount"} {
|
||||
if got[field] != "9223372036854775807" {
|
||||
t.Fatalf("%s = %#v, want exact decimal string", field, got[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGiftImportActionDecodes(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-default-gift", strings.NewReader(`{
|
||||
"command_id":"c1","reason":"demo","confirm":true,"id":3,"enabled":true
|
||||
}`))
|
||||
var got importDefaultStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode default gift action: %v", err)
|
||||
}
|
||||
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm || !got.Enabled {
|
||||
t.Fatalf("decoded default gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
|
||||
"source_gift_id":"5895603153683874485",
|
||||
"gift_id":"9223372036854775807",
|
||||
"stars":"9223372036854775807",
|
||||
"convert_stars":"9223372036854775807",
|
||||
"upgrade_stars":"9223372036854775807"
|
||||
}`))
|
||||
var got importOfficialStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode official gift action: %v", err)
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
|
||||
t.Fatalf("decoded official gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.SetStarGiftEnabledRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/gifts/set-enabled" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-gift-enabled", strings.NewReader(`{
|
||||
"reason":"precision regression","confirm":false,
|
||||
"gift_id":"9223372036854775807","enabled":false
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetStarGiftEnabledAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.GiftID != maxInt64 || got.Actor != "operator" || !got.DryRun {
|
||||
t.Fatalf("forwarded gift request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.MintCollectibleUsernameRequest
|
||||
|
|
@ -268,33 +182,6 @@ func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingBFFForwardsNumericPayload(t *testing.T) {
|
||||
var got admin.AdjustAccountRatingRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/account-ratings/adjust" {
|
||||
t.Fatalf("upstream path=%q", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed"})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"reason":"manual penalty","confirm":true,"user_id":1001,"amount":-2500}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleAdjustAccountRatingAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Actor != "operator" || got.UserID != 1001 || got.Amount != -2500 || got.DryRun {
|
||||
t.Fatalf("forwarded adjust request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-collectible-username", strings.NewReader(
|
||||
|
|
@ -307,7 +194,7 @@ func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
func TestCollectibleUsernameRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(CollectibleUsernameRow{
|
||||
ID: maxInt64, OwnerPeerID: maxInt64, Amount: maxInt64, CryptoAmount: maxInt64,
|
||||
|
|
@ -326,30 +213,6 @@ func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *te
|
|||
}
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(AccountRatingRow{
|
||||
UserID: maxInt64, Stars: maxInt64, CurrentLevelStars: maxInt64, NextLevelStars: maxInt64,
|
||||
StarsComponent: maxInt64, ActivityComponent: maxInt64, PenaltyComponent: maxInt64,
|
||||
ManualComponent: -maxInt64, PendingStars: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal account rating row: %v", err)
|
||||
}
|
||||
var rating map[string]any
|
||||
if err := json.Unmarshal(raw, &rating); err != nil {
|
||||
t.Fatalf("unmarshal account rating row: %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"UserID", "Stars", "CurrentLevelStars", "NextLevelStars",
|
||||
"StarsComponent", "ActivityComponent", "PenaltyComponent", "PendingStars", "Version",
|
||||
} {
|
||||
if rating[field] != "9223372036854775807" {
|
||||
t.Fatalf("rating %s = %#v, want exact decimal string", field, rating[field])
|
||||
}
|
||||
}
|
||||
if rating["ManualComponent"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating ManualComponent = %#v, want signed decimal string", rating["ManualComponent"])
|
||||
}
|
||||
|
||||
transfer, err := json.Marshal(CollectibleUsernameTransferRow{
|
||||
ID: maxInt64, CollectibleID: maxInt64, FromPeerID: maxInt64, ToPeerID: maxInt64, Amount: maxInt64,
|
||||
})
|
||||
|
|
@ -381,26 +244,9 @@ func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
|
|||
body.PurchaseDate.Unix() != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("decoded mint action = %+v", body)
|
||||
}
|
||||
|
||||
var rating adjustAccountRatingAPIRequest
|
||||
numeric := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":1001,"amount":-2500}`))
|
||||
if err := decodeJSON(numeric, &rating); err != nil {
|
||||
t.Fatalf("decode adjust action: %v", err)
|
||||
}
|
||||
if rating.UserID.Int64() != 1001 || rating.Amount.Int64() != -2500 {
|
||||
t.Fatalf("decoded adjust action = %+v", rating)
|
||||
}
|
||||
|
||||
var broken adjustAccountRatingAPIRequest
|
||||
invalid := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":"not-a-number"}`))
|
||||
if err := decodeJSON(invalid, &broken); err == nil {
|
||||
t.Fatal("decoded a non-numeric user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
|
||||
func TestNewCollectibleRoutesRequireSession(t *testing.T) {
|
||||
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
|
|
@ -411,13 +257,9 @@ func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
|
|||
}{
|
||||
{http.MethodGet, "/api/collectible-usernames"},
|
||||
{http.MethodGet, "/api/collectible-usernames/7"},
|
||||
{http.MethodGet, "/api/account-ratings"},
|
||||
{http.MethodGet, "/api/account-ratings/7"},
|
||||
{http.MethodPost, "/api/actions/mint-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/transfer-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/revoke-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/recompute-account-rating"},
|
||||
{http.MethodPost, "/api/actions/adjust-account-rating"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
req := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`))
|
||||
|
|
|
|||
|
|
@ -179,10 +179,8 @@ func TestCSRFProtectionCoversEveryExistingMutatingRoute(t *testing.T) {
|
|||
for _, path := range []string{
|
||||
"/api/logout",
|
||||
"/api/actions/set-frozen",
|
||||
"/api/actions/grant-stars",
|
||||
"/api/actions/delete-bot",
|
||||
"/api/actions/revoke-collectible-username",
|
||||
"/api/actions/adjust-account-rating",
|
||||
"/api/moderation/cases/7/claim",
|
||||
"/api/verification/applications/7/approve",
|
||||
"/api/actions/revoke-verification",
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-98gjjW0y.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-98gjjW0y.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-B6-NxcJd.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B6-NxcJd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-CS-EAiSc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-sqNghGhC.css">
|
||||
<script type="module" crossorigin src="/assets/index-B6-NxcJd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-98gjjW0y.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountRatingDetail,
|
||||
AccountRatingListResponse,
|
||||
AccountStatsResponse,
|
||||
AccountStorageListResponse,
|
||||
SharedDeviceGroupListResponse,
|
||||
|
|
@ -28,13 +26,9 @@ import type {
|
|||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
DefaultGiftListResponse,
|
||||
ModerationCaseDetail,
|
||||
ModerationCaseRow,
|
||||
ModerationReport,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse,
|
||||
StickerSetListResponse,
|
||||
VerificationApplicationDetail,
|
||||
VerificationApplicationListResponse,
|
||||
|
|
@ -167,10 +161,6 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
accountRatings: (params: URLSearchParams) =>
|
||||
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
|
||||
accountRating: (userID: string) =>
|
||||
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
request<AccountStorageListResponse>(`/api/storage/accounts?${params.toString()}`),
|
||||
|
|
@ -229,7 +219,6 @@ export const api = {
|
|||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||
|
|
@ -237,17 +226,6 @@ export const api = {
|
|||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
||||
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
|
||||
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
|
||||
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
|
||||
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
|
||||
importDefaultGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
|
||||
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,8 @@ import {
|
|||
ShieldCheck,
|
||||
Smile,
|
||||
Stamp,
|
||||
Trophy,
|
||||
Users,
|
||||
Gift,
|
||||
Sticker,
|
||||
Send
|
||||
Sticker
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
|
|
@ -101,10 +98,7 @@ export function Shell({
|
|||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
||||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, UserRound } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { AvatarModal } from "../components/AvatarModal";
|
||||
|
|
@ -20,7 +20,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>("profile");
|
||||
const [months, setMonths] = useState("1");
|
||||
const [starsAmount, setStarsAmount] = useState("1000");
|
||||
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
|
||||
const [freezeAppealURL, setFreezeAppealURL] = useState("");
|
||||
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||
|
|
@ -123,7 +122,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<Summary label={"User ID"} value={String(account.ID)} mono />
|
||||
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
|
||||
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
|
||||
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
|
||||
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
|
||||
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
|
||||
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
|
|
@ -197,7 +195,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Premium & Stars"} />
|
||||
<SectionHead title={"Premium"} />
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
|
|
@ -230,27 +228,6 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"Stars to grant"}</span>
|
||||
<input
|
||||
aria-label={"Set Stars amount to grant"}
|
||||
value={starsAmount}
|
||||
onChange={(event) => setStarsAmount(event.target.value)}
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000000"
|
||||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
label={"Grant Stars"}
|
||||
icon={<Star size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-stars"
|
||||
payload={() => ({ user_id: account.ID, amount: toInt(starsAmount) })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
|
||||
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
|
||||
|
||||
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [adjustment, setAdjustment] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.accountRating(userID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [userID]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? "Loading account rating…" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const rating = detail.rating;
|
||||
const events = detail.events ?? [];
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
const progress = levelProgress(rating);
|
||||
// user_id / amount are `,string` int64 fields on the backend, so they stay
|
||||
// decimal strings and never pass through a float.
|
||||
const payloadUserID = rating.UserID || userID;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Rating of ${displayUsername(rating.Username) || rating.FirstName || rating.UserID}`}
|
||||
eyebrow={"Rating / Component breakdown"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || "Unnamed bot"}</div>
|
||||
<div className="entity-subtitle">{"User ID"}: {rating.UserID}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<LevelBadge level={rating.Level} />
|
||||
{pending !== 0 && <Badge tone="warn">{`Pending ${formatSigned(rating.PendingStars)}`}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="metric-row">
|
||||
<Metric label={"Points"} value={formatQuantity(rating.Stars)} mono />
|
||||
<Metric label={"Level"} value={String(rating.Level)} tone="good" />
|
||||
<Metric
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Metric
|
||||
label={"Points to next level"}
|
||||
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
|
||||
mono
|
||||
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"How the rating adds up"} text={"Contribution of every source: stars, activity, moderation penalties and manual corrections."} />
|
||||
<Breakdown rating={rating} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Current level threshold"} value={formatQuantity(rating.CurrentLevelStars)} mono />
|
||||
<Summary
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Summary label={"Computed"} value={formatDate(rating.ComputedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(rating.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="progress-wide">
|
||||
<RatingProgress row={rating} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pending !== 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Pending points"} text={"Already earned, but counted towards the rating only on the date below."} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Pending"} value={formatSigned(rating.PendingStars)} mono />
|
||||
<Summary label={"Applied on"} value={formatDate(rating.PendingDate) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Rating events"} text={"Every rating change with its source, actor and reason."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Source"}</th>
|
||||
<th>{"Change"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">{formatSigned(row.Amount)}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Rating operations"}</div>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
|
||||
<User size={15} /> {"Open account"}
|
||||
</button>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Recompute"}
|
||||
icon={<Calculator size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/recompute-account-rating"
|
||||
payload={() => ({ user_id: payloadUserID })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Rebuilds the rating from stars, activity, penalties and manual corrections."}</p>
|
||||
<div className="dock-title">{"Manual correction"}</div>
|
||||
<label className="duration-field">
|
||||
<span>{"Value (negative allowed)"}</span>
|
||||
<input
|
||||
value={adjustment}
|
||||
onChange={(event) => setAdjustment(event.target.value)}
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Apply correction"}
|
||||
icon={<SlidersHorizontal size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/adjust-account-rating"
|
||||
payload={() => ({
|
||||
user_id: payloadUserID,
|
||||
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
|
||||
})}
|
||||
onDone={() => {
|
||||
setAdjustment("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"The value is added to the manual component; a negative number lowers the rating."}</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ rating }: { rating: AccountRatingRow }) {
|
||||
// PenaltyComponent is stored as a positive magnitude and subtracted by the
|
||||
// scorer, so it is shown (and summed) as a negative contribution.
|
||||
const components = [
|
||||
{ key: "stars", label: "Stars", hint: "Purchased and received stars", value: toNumeric(rating.StarsComponent) },
|
||||
{ key: "activity", label: "Activity", hint: "Messages, sessions and long-term engagement", value: toNumeric(rating.ActivityComponent) },
|
||||
{ key: "penalty", label: "Penalties", hint: "Moderation decisions and restrictions", value: -toNumeric(rating.PenaltyComponent) },
|
||||
{ key: "manual", label: "Manual corrections", hint: "Adjustments made by admins", value: toNumeric(rating.ManualComponent) }
|
||||
];
|
||||
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
|
||||
// The score is clamped at zero, and a delayed increase sits in PendingStars
|
||||
// instead of the score, so both cases are expected rather than drift.
|
||||
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
|
||||
const total = toNumeric(rating.Stars);
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breakdown-list">
|
||||
{components.map((item) => {
|
||||
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
|
||||
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
|
||||
return (
|
||||
<div className="breakdown-row" key={item.key}>
|
||||
<div className="breakdown-label">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.hint}</small>
|
||||
</div>
|
||||
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="breakdown-row total">
|
||||
<div className="breakdown-label"><strong>{"Total rating"}</strong></div>
|
||||
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{pending === 0 && sum !== total && (
|
||||
<Alert>{`Components add up to ${formatQuantity(String(sum))} while the stored rating is ${formatQuantity(rating.Stars)}. Recompute to resolve the drift.`}</Alert>
|
||||
)}
|
||||
{pending !== 0 && <p className="bot-create-note">{`Components already include ${formatSigned(rating.PendingStars)} that reaches the score only on the date below.`}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingKindLabels: Record<AccountRatingEventKind, string> = {
|
||||
stars: "Stars",
|
||||
activity: "Activity",
|
||||
moderation: "Moderation",
|
||||
manual: "Manual",
|
||||
recompute: "Recompute"
|
||||
};
|
||||
|
||||
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
|
||||
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
|
||||
return <Badge tone={tone}>{ratingKindLabels[kind]}</Badge>;
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingRow } from "../types";
|
||||
|
||||
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
|
||||
const [minLevel, setMinLevel] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<AccountRatingRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
// One free-text field: the backend matches a username prefix (editable or
|
||||
// collectible), a first/last name prefix, and a bare number as the user id.
|
||||
const wanted = search.trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (minLevel.trim()) params.set("min_level", minLevel.trim());
|
||||
if (wanted) params.set("q", wanted);
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.accountRatings(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
|
||||
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
|
||||
const avgLevel = rows.length > 0
|
||||
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Account rating leaderboard"}
|
||||
eyebrow={"Rating / Leaderboard"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Loaded rows"} value={String(rows.length)} />
|
||||
<Metric label={"Top level"} value={String(topLevel)} tone="good" />
|
||||
<Metric label={"Average level"} value={avgLevel} />
|
||||
<Metric label={"With pending points"} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={"Search by username, name or user ID"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Min level"}</span>
|
||||
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Level"}</th>
|
||||
<th>{"Points"}</th>
|
||||
<th>{"Progress to next level"}</th>
|
||||
<th>{"Pending"}</th>
|
||||
<th>{"Computed"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.UserID}>
|
||||
<td className="mono">{row.UserID}</td>
|
||||
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
|
||||
<td><LevelBadge level={row.Level} /></td>
|
||||
<td className="mono">{formatQuantity(row.Stars)}</td>
|
||||
<td><RatingProgress row={row} /></td>
|
||||
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
|
||||
<td>{formatDate(row.ComputedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
|
||||
<Trophy size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ level }: { level: number }) {
|
||||
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
|
||||
return <Badge tone={tone}>{`Level ${level}`}</Badge>;
|
||||
}
|
||||
|
||||
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
|
||||
const stars = toNumeric(row.Stars);
|
||||
const current = toNumeric(row.CurrentLevelStars);
|
||||
const target = toNumeric(row.NextLevelStars);
|
||||
const span = target - current;
|
||||
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
|
||||
return { percent, remaining: Math.max(0, target - stars), target, stars };
|
||||
}
|
||||
|
||||
export function RatingProgress({ row }: { row: AccountRatingRow }) {
|
||||
if (!row.HasNextLevel) {
|
||||
return <span className="progress-note">{"Max level reached"}</span>;
|
||||
}
|
||||
const { percent, remaining, target } = levelProgress(row);
|
||||
return (
|
||||
<div className="progress-cell">
|
||||
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<small>{`${formatQuantity(String(remaining))} left to reach ${formatQuantity(String(target))}`}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import { CheckCircle2, FileJson2, Gem, Loader2, Plus, ShieldCheck, Sparkles, Trash2, Upload, X } from "lucide-react";
|
||||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge } from "../components/ui";
|
||||
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
type AnimationData = Record<string, unknown>;
|
||||
type AnimatedDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
sortOrder: string;
|
||||
file: File | null;
|
||||
animation: AnimationData | null;
|
||||
fileError: string;
|
||||
};
|
||||
type BackdropDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
backdropID: string;
|
||||
rarity: string;
|
||||
sortOrder: string;
|
||||
center: string;
|
||||
edge: string;
|
||||
pattern: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
let draftSequence = 0;
|
||||
const nextKey = (kind: string) => `${kind}-${++draftSequence}`;
|
||||
const backdropPalettes = [
|
||||
{ center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" },
|
||||
{ center: "#32a86b", edge: "#17613e", pattern: "#8ee0b3", text: "#ffffff" },
|
||||
{ center: "#df8d2f", edge: "#8c421e", pattern: "#ffd08a", text: "#ffffff" },
|
||||
{ center: "#d95878", edge: "#7b2944", pattern: "#f5a1b6", text: "#ffffff" }
|
||||
];
|
||||
|
||||
function rebalanceRarity<T extends { rarity: string }>(rows: T[]): T[] {
|
||||
if (!rows.length) return rows;
|
||||
const base = Math.floor(1000 / rows.length);
|
||||
const remainder = 1000 % rows.length;
|
||||
return rows.map((row, index) => ({ ...row, rarity: String(base + (index < remainder ? 1 : 0)) }));
|
||||
}
|
||||
|
||||
const newAnimated = (kind: string, sortOrder: number): AnimatedDraft => ({
|
||||
key: nextKey(kind), name: "", rarity: "1", sortOrder: String(sortOrder), file: null, animation: null, fileError: ""
|
||||
});
|
||||
|
||||
function newBackdrop(rows: BackdropDraft[]): BackdropDraft {
|
||||
const backdropID = rows.reduce((maximum, row) => {
|
||||
const value = Number(row.backdropID);
|
||||
return Number.isInteger(value) ? Math.max(maximum, value) : maximum;
|
||||
}, 0) + 1;
|
||||
const colors = backdropPalettes[rows.length % backdropPalettes.length];
|
||||
return { key: nextKey("backdrop"), name: "", backdropID: String(backdropID), rarity: "1", sortOrder: String(rows.length), ...colors };
|
||||
}
|
||||
|
||||
const initialAnimated = (kind: string) => rebalanceRarity([newAnimated(kind, 0), newAnimated(kind, 1)]);
|
||||
const initialBackdrops = () => {
|
||||
const first = newBackdrop([]);
|
||||
return rebalanceRarity([first, newBackdrop([first])]);
|
||||
};
|
||||
|
||||
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
return () => player.destroy();
|
||||
}, [data]);
|
||||
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
|
||||
}
|
||||
|
||||
function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) {
|
||||
const [data, setData] = useState<AnimationData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFailed(false);
|
||||
api.giftCollectibleAnimation(giftID, attribute.kind as "model" | "pattern", attribute.id)
|
||||
.then((value) => { if (!cancelled) setData(value); })
|
||||
.catch(() => { if (!cancelled) setFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [giftID, attribute.id, attribute.kind]);
|
||||
if (failed) return <div className="collectible-animation compact failed">!</div>;
|
||||
if (!data) return <div className="collectible-animation compact loading"><Loader2 className="spin" size={15} /></div>;
|
||||
return <AnimationPreview data={data} compact />;
|
||||
}
|
||||
|
||||
async function parseAnimationFile(file: File): Promise<AnimationData> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let raw: Uint8Array = bytes;
|
||||
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
if (!("DecompressionStream" in window)) throw new Error("This browser cannot preview TGS files");
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
raw = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid Lottie JSON");
|
||||
return parsed as AnimationData;
|
||||
}
|
||||
|
||||
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
|
||||
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
|
||||
|
||||
const collectibleGroupLabels: Record<"models" | "patterns", string> = {
|
||||
models: "Models",
|
||||
patterns: "Patterns"
|
||||
};
|
||||
|
||||
const collectibleAttributeLabels: Record<"model" | "pattern" | "backdrop", string> = {
|
||||
model: "Model",
|
||||
pattern: "Pattern",
|
||||
backdrop: "Backdrop"
|
||||
};
|
||||
|
||||
const collectibleColorLabels: Record<"center" | "edge" | "pattern" | "text", string> = {
|
||||
center: "Center",
|
||||
edge: "Edge",
|
||||
pattern: "Pattern",
|
||||
text: "Text"
|
||||
};
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState<CommandResult | null>(null);
|
||||
const [upgradeStars, setUpgradeStars] = useState("100");
|
||||
const [supplyTotal, setSupplyTotal] = useState("1000");
|
||||
const [slugPrefix, setSlugPrefix] = useState(`gift-${gift.GiftID}`);
|
||||
const [reason, setReason] = useState("");
|
||||
const [models, setModels] = useState<AnimatedDraft[]>(() => initialAnimated("model"));
|
||||
const [patterns, setPatterns] = useState<AnimatedDraft[]>(() => initialAnimated("pattern"));
|
||||
const [backdrops, setBackdrops] = useState<BackdropDraft[]>(initialBackdrops);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.giftCollectibles(gift.GiftID).then((value) => {
|
||||
if (cancelled) return;
|
||||
setActive(value);
|
||||
if (value.found) {
|
||||
setUpgradeStars(String(value.upgrade_stars ?? 100));
|
||||
setSupplyTotal(String(value.supply_total ?? 1000));
|
||||
setSlugPrefix(value.slug_prefix ?? `gift-${gift.GiftID}`);
|
||||
}
|
||||
}).catch((err) => setError(errorMessage(err))).finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [gift.GiftID]);
|
||||
|
||||
const rarityTotals = useMemo(() => ({
|
||||
models: models.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
|
||||
patterns: patterns.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
|
||||
backdrops: backdrops.reduce((sum, value) => sum + Number(value.rarity || 0), 0)
|
||||
}), [models, patterns, backdrops]);
|
||||
|
||||
const invalidate = () => setPreview(null);
|
||||
const updateAnimated = (kind: "models" | "patterns", key: string, patch: Partial<AnimatedDraft>) => {
|
||||
const setter = kind === "models" ? setModels : setPatterns;
|
||||
setter((rows) => rows.map((row) => row.key === key ? { ...row, ...patch } : row));
|
||||
invalidate();
|
||||
};
|
||||
|
||||
async function chooseFile(kind: "models" | "patterns", row: AnimatedDraft, file: File | null) {
|
||||
updateAnimated(kind, row.key, { file, animation: null, fileError: "" });
|
||||
if (!file) return;
|
||||
try {
|
||||
const animation = await parseAnimationFile(file);
|
||||
updateAnimated(kind, row.key, { animation, fileError: "" });
|
||||
} catch (err) {
|
||||
updateAnimated(kind, row.key, { animation: null, fileError: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function buildForm(confirm: boolean, commandID = "") {
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error("Models, patterns, and backdrops must each contain at least two attributes.");
|
||||
const backdropIDs = backdrops.map((row) => Number(row.backdropID));
|
||||
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error("Backdrop IDs must be unique within the pool.");
|
||||
for (const row of [...models, ...patterns]) if (!row.file) throw new Error("Every model and pattern needs a TGS or Lottie file.");
|
||||
const form = new FormData();
|
||||
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
upgrade_stars: upgradeStars, supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
|
||||
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
|
||||
backdrops: backdrops.map((row) => ({
|
||||
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
|
||||
center_color: colorNumber(row.center), edge_color: colorNumber(row.edge), pattern_color: colorNumber(row.pattern), text_color: colorNumber(row.text)
|
||||
}))
|
||||
}));
|
||||
for (const row of [...models, ...patterns]) form.set(row.key, row.file as File, (row.file as File).name);
|
||||
return form;
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
setBusy(true); setError(""); setPreview(null);
|
||||
try { setPreview(await api.publishGiftCollectibles(gift.GiftID, buildForm(false))); }
|
||||
catch (err) { setError(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!preview) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
await api.publishGiftCollectibles(gift.GiftID, buildForm(true, preview.command_id));
|
||||
onPublished(); onClose();
|
||||
} catch (err) { setError(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head">
|
||||
<div><strong>{collectibleGroupLabels[kind]}</strong><span>{"Permille values are relative regular-upgrade weights; their total does not need to equal 1000."}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div>
|
||||
</div>
|
||||
<div className="collectible-rows">
|
||||
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
|
||||
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
|
||||
<label className="collectible-file"><span>{"Animation file"}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? "Choose file"}</em></label>
|
||||
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
|
||||
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
|
||||
</div>)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return createPortal(<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={`Collectible pool · Gift #${gift.GiftID}`}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Unique gift attributes"}</div><h2>{`Collectible pool · Gift #${gift.GiftID}`}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body collectible-modal-body">
|
||||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{"Loading"}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{`Published revision ${active.revision ?? 0}`}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{"Published"}</Badge></div>
|
||||
<div className="collectible-active-grid">
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{collectibleAttributeLabels[attribute.kind]} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{"Backdrop"} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{"No collectible pool published"}</strong><span>{"Publish models, patterns and backdrops to enable upgrades."}</span></div></div>}
|
||||
|
||||
<section className="collectible-definition">
|
||||
<div className="collectible-definition-head"><div><strong>{"Publish a new immutable revision"}</strong><span>{"Dry-run checks every file and rarity total before the revision becomes active."}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<div className="gift-fields-grid collectible-main-fields">
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
|
||||
<label><span>{"Audit reason"}</span><input value={reason} maxLength={1000} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
</div>
|
||||
{renderAnimatedRows("models", models, setModels)}
|
||||
{renderAnimatedRows("patterns", patterns, setPatterns)}
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head"><div><strong>{"Backdrops"}</strong><span>{"Colors are stored as 24-bit RGB values."}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div></div>
|
||||
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Backdrop ID"}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{collectibleColorLabels[field]}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
|
||||
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
</div>)}</div>
|
||||
</section>
|
||||
</section>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Attribute pool is valid"}</strong><span>{"Review the normalized assets, then publish this immutable revision."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{"Publish revision"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body);
|
||||
}
|
||||
|
|
@ -1,696 +0,0 @@
|
|||
import { CheckCircle2, ChevronLeft, ChevronRight, FileJson2, Gem, Loader2, Pause, Play, Plus, RefreshCw, Search, ShieldCheck, Upload, X } from "lucide-react";
|
||||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
||||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
const officialCategoryLabels: Record<OfficialGiftCategory, string> = {
|
||||
all: "All",
|
||||
upgrade: "Upgradable",
|
||||
craft: "Craftable",
|
||||
basic: "Not upgradable"
|
||||
};
|
||||
|
||||
// The demo pool only has 3 placeholder gifts left after pruning to one per
|
||||
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
|
||||
// replace them. Flip back to true to re-enable.
|
||||
const SHOW_DEFAULT_GIFTS_TAB = false;
|
||||
|
||||
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | string) {
|
||||
const bytes = Number(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.giftAnimation(giftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
animation.current?.destroy();
|
||||
animation.current = lottie.loadAnimation({
|
||||
container: host.current,
|
||||
renderer: "canvas",
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: structuredClone(data)
|
||||
});
|
||||
}).catch((err) => setError(errorMessage(err)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
};
|
||||
}, [giftID, revision]);
|
||||
|
||||
function toggle() {
|
||||
if (!animation.current) return;
|
||||
if (playing) animation.current.pause();
|
||||
else animation.current.play();
|
||||
setPlaying(!playing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`gift-animation-shell ${compact ? "compact" : ""}`}>
|
||||
<div className="gift-animation" ref={host}>{error && <span>{error}</span>}</div>
|
||||
<button className="gift-play" type="button" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
||||
{playing ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultLottiePreview({ id }: { id: number }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.defaultGiftAnimation(id).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [id]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
|
||||
api.officialGiftAnimation(sourceGiftID).then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
|
||||
}).catch(() => undefined);
|
||||
return () => { cancelled = true; player?.destroy(); };
|
||||
}, [sourceGiftID]);
|
||||
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
|
||||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [importSource, setImportSource] = useState<"default" | "official" | "file">(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official");
|
||||
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
|
||||
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
|
||||
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
|
||||
const [officialQuery, setOfficialQuery] = useState("");
|
||||
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
|
||||
const [sourceGiftID, setSourceGiftID] = useState("");
|
||||
const [includeCollectible, setIncludeCollectible] = useState(true);
|
||||
const [upgradeStars, setUpgradeStars] = useState("0");
|
||||
const [supplyTotal, setSupplyTotal] = useState("0");
|
||||
const [slugPrefix, setSlugPrefix] = useState("");
|
||||
const [giftID, setGiftID] = useState("0");
|
||||
const [title, setTitle] = useState("");
|
||||
const [stars, setStars] = useState("50");
|
||||
const [convertStars, setConvertStars] = useState("50");
|
||||
const [sortOrder, setSortOrder] = useState("0");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [reason, setReason] = useState("");
|
||||
const [preview, setPreview] = useState<CommandResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [importError, setImportError] = useState("");
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState<"default" | "official" | null>(null);
|
||||
const [bulkImportItems, setBulkImportItems] = useState<Array<DefaultGiftRow | OfficialStarGiftRow>>([]);
|
||||
const [bulkImportEnabled, setBulkImportEnabled] = useState(true);
|
||||
const [bulkImportReason, setBulkImportReason] = useState("");
|
||||
const [bulkImportBusy, setBulkImportBusy] = useState(false);
|
||||
const [bulkImportProgress, setBulkImportProgress] = useState({ done: 0, total: 0 });
|
||||
const [bulkImportError, setBulkImportError] = useState("");
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{ imported: number; skipped: number; failed: number; errors: string[] } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkReason, setBulkReason] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [pageSize, setPageSize] = useState<GiftPageSize>(10);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setGifts((await api.gifts()).Gifts ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "default" || defaultGifts.length > 0) return;
|
||||
api.defaultGifts().then((value) => setDefaultGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, defaultGifts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
|
||||
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
|
||||
}, [importOpen, importSource, officialGifts.length]);
|
||||
|
||||
const selectedDefault = useMemo(() => defaultGifts.find((gift) => gift.id === selectedDefaultID) ?? null, [defaultGifts, selectedDefaultID]);
|
||||
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
|
||||
const officialCategoryCounts = useMemo(() => ({
|
||||
all: officialGifts.length,
|
||||
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
|
||||
craft: officialGifts.filter((gift) => gift.can_craft).length,
|
||||
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
|
||||
}), [officialGifts]);
|
||||
const visibleOfficial = useMemo(() => {
|
||||
const normalized = officialQuery.trim().toLowerCase();
|
||||
return officialGifts.filter((gift) => {
|
||||
const categoryMatches = officialCategory === "all" ||
|
||||
(officialCategory === "upgrade" && gift.can_upgrade) ||
|
||||
(officialCategory === "craft" && gift.can_craft) ||
|
||||
(officialCategory === "basic" && !gift.can_upgrade);
|
||||
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
|
||||
});
|
||||
}, [officialGifts, officialQuery, officialCategory]);
|
||||
|
||||
const visibleGifts = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
return gifts.filter((gift) =>
|
||||
String(gift.GiftID).includes(normalized) ||
|
||||
gift.Title.toLowerCase().includes(normalized) ||
|
||||
gift.SourceFormat.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [gifts, query]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [query, pageSize]);
|
||||
|
||||
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visibleGifts.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedGifts = useMemo(() => {
|
||||
if (pageSize === "all") return visibleGifts;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return visibleGifts.slice(start, start + pageSize);
|
||||
}, [visibleGifts, currentPage, pageSize]);
|
||||
const pageRangeStart = pagedGifts.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
|
||||
const pageRangeEnd = pageRangeStart === 0 ? 0 : pageRangeStart + pagedGifts.length - 1;
|
||||
|
||||
const allVisibleSelected = pagedGifts.length > 0 && pagedGifts.every((gift) => selected.has(gift.GiftID));
|
||||
|
||||
function toggleSelected(giftID: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(giftID)) next.delete(giftID);
|
||||
else next.add(giftID);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible() {
|
||||
setSelected((prev) => {
|
||||
if (allVisibleSelected) {
|
||||
const next = new Set(prev);
|
||||
for (const gift of pagedGifts) next.delete(gift.GiftID);
|
||||
return next;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
for (const gift of pagedGifts) next.add(gift.GiftID);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function bulkSetEnabled(nextEnabled: boolean) {
|
||||
if (!bulkReason.trim()) {
|
||||
setBulkError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBulkBusy(true);
|
||||
setBulkError("");
|
||||
const ids = Array.from(selected);
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.action("/api/actions/set-gift-enabled", {
|
||||
gift_id: id,
|
||||
enabled: nextEnabled,
|
||||
reason: bulkReason.trim(),
|
||||
confirm: true
|
||||
});
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setBulkBusy(false);
|
||||
if (failed > 0) {
|
||||
setBulkError(`${failed} of ${ids.length} failed`);
|
||||
} else {
|
||||
setSelected(new Set());
|
||||
setBulkReason("");
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function uploadForm(confirm: boolean, commandID = "") {
|
||||
if (!file) throw new Error("Choose a TGS or Lottie file first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID,
|
||||
reason: reason.trim(),
|
||||
confirm,
|
||||
gift_id: giftID,
|
||||
title: title.trim(),
|
||||
stars,
|
||||
convert_stars: convertStars,
|
||||
enabled,
|
||||
sort_order: Number(sortOrder)
|
||||
}));
|
||||
form.set("file", file, file.name);
|
||||
return form;
|
||||
}
|
||||
|
||||
function defaultPayload(confirm: boolean, commandID = "") {
|
||||
if (!selectedDefaultID) throw new Error("Choose a default gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error("Choose an official gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
return {
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
|
||||
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
|
||||
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
|
||||
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function chooseOfficial(gift: OfficialStarGiftRow) {
|
||||
setSourceGiftID(gift.source_gift_id);
|
||||
setTitle(gift.title || `Unnamed official gift #${gift.source_gift_id}`);
|
||||
setStars(String(gift.stars));
|
||||
setConvertStars(String(gift.convert_stars));
|
||||
setIncludeCollectible(gift.can_upgrade);
|
||||
setUpgradeStars(gift.upgrade_stars);
|
||||
setSupplyTotal(String(gift.availability_total || 1));
|
||||
setSlugPrefix(`official-${gift.source_gift_id}`);
|
||||
setPreview(null);
|
||||
}
|
||||
|
||||
async function openBulkImport(source: "default" | "official") {
|
||||
setBulkImportOpen(source);
|
||||
setBulkImportItems([]);
|
||||
setBulkImportEnabled(true);
|
||||
setBulkImportReason("");
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportProgress({ done: 0, total: 0 });
|
||||
setBulkImportError("");
|
||||
setBulkImportResult(null);
|
||||
try {
|
||||
if (source === "default") {
|
||||
const list = defaultGifts.length > 0 ? defaultGifts : (await api.defaultGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
} else {
|
||||
const list = officialGifts.length > 0 ? officialGifts : (await api.officialGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
}
|
||||
} catch (err) {
|
||||
setBulkImportError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function closeBulkImport() {
|
||||
if (bulkImportBusy) return;
|
||||
setBulkImportOpen(null);
|
||||
}
|
||||
|
||||
async function runBulkImport() {
|
||||
if (!bulkImportOpen) return;
|
||||
if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; }
|
||||
const source = bulkImportOpen;
|
||||
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
|
||||
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
|
||||
let imported = 0, skipped = 0, failed = 0;
|
||||
const errors: string[] = [];
|
||||
for (const item of bulkImportItems) {
|
||||
const label = source === "default" ? (item as DefaultGiftRow).title : ((item as OfficialStarGiftRow).title || `#${(item as OfficialStarGiftRow).source_gift_id}`);
|
||||
try {
|
||||
// Stable per-gift command_id (mirrors the old server-side bulk
|
||||
// endpoint) so a gift already imported by a prior run is recognized
|
||||
// as a replay instead of creating a duplicate catalog entry -
|
||||
// CreateCatalogBundle has no unique constraint on title/source id to
|
||||
// fall back on. If this run's Enabled value differs from the run
|
||||
// that first created it, the server reports COMMAND_ID_CONFLICT
|
||||
// instead of silently re-importing; treat that as "skipped" too.
|
||||
const result = source === "default"
|
||||
? await api.importDefaultGift({
|
||||
command_id: `bulk-default-gift-${(item as DefaultGiftRow).id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
id: (item as DefaultGiftRow).id,
|
||||
enabled: bulkImportEnabled
|
||||
})
|
||||
: await api.importOfficialGift({
|
||||
command_id: `bulk-official-gift-${(item as OfficialStarGiftRow).source_gift_id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
source_gift_id: (item as OfficialStarGiftRow).source_gift_id,
|
||||
include_collectible: (item as OfficialStarGiftRow).can_upgrade,
|
||||
enabled: bulkImportEnabled
|
||||
});
|
||||
if (result.already_executed || result.details?.skipped) skipped++;
|
||||
else imported++;
|
||||
} catch (err) {
|
||||
if (err instanceof APIError && err.message === "COMMAND_ID_CONFLICT") {
|
||||
skipped++;
|
||||
} else {
|
||||
failed++;
|
||||
errors.push(`${label}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
setBulkImportProgress((prev) => ({ ...prev, done: prev.done + 1 }));
|
||||
}
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportResult({ imported, skipped, failed, errors });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
const result = importSource === "default" ? await api.importDefaultGift(defaultPayload(false))
|
||||
: importSource === "official" ? await api.importOfficialGift(officialPayload(false))
|
||||
: await api.importGift(uploadForm(false));
|
||||
setPreview(result);
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!preview) return;
|
||||
setBusy(true); setImportError("");
|
||||
try {
|
||||
if (importSource === "default") await api.importDefaultGift(defaultPayload(true, preview.command_id));
|
||||
else if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
|
||||
else await api.importGift(uploadForm(true, preview.command_id));
|
||||
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0); setSourceGiftID("");
|
||||
await load();
|
||||
setImportOpen(false);
|
||||
} catch (err) {
|
||||
setImportError(errorMessage(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function startImport() {
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); setSelectedDefaultID(0);
|
||||
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all");
|
||||
setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError("");
|
||||
setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
|
||||
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
|
||||
setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("file"); setSelectedDefaultID(0); setSourceGiftID(""); setImportOpen(true);
|
||||
}
|
||||
|
||||
const step1Done = importSource === "default" ? selectedDefaultID > 0
|
||||
: importSource === "official" ? Boolean(sourceGiftID)
|
||||
: Boolean(file);
|
||||
|
||||
return (
|
||||
<PageFrame title={"Star Gift Catalog"} eyebrow={"Catalog, immutable revisions and animation assets"} actions={<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {"Add gift"}</button>
|
||||
</>}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row gift-metrics">
|
||||
<Metric label={"Catalog entries"} value={String(gifts.length)} />
|
||||
<Metric label={"Enabled"} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={"Received gifts"} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={"Accepted formats"} value="TGS / Lottie" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search gift ID, title or format"} /></label>
|
||||
<label className="gift-page-size"><span>{"Per page"}</span>
|
||||
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{"All"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{`Showing ${visibleGifts.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
{selected.size > 0 && <div className="gift-bulk-toolbar">
|
||||
<span className="gift-bulk-count">{`${selected.size} selected`}</span>
|
||||
<label className="gift-reason-field gift-bulk-reason"><span>{"Audit reason"}</span><input value={bulkReason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setBulkReason(e.target.value)} /></label>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {"Enable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {"Disable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{"Close"}</button>
|
||||
{bulkError && <span className="gift-bulk-error">{bulkError}</span>}
|
||||
</div>}
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table gift-table">
|
||||
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={"Select all visible gifts"} /></th><th>{"Animation file"}</th><th>{"ID / Revision"}</th><th>{"Display title"}</th><th>{"Price / Conversion"}</th><th>{"Source"}</th><th>{"Received gifts"}</th><th>{"Status"}</th><th>{"Updated"}</th><th>{"Actions"}</th></tr></thead>
|
||||
<tbody>
|
||||
{pagedGifts.map((gift) => (
|
||||
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
|
||||
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /></td>
|
||||
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
|
||||
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
|
||||
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{"Sort order"}: {gift.SortOrder}</span></td>
|
||||
<td><strong className="gift-table-price">⭐ {gift.Stars}</strong><span className="gift-convert-price">→ {gift.ConvertStars}</span></td>
|
||||
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
|
||||
<td>{gift.ReceivedCount}</td>
|
||||
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? "Enabled" : "Disabled"}</Badge></td>
|
||||
<td>{formatDate(gift.UpdatedAt)}</td>
|
||||
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{"Attribute pool"}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{"New revision"}</button><ActionButton compact tone="neutral" label={gift.Enabled ? "Disable" : "Enable"} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
|
||||
</tr>
|
||||
))}
|
||||
{pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
|
||||
<span className="gift-pager-range">{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{importOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{"File and details"}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{"Dry-run validation"}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{"Confirm import"}</strong></div>
|
||||
</div>
|
||||
{giftID === "0" && <div className="gift-source-tabs">
|
||||
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{"Default gifts"}</button>}
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{"Official snapshot"}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{"Upload file"}</button>
|
||||
</div>}
|
||||
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
|
||||
<Upload size={14} /> {"Import all default gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose a default gift"}>
|
||||
{defaultGifts.map((gift) => {
|
||||
const isSelected = gift.id === selectedDefaultID;
|
||||
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => { setSelectedDefaultID(gift.id); setPreview(null); }}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title}</strong>
|
||||
<span className="mono">⭐ {gift.stars}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>{`${defaultGiftAttributeCount(gift)} attributes`}</span>
|
||||
{gift.limited && <span>{`Limited · ${gift.availability}`}</span>}
|
||||
{gift.require_premium && <span>{"Premium only"}</span>}
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{defaultGifts.length === 0 && <div className="official-gift-empty">{"No default gifts are available."}</div>}
|
||||
</div>
|
||||
{selectedDefault && <div className="official-gift-selected">
|
||||
<DefaultLottiePreview id={selectedDefault.id} />
|
||||
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}</span></span></div>
|
||||
</div>}
|
||||
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
|
||||
<Upload size={14} /> {"Import all official gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={"Search official gift ID or title"} /></label>
|
||||
<span>{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}</span>
|
||||
</div>
|
||||
<div className="official-gift-categories" role="group" aria-label={"Official gift capability category"}>
|
||||
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
|
||||
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
|
||||
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
|
||||
{officialCategoryLabels[category]}<span>{officialCategoryCounts[category]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose an official gift"}>
|
||||
{visibleOfficial.map((gift) => {
|
||||
const isSelected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title || `Unnamed official gift #${gift.source_gift_id}`}</strong>
|
||||
<span className="mono">#{gift.source_gift_id}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>⭐ {gift.stars}</span>
|
||||
<span>{`${officialGiftAttributeCount(gift)} attributes`}</span>
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{"No official gifts match this category and search."}</div>}
|
||||
</div>
|
||||
{selectedOfficial && <div className="official-gift-selected">
|
||||
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
|
||||
<div><strong>{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}</span></span></div>
|
||||
</div>}
|
||||
{selectedOfficial?.can_upgrade && <>
|
||||
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Import the complete collectible pool, including crafted models"}</span></label>
|
||||
{includeCollectible && <div className="gift-fields-grid">
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
</div>}
|
||||
</>}
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</section> : <>
|
||||
<div className="gift-import-note"><span>{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}</span><div className="gift-format-chips" aria-label={"Accepted formats"}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
<span className="gift-file-icon"><FileJson2 size={22} /></span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"Animation file"}</span><strong>{file ? file.name : "Drop or choose a TGS / Lottie file"}</strong><small>{file ? formatBytes(file.size) : "TGS, JSON or Lottie · validated before import"}</small></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</>}
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
{importError && <Alert>{importError}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Validation passed"}</strong><span>{"Review the normalized metadata, then confirm the import."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{"Confirm import"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{bulkImportOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
|
||||
aria-label={bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-import-note"><span>{`${bulkImportItems.length} gifts available to import`}</span></div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={bulkImportReason} placeholder={"Briefly describe why this gift is being imported"} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
|
||||
{bulkImportBusy && <div className="gift-bulk-import-progress">
|
||||
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
|
||||
<span>{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}</span>
|
||||
</div>}
|
||||
{bulkImportError && <Alert>{bulkImportError}</Alert>}
|
||||
{bulkImportResult && <div className="gift-validation">
|
||||
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Import complete"}</strong><span>{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}</span></div></div>
|
||||
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{"Close"}</button>
|
||||
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
|
||||
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {"Start import"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
import { CheckCircle2, CircleAlert, Gift, Loader2, Play, User, Users } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, JsonBlock } from "../components/ui";
|
||||
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
const SYSTEM_SENDER = "777000";
|
||||
|
||||
type RecipientKind = "user" | "channel";
|
||||
|
||||
function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
|
||||
const rarity = attr.rarity_permille > 0 ? ` · ${(attr.rarity_permille / 10).toFixed(1)}%` : "";
|
||||
return `${attr.name || `#${attr.id}`}${rarity}`;
|
||||
}
|
||||
|
||||
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
|
||||
const [kind, setKind] = useState<RecipientKind>("user");
|
||||
const [user, setUser] = useState<AccountRow | null>(null);
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [hideName, setHideName] = useState(false);
|
||||
const [upgrade, setUpgrade] = useState(false);
|
||||
const [preview, setPreview] = useState<StarGiftCollectiblePreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
const [modelID, setModelID] = useState("0");
|
||||
const [patternID, setPatternID] = useState("0");
|
||||
const [backdropID, setBackdropID] = useState("0");
|
||||
const [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const recipientID = kind === "user" ? user?.ID ?? 0 : channel?.ID ?? 0;
|
||||
const upgradable = kind === "user" && upgrade;
|
||||
|
||||
// Reset the collectible selection whenever the chosen gift changes; the
|
||||
// recipient/sender/message are intentionally preserved for fast re-issuing.
|
||||
useEffect(() => {
|
||||
setUpgrade(false);
|
||||
setPreview(null);
|
||||
setPreviewError("");
|
||||
setModelID("0");
|
||||
setPatternID("0");
|
||||
setBackdropID("0");
|
||||
setResult(null);
|
||||
setError("");
|
||||
}, [gift.GiftID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!upgradable || preview) return;
|
||||
let cancelled = false;
|
||||
setPreviewError("");
|
||||
api.giftCollectibles(gift.GiftID)
|
||||
.then((data) => { if (!cancelled) setPreview(data); })
|
||||
.catch((err) => { if (!cancelled) setPreviewError(errorMessage(err)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [upgradable, preview, gift.GiftID]);
|
||||
|
||||
function buildPayload(confirm: boolean): Record<string, unknown> {
|
||||
return {
|
||||
gift_id: gift.GiftID,
|
||||
// Gifts are always sent from the official system account (777000).
|
||||
sender_user_id: Number(SYSTEM_SENDER),
|
||||
user_id: kind === "user" ? recipientID : 0,
|
||||
channel_id: kind === "channel" ? recipientID : 0,
|
||||
hide_name: hideName,
|
||||
message: message.trim(),
|
||||
upgrade: upgradable,
|
||||
model_attribute_id: upgradable ? modelID : "0",
|
||||
pattern_attribute_id: upgradable ? patternID : "0",
|
||||
backdrop_attribute_id: upgradable ? backdropID : "0",
|
||||
reason: reason.trim(),
|
||||
confirm
|
||||
};
|
||||
}
|
||||
|
||||
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
|
||||
async function run(confirm: boolean) {
|
||||
if (recipientID <= 0) {
|
||||
setError("Select a recipient first");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const commandResult = await api.action("/api/actions/give-gift", buildPayload(confirm));
|
||||
setResult(commandResult);
|
||||
if (confirm && !commandResult.error) {
|
||||
onDone?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="give-gift-form">
|
||||
<div className="give-gift-summary">
|
||||
<Gift size={16} />
|
||||
<div>
|
||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||
<span className="mono">#{gift.GiftID} · ⭐ {gift.Stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="give-gift-tabs" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
|
||||
<User size={15} /> {"User"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
|
||||
<Users size={15} /> {"Channel"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kind === "user"
|
||||
? <UserPicker label={"Recipient user"} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
|
||||
: <ChannelPicker label={"Recipient channel"} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Sender account ID"}</span>
|
||||
<input value={SYSTEM_SENDER} disabled readOnly />
|
||||
<small className="field-hint">{"Gifts are always sent from the system account 777000 (Telesrv)."}</small>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Attached message (optional)"}</span>
|
||||
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={"Shown with the gift"} />
|
||||
</label>
|
||||
|
||||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{"Hide sender name from recipient"}</span>
|
||||
</label>
|
||||
|
||||
{kind === "user" && (
|
||||
<>
|
||||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{"Deliver as upgraded collectible"}</span>
|
||||
</label>
|
||||
{upgrade && <p className="give-gift-upgrade-note">{"The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply."}</p>}
|
||||
{upgrade && previewError && <Alert>{previewError}</Alert>}
|
||||
{upgrade && preview && (
|
||||
<div className="gift-fields-grid give-gift-attrs">
|
||||
<label>
|
||||
<span>{"Model"}</span>
|
||||
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{"Pattern"}</span>
|
||||
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{"Backdrop"}</span>
|
||||
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{"Operation reason"}</span>
|
||||
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
|
||||
</label>
|
||||
|
||||
<div className="command-preview">
|
||||
<div className="preview-head">{"Request preview"}</div>
|
||||
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{result && (
|
||||
<div className="result-box">
|
||||
<div className="result-title">
|
||||
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
|
||||
<strong>{result.message || result.error || "Action result"}</strong>
|
||||
</div>
|
||||
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
|
||||
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="give-gift-form-actions">
|
||||
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||
{result ? "Run dry-run again" : "Run dry-run first"}
|
||||
</button>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
|
||||
<Gift size={15} />
|
||||
{"Give gift"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { Gift, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Badge, PageFrame } from "../components/ui";
|
||||
import type { StarGiftRow } from "../types";
|
||||
import { GiveGiftForm } from "./GiveGiftForm";
|
||||
|
||||
export function GiveGiftsPage() {
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<StarGiftRow | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const rows = (await api.gifts()).Gifts ?? [];
|
||||
setGifts(rows);
|
||||
setSelected((current) => current ?? rows[0] ?? null);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return gifts;
|
||||
return gifts.filter((gift) =>
|
||||
String(gift.GiftID).includes(normalized) || gift.Title.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [gifts, query]);
|
||||
|
||||
return (
|
||||
<PageFrame title={"Give Gifts"} eyebrow={"Grant catalog gifts to any user or channel"} actions={
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<p className="give-gift-upgrade-note">{"Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default."}</p>
|
||||
<div className="give-gift-layout">
|
||||
<section className="give-gift-picker">
|
||||
<div className="give-gift-picker-head">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search by title or gift ID"} /></label>
|
||||
<span className="gift-list-summary">{`Showing ${visible.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
<div className="give-gift-picker-list" role="listbox" aria-label={"Select a gift"}>
|
||||
{visible.map((gift) => {
|
||||
const active = selected?.GiftID === gift.GiftID;
|
||||
return (
|
||||
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
|
||||
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
|
||||
onClick={() => setSelected(gift)}>
|
||||
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
|
||||
<span className="give-gift-option-info">
|
||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||
<span className="mono">#{gift.GiftID}</span>
|
||||
</span>
|
||||
<span className="give-gift-option-price">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{"Disabled"}</Badge>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && !busy && <div className="official-gift-empty">{"No results"}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="give-gift-panel">
|
||||
{selected
|
||||
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
|
||||
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{"Select a gift from the list to start."}</p></div>}
|
||||
</section>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
|
|
@ -16,9 +14,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
|||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { GiftsPage } from "./GiftsPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { StoragePage } from "./StoragePage";
|
||||
|
|
@ -40,7 +36,6 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||
// int64 ids stay strings so large values never lose precision.
|
||||
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
|
||||
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
|
||||
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
|
||||
// Third-party verification: a separate section with its own rights, matched before
|
||||
// the official one so neither prefix can shadow the other.
|
||||
|
|
@ -83,15 +78,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (collectibleUsernameID) {
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
}
|
||||
if (ratingUserID) {
|
||||
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/account-ratings") {
|
||||
return <AccountRatingsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -128,15 +117,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/emoji") {
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
}
|
||||
if (route.path === "/gifts") {
|
||||
return <GiftsPage />;
|
||||
}
|
||||
if (route.path === "/stickers") {
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
}
|
||||
if (route.path === "/give-gifts") {
|
||||
return <GiveGiftsPage />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/account-ratings")) return "Account Rating";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
@ -30,9 +29,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/broadcasts")) return "Broadcasts";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
|
||||
if (pathname.startsWith("/gifts")) return "Star Gifts";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -720,42 +720,3 @@ textarea:focus {
|
|||
}
|
||||
}
|
||||
|
||||
/* Level progress bars (account rating leaderboard and detail). */
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.progress-cell small,
|
||||
.progress-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.progress-bar.good > span {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.progress-bar.danger > span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.progress-wide .progress-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -879,71 +879,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Account rating component breakdown. */
|
||||
.breakdown-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr minmax(80px, auto);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breakdown-label strong {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.breakdown-label small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-value.good {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.breakdown-value.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-row,
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collectible usernames branching off the peer's editable one. The guide is drawn
|
||||
with borders rather than a "↳" character so it lines up at any font size and is
|
||||
not read out by a screen reader as punctuation. */
|
||||
|
|
|
|||
|
|
@ -75,8 +75,6 @@ export type AccountDetail = {
|
|||
Fake: boolean;
|
||||
Support: boolean;
|
||||
Bot: boolean;
|
||||
StarsBalance: number;
|
||||
StarsGranted: boolean;
|
||||
Restriction: RestrictionRow;
|
||||
HasRestriction: boolean;
|
||||
Authorizations: AuthorizationRow[];
|
||||
|
|
@ -205,74 +203,6 @@ export type OutboxRow = {
|
|||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftRow = {
|
||||
GiftID: string;
|
||||
RevisionID: string;
|
||||
Revision: number;
|
||||
Title: string;
|
||||
Stars: string;
|
||||
ConvertStars: string;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
DocumentID: string;
|
||||
SourceName: string;
|
||||
SourceFormat: "tgs" | "lottie";
|
||||
AnimationSHA: string;
|
||||
AnimationSize: string;
|
||||
Width: number;
|
||||
Height: number;
|
||||
FrameRate: number;
|
||||
ReceivedCount: string;
|
||||
CreatedBy: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
|
||||
|
||||
// A built-in original demo gift available to import. Ids are small integers
|
||||
// (1..N), so plain numbers are safe here — no snowflake precision concern.
|
||||
export type DefaultGiftRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
stars: number;
|
||||
convert_stars: number;
|
||||
upgrade_stars: number;
|
||||
upgradeable: boolean;
|
||||
craftable: boolean;
|
||||
limited: boolean;
|
||||
availability: number;
|
||||
require_premium: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_count: number;
|
||||
};
|
||||
|
||||
export type DefaultGiftListResponse = { gifts: DefaultGiftRow[] };
|
||||
|
||||
// A verified official Star Gift snapshot entry (see cmd/giftfetch). Numeric
|
||||
// ids/counters that can approach int64 range are decimal strings.
|
||||
export type OfficialStarGiftRow = {
|
||||
source_gift_id: string;
|
||||
title: string;
|
||||
stars: string;
|
||||
convert_stars: string;
|
||||
upgrade_stars: string;
|
||||
availability_total: number;
|
||||
limited: boolean;
|
||||
sold_out: boolean;
|
||||
model_count: number;
|
||||
pattern_count: number;
|
||||
backdrop_count: number;
|
||||
crafted_model_count: number;
|
||||
can_upgrade: boolean;
|
||||
can_craft: boolean;
|
||||
document_id: string;
|
||||
animation_validated: boolean;
|
||||
};
|
||||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type ModerationPeer = {
|
||||
Type: "user" | "channel";
|
||||
ID: number;
|
||||
|
|
@ -352,37 +282,6 @@ export type ModerationReport = {
|
|||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
name: string;
|
||||
rarity_kind: "permille" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
rarity_permille: number;
|
||||
crafted: boolean;
|
||||
official_document_id: string;
|
||||
sort_order: number;
|
||||
source_name?: string;
|
||||
source_format?: "tgs" | "lottie";
|
||||
backdrop_id?: number;
|
||||
center_color?: number;
|
||||
edge_color?: number;
|
||||
pattern_color?: number;
|
||||
text_color?: number;
|
||||
};
|
||||
|
||||
export type StarGiftCollectiblePreview = {
|
||||
found: boolean;
|
||||
gift_id: string;
|
||||
revision?: number;
|
||||
upgrade_stars?: string;
|
||||
supply_total?: number;
|
||||
issued?: number;
|
||||
slug_prefix?: string;
|
||||
models?: StarGiftCollectibleAttributeRow[];
|
||||
patterns?: StarGiftCollectibleAttributeRow[];
|
||||
backdrops?: StarGiftCollectibleAttributeRow[];
|
||||
};
|
||||
|
||||
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
|
||||
|
||||
export type CollectiblePeerType = "" | "user" | "channel";
|
||||
|
|
@ -448,50 +347,6 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type AccountRatingRow = {
|
||||
UserID: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Level: number;
|
||||
Stars: string;
|
||||
CurrentLevelStars: string;
|
||||
NextLevelStars: string;
|
||||
HasNextLevel: boolean;
|
||||
StarsComponent: string;
|
||||
ActivityComponent: string;
|
||||
PenaltyComponent: string;
|
||||
ManualComponent: string;
|
||||
PendingStars: string;
|
||||
PendingDate: string;
|
||||
ComputedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
|
||||
|
||||
export type AccountRatingEventRow = {
|
||||
ID: string;
|
||||
UserID: string;
|
||||
Kind: AccountRatingEventKind;
|
||||
Amount: string;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type AccountRatingListResponse = {
|
||||
rows: AccountRatingRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type AccountRatingDetail = {
|
||||
rating: AccountRatingRow;
|
||||
events: AccountRatingEventRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
@ -762,7 +617,7 @@ export type CommandResult = {
|
|||
|
||||
export type StickerSetRow = {
|
||||
// String, not number: these are 18-19 digit snowflake ids, past JS's 2^53
|
||||
// safe-integer limit — see GiftID on StarGiftRow for the same convention.
|
||||
// safe-integer limit.
|
||||
ID: string;
|
||||
ShortName: string;
|
||||
Title: string;
|
||||
|
|
|
|||
|
|
@ -50,10 +50,7 @@ import (
|
|||
phoneapp "telesrv/internal/app/phone"
|
||||
pollsapp "telesrv/internal/app/polls"
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
secretchatapp "telesrv/internal/app/secretchat"
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/app/stars"
|
||||
storiesapp "telesrv/internal/app/stories"
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
themesapp "telesrv/internal/app/themes"
|
||||
|
|
@ -68,7 +65,6 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
|
|
@ -873,9 +869,8 @@ func run(logger *zap.Logger) error {
|
|||
rateLimiter := redisstore.NewRateLimiter(rdb)
|
||||
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
||||
adminService := adminapp.NewService(adminapp.Dependencies{
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
|
||||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
})
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
|
|
@ -1095,32 +1090,6 @@ func run(logger *zap.Logger) error {
|
|||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||
starsStore := postgres.NewStarsStore(pool)
|
||||
starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore)
|
||||
starsService := stars.NewService(starsStore,
|
||||
stars.WithStartingGrant(cfg.StarsStartingGrant),
|
||||
stars.WithPurchaseStore(starsPurchaseStore))
|
||||
starGiftStore := postgres.NewStarGiftStore(pool)
|
||||
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
|
||||
OfferMinStars: cfg.StarGiftOfferMinStars,
|
||||
ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second),
|
||||
ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second),
|
||||
CraftChancePermille: cfg.StarGiftCraftChancePermille,
|
||||
}))
|
||||
starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant,
|
||||
postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
||||
StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille,
|
||||
TONProceedsPermille: cfg.StarGiftTONProceedsPermille,
|
||||
}))
|
||||
starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init local star gift withdrawal provider: %w", err)
|
||||
}
|
||||
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
|
||||
stargifts.WithUpgradeStore(starGiftUpgradeStore),
|
||||
stargifts.WithLifecycleStore(starGiftLifecycleStore),
|
||||
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -1216,11 +1185,9 @@ func run(logger *zap.Logger) error {
|
|||
}),
|
||||
auth.WithEmailSignup(cfg.EmailSignupEnable),
|
||||
auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes))
|
||||
// Collectible (NFT) usernames and the gramsrv composite account rating are
|
||||
// optional read models projected at the protocol edge. The rating worker
|
||||
// computes and persists scores; profile reads never recompute them.
|
||||
// Collectible (NFT) usernames are an optional read model projected at the
|
||||
// protocol edge.
|
||||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||||
accountRatingStore := postgres.NewAccountRatingStore(pool)
|
||||
usernamesService := usernamesapp.NewService(
|
||||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||||
|
|
@ -1228,14 +1195,6 @@ func run(logger *zap.Logger) error {
|
|||
usernamesapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
usernamesapp.WithLogger(logger.Named("app").Named("usernames")),
|
||||
)
|
||||
ratingService := ratingapp.NewService(
|
||||
ratingapp.WithStore(accountRatingStore),
|
||||
ratingapp.WithEnabled(cfg.RatingEnabled),
|
||||
ratingapp.WithWeights(cfg.AccountRatingWeights()),
|
||||
ratingapp.WithPendingDelay(cfg.RatingPendingDelay),
|
||||
ratingapp.WithStaleAfter(cfg.RatingStaleAfter),
|
||||
ratingapp.WithLogger(logger.Named("app").Named("rating")),
|
||||
)
|
||||
// Official platform verification: applications are filed through the built-in
|
||||
// @verifybot and decided in the admin panel. Every eligibility rule lives in
|
||||
// this service; the bot and the panel are only its two surfaces.
|
||||
|
|
@ -1339,7 +1298,6 @@ func run(logger *zap.Logger) error {
|
|||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
AccountRatings: ratingService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
|
|
@ -1361,8 +1319,6 @@ func run(logger *zap.Logger) error {
|
|||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Stars: starsService,
|
||||
Gifts: giftsService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
|
|
@ -1393,7 +1349,6 @@ func run(logger *zap.Logger) error {
|
|||
RPCProjections: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
StarGifts: giftsService,
|
||||
AccountSettings: router,
|
||||
}, logger.Named("store").Named("read-model-listener"))
|
||||
go readModelListener.Run(ctx)
|
||||
|
|
@ -1406,23 +1361,18 @@ func run(logger *zap.Logger) error {
|
|||
Auth: authService,
|
||||
Revoker: router,
|
||||
Users: usersService,
|
||||
Stars: starsService,
|
||||
StarsNotifier: router,
|
||||
UserNotifier: router,
|
||||
UserModerationNotifier: router,
|
||||
FreezeNotifier: router,
|
||||
Channels: channelsService,
|
||||
ChannelNotifier: router,
|
||||
Messages: messagesService,
|
||||
Gifts: giftsService,
|
||||
Photos: filesService,
|
||||
StickerSets: filesService,
|
||||
GiftGranter: router,
|
||||
Bots: botsService,
|
||||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
Rating: ratingService,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
@ -1479,8 +1429,6 @@ func run(logger *zap.Logger) error {
|
|||
logger.Warn("third-party verification push is not implemented by the RPC edge",
|
||||
zap.String("expected_hook", "rpc.Router.NotifyPeerBotVerification"))
|
||||
}
|
||||
go ratingapp.NewRecomputeWorker(ratingService, logger.Named("rating").Named("recompute"),
|
||||
cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch).Run(ctx)
|
||||
// Applicant notifications are delivered from a durable outbox, never inside the
|
||||
// decision transaction: @verifybot may be blocked and the panel must not wait on
|
||||
// a message send.
|
||||
|
|
@ -1536,32 +1484,6 @@ func run(logger *zap.Logger) error {
|
|||
if telegramLoginService != nil {
|
||||
go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention"))
|
||||
}
|
||||
go func() {
|
||||
interval := cfg.StarGiftSweepInterval
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
batch := cfg.StarGiftSweepBatch
|
||||
if batch <= 0 {
|
||||
batch = 1000
|
||||
}
|
||||
run := func() {
|
||||
if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}()
|
||||
go router.RunInlineBotPushSubscriber(ctx)
|
||||
go router.RunBotCallbackAnswerSubscriber(ctx)
|
||||
go router.RunEphemeralPushSubscriber(ctx)
|
||||
|
|
@ -1598,8 +1520,6 @@ func run(logger *zap.Logger) error {
|
|||
Channels: channelStore,
|
||||
Privacy: privacyService,
|
||||
Photos: filesService,
|
||||
UniqueGifts: giftsService,
|
||||
GiftWithdrawals: giftsService,
|
||||
ModerationAppeals: moderationService,
|
||||
TelegramLogin: telegramLoginHTTPHandler,
|
||||
}, logger.Named("public-web")); err != nil {
|
||||
|
|
|
|||
|
|
@ -31,3 +31,12 @@ time the migration is written.
|
|||
|
||||
Upstream's own migrations keep their original sequential numbers untouched;
|
||||
only migrations authored in this fork use the timestamp scheme.
|
||||
|
||||
## Removed feature: Stars, Star Gifts, Account Rating
|
||||
|
||||
The Stars economy, Star Gifts (collectibles/NFT gifts, local TON ledger),
|
||||
and the Account Rating system were fully removed from the server (all
|
||||
application/domain/store code and admin panel UI deleted). Their migrations
|
||||
stay in place unmodified and are not reverted — this repo's convention is to
|
||||
never renumber or delete migration history. The corresponding tables are
|
||||
inert/orphaned as of the removal; this is intentional, not an oversight.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,6 @@ package admin
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
|
@ -12,21 +10,15 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
stargiftapp "telesrv/internal/app/stargifts"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
// cmd/telesrv wires *usernames.Service and *rating.Service into
|
||||
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
|
||||
// has to fail here rather than at integration time.
|
||||
// cmd/telesrv wires *usernames.Service into Dependencies.Usernames directly,
|
||||
// so a drifting method set has to fail here rather than at integration time.
|
||||
var (
|
||||
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
|
||||
_ AccountRatingService = (*ratingapp.Service)(nil)
|
||||
)
|
||||
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
|
|
@ -352,59 +344,6 @@ func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGrantStarsDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, Phone: "1001", Username: "alice", FirstName: "Alice"},
|
||||
}}
|
||||
stars := &fakeStarsService{balances: map[int64]domain.StarsBalance{
|
||||
1001: {UserID: 1001, Balance: 1000, Granted: true},
|
||||
}}
|
||||
notifier := &fakeStarsNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
Stars: stars,
|
||||
StarsNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.GrantStars(ctx, GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-stars", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run stars: %v", err)
|
||||
}
|
||||
if !dry.DryRun || stars.creditCalls != 0 || len(notifier.balances) != 0 {
|
||||
t.Fatalf("dry=%+v creditCalls=%d notified=%v, want no mutation", dry, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
|
||||
req := GrantStarsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-stars", Actor: "ops", Reason: "ops grant"},
|
||||
UserID: 1001,
|
||||
Amount: 250,
|
||||
}
|
||||
exec, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute stars: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || stars.creditCalls != 1 || stars.lastAmount != 250 || stars.lastReason != domain.StarsReasonAdjust || len(notifier.balances) != 1 {
|
||||
t.Fatalf("exec=%+v creditCalls=%d amount=%d reason=%s notified=%v", exec, stars.creditCalls, stars.lastAmount, stars.lastReason, notifier.balances)
|
||||
}
|
||||
if exec.Details["updated_balance"] != int64(1250) {
|
||||
t.Fatalf("updated_balance=%v, want 1250", exec.Details["updated_balance"])
|
||||
}
|
||||
again, err := svc.GrantStars(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate stars: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || stars.creditCalls != 1 || len(notifier.balances) != 1 {
|
||||
t.Fatalf("again=%+v creditCalls=%d notified=%v, want idempotent replay", again, stars.creditCalls, notifier.balances)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
|
|
@ -897,47 +836,6 @@ func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, st
|
|||
return u, nil
|
||||
}
|
||||
|
||||
type fakeStarsService struct {
|
||||
balances map[int64]domain.StarsBalance
|
||||
creditCalls int
|
||||
lastUserID int64
|
||||
lastAmount int64
|
||||
lastReason domain.StarsTransactionReason
|
||||
lastPeer domain.Peer
|
||||
lastTitle string
|
||||
lastDesc string
|
||||
}
|
||||
|
||||
func (f *fakeStarsService) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
f.creditCalls++
|
||||
f.lastUserID = userID
|
||||
f.lastAmount = amount
|
||||
f.lastReason = reason
|
||||
f.lastPeer = peer
|
||||
f.lastTitle = title
|
||||
f.lastDesc = desc
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
if f.balances == nil {
|
||||
f.balances = map[int64]domain.StarsBalance{}
|
||||
}
|
||||
balance := f.balances[userID]
|
||||
balance.UserID = userID
|
||||
balance.Balance += amount
|
||||
f.balances[userID] = balance
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
type fakeStarsNotifier struct {
|
||||
balances []domain.StarsBalance
|
||||
}
|
||||
|
||||
func (f *fakeStarsNotifier) NotifyStarsBalanceChanged(_ context.Context, balance domain.StarsBalance) error {
|
||||
f.balances = append(f.balances, balance)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUserNotifier struct {
|
||||
users []int64
|
||||
}
|
||||
|
|
@ -1054,424 +952,6 @@ type fakeChannelNotifier struct {
|
|||
channels []int64
|
||||
}
|
||||
|
||||
func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := ImportStarGiftRequest{
|
||||
Title: "Cake", Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 3,
|
||||
FileName: "cake.lottie", Data: []byte(`{"v":"5.7"}`),
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-gift", Actor: "ops", Reason: "catalog", DryRun: true}
|
||||
preview, err := svc.ImportStarGift(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["source_format"] != domain.StarGiftAnimationLottie {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
|
||||
result, err := svc.ImportStarGift(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandIDConflictRejectsDifferentGiftBytes(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
req := ImportStarGiftRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "same", Actor: "ops", Reason: "catalog", DryRun: true},
|
||||
Title: "Gift", Stars: 10, ConvertStars: 5, Enabled: true, FileName: "a.lottie", Data: []byte("one"),
|
||||
}
|
||||
if _, err := svc.ImportStarGift(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Data = []byte("two")
|
||||
if _, err := svc.ImportStarGift(context.Background(), req); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
|
||||
t.Fatalf("conflict err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
|
||||
base := PublishStarGiftCollectiblesRequest{
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileKey: "model-1", FileName: "sapphire.lottie", Data: []byte("model-1")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")},
|
||||
{Name: "Moons", RarityPermille: 500, FileKey: "pattern-1", FileName: "moons.tgs", Data: []byte("pattern-1")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
|
||||
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["models"] == nil {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
|
||||
result, err := svc.PublishStarGiftCollectibles(context.Background(), base)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishStarGiftCollectiblesRejectsUnsafeClientPreviewPool(t *testing.T) {
|
||||
valid := func() PublishStarGiftCollectiblesRequest {
|
||||
return PublishStarGiftCollectiblesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "unsafe-pool", Actor: "ops", Reason: "regression", DryRun: true},
|
||||
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
|
||||
Models: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Ruby", RarityPermille: 500, FileName: "ruby.lottie", Data: []byte("ruby")},
|
||||
{Name: "Sapphire", RarityPermille: 500, FileName: "sapphire.lottie", Data: []byte("sapphire")},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAnimationUpload{
|
||||
{Name: "Stars", RarityPermille: 500, FileName: "stars.lottie", Data: []byte("stars")},
|
||||
{Name: "Moons", RarityPermille: 500, FileName: "moons.lottie", Data: []byte("moons")},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleBackdropInput{
|
||||
{Name: "Night", BackdropID: 1, RarityPermille: 500},
|
||||
{Name: "Day", BackdropID: 2, RarityPermille: 500},
|
||||
},
|
||||
}
|
||||
}
|
||||
tests := map[string]func(*PublishStarGiftCollectiblesRequest){
|
||||
"single model": func(req *PublishStarGiftCollectiblesRequest) { req.Models = req.Models[:1] },
|
||||
"single pattern": func(req *PublishStarGiftCollectiblesRequest) { req.Patterns = req.Patterns[:1] },
|
||||
"single backdrop": func(req *PublishStarGiftCollectiblesRequest) { req.Backdrops = req.Backdrops[:1] },
|
||||
"duplicate backdrop id": func(req *PublishStarGiftCollectiblesRequest) {
|
||||
req.Backdrops[1].BackdropID = req.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := valid()
|
||||
mutate(&req)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: &fakeGiftsService{}, Now: fixedNow})
|
||||
if _, err := svc.PublishStarGiftCollectibles(context.Background(), req); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
|
||||
permille := 922
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x42, 32),
|
||||
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
|
||||
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
|
||||
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
|
||||
},
|
||||
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
|
||||
{Name: "Regular Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 5, FileName: "regular-two.tgs", SHA256: strings.Repeat("e", 64), Data: []byte("regular-two")}},
|
||||
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}},
|
||||
{Name: "Pattern Two", DocumentID: 6, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 6, FileName: "pattern-two.tgs", SHA256: strings.Repeat("f", 64), Data: []byte("pattern-two")}},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "White", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
gifts := &fakeGiftsService{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
|
||||
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
|
||||
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
|
||||
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
|
||||
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
|
||||
}
|
||||
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
|
||||
result, err := svc.ImportOfficialStarGift(context.Background(), req)
|
||||
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
|
||||
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
|
||||
}
|
||||
models := gifts.lastBundle.Collectible.Models
|
||||
if len(models) != 3 || !models[2].Crafted || models[2].RarityKind != domain.StarGiftRarityLegendary || models[2].RarityPermille != 0 ||
|
||||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
|
||||
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
|
||||
}
|
||||
catalog := gifts.lastBundle.Catalog
|
||||
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
|
||||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
|
||||
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
|
||||
}
|
||||
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
|
||||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
|
||||
t.Fatalf("official provenance was not preserved: %+v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
if list := svc.DefaultStarGifts(); len(list) != 3 {
|
||||
t.Fatalf("default gifts = %d, want 3", len(list))
|
||||
}
|
||||
|
||||
// Dry-run must not write to the catalog.
|
||||
dry := ImportDefaultStarGiftRequest{ID: 3, CommandMeta: CommandMeta{CommandID: "dry-default-3", Actor: "ops", Reason: "demo", DryRun: true}}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, dry); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if cat, _ := giftService.Catalog(ctx); len(cat) != 0 {
|
||||
t.Fatalf("dry run wrote %d gifts", len(cat))
|
||||
}
|
||||
|
||||
// Confirmed import-all creates the whole demo set, enabled per the request flag.
|
||||
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
result, err := svc.ImportAllDefaultStarGifts(ctx, all)
|
||||
if err != nil || result.Details["imported"] != 3 {
|
||||
t.Fatalf("import all: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 3 {
|
||||
t.Fatalf("catalog=%d err=%v, want 3", len(catalog), err)
|
||||
}
|
||||
limited, premium, upgradeable := 0, 0, 0
|
||||
var craftGiftID int64
|
||||
for _, g := range catalog {
|
||||
if g.Limited {
|
||||
limited++
|
||||
}
|
||||
if g.RequirePremium {
|
||||
premium++
|
||||
}
|
||||
if g.UpgradeStars > 0 {
|
||||
upgradeable++
|
||||
}
|
||||
if g.Title == "OwpenGram Coin" {
|
||||
craftGiftID = g.ID
|
||||
}
|
||||
}
|
||||
if limited != 0 || premium != 0 || upgradeable != 2 {
|
||||
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
|
||||
}
|
||||
// The craftable gift must carry a craft-only (named-rarity) model.
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, craftGiftID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("coin preview ok=%v err=%v", ok, err)
|
||||
}
|
||||
crafted := 0
|
||||
for _, m := range preview.Models {
|
||||
if m.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
if crafted == 0 {
|
||||
t.Fatalf("coin has no craft-only model: %+v", preview.Models)
|
||||
}
|
||||
|
||||
// Re-running import-all is idempotent: everything already present -> skipped.
|
||||
// Enabled must match the first run's value (true) or the per-gift replay
|
||||
// hits COMMAND_ID_CONFLICT since the payload would differ from the cached one.
|
||||
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}, Enabled: true})
|
||||
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 3 {
|
||||
t.Fatalf("re-import: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportDefaultStarGiftRespectsEnabledFlag is a regression test: a single
|
||||
// default gift import must honor the request's Enabled flag rather than
|
||||
// always landing enabled (or, before this fix, always disabled regardless of
|
||||
// the admin console checkbox).
|
||||
func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
off := ImportDefaultStarGiftRequest{ID: 1, CommandMeta: CommandMeta{CommandID: "default-1-off", Actor: "ops", Reason: "demo"}, Enabled: false}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, off); err != nil {
|
||||
t.Fatalf("import disabled: %v", err)
|
||||
}
|
||||
on := ImportDefaultStarGiftRequest{ID: 2, CommandMeta: CommandMeta{CommandID: "default-2-on", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, on); err != nil {
|
||||
t.Fatalf("import enabled: %v", err)
|
||||
}
|
||||
|
||||
// Catalog() only ever returns enabled gifts, so presence/absence here
|
||||
// directly proves whether the Enabled flag was honored.
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%d err=%v, want 1 (only the enabled gift)", len(catalog), err)
|
||||
}
|
||||
if catalog[0].Title != "OwpenGram Star" {
|
||||
t.Fatalf("unexpected gift in catalog: %q, want %q", catalog[0].Title, "OwpenGram Star")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
|
||||
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
|
||||
document := func(id int64, name string) officialgifts.Document {
|
||||
raw := []byte(lottie)
|
||||
sum := sha256.Sum256(raw)
|
||||
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
|
||||
}
|
||||
permille := 1000
|
||||
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
|
||||
ManifestSHA256: bytesOf(0x24, sha256.Size),
|
||||
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
|
||||
Gift: officialgifts.Gift{
|
||||
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
|
||||
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
|
||||
},
|
||||
BaseDocument: document(1, "gift.json"),
|
||||
Collectible: &officialgifts.CollectibleSet{
|
||||
Models: []officialgifts.Model{
|
||||
{Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(2, "model.json")},
|
||||
{Name: "Model Two", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(4, "model-two.json")},
|
||||
},
|
||||
Patterns: []officialgifts.Pattern{
|
||||
{Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(3, "pattern.json")},
|
||||
{Name: "Pattern Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(5, "pattern-two.json")},
|
||||
},
|
||||
Backdrops: []officialgifts.Backdrop{
|
||||
{Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
{Name: "Backdrop Two", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
|
||||
})
|
||||
req := ImportOfficialStarGiftRequest{
|
||||
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
|
||||
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
|
||||
}
|
||||
result, err := svc.ImportOfficialStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
|
||||
}
|
||||
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
|
||||
if err != nil || !ok || len(preview.Models) != 2 || len(preview.Patterns) != 2 {
|
||||
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
|
||||
}
|
||||
model := preview.Models[0].Document
|
||||
pattern := preview.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
|
||||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
type adminGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *adminGiftBlob) Name() string { return "localfs" }
|
||||
func (b *adminGiftBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func bytesOf(value byte, count int) []byte {
|
||||
out := make([]byte, count)
|
||||
for i := range out {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
|
||||
|
||||
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
|
||||
if giftID != f.bundle.Gift.ID {
|
||||
return officialgifts.Bundle{}, officialgifts.ErrNotFound
|
||||
}
|
||||
out := f.bundle
|
||||
if !include {
|
||||
out.Collectible = nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeGiftsService struct {
|
||||
createCalls int
|
||||
lastBundle domain.StarGiftCatalogBundleWrite
|
||||
}
|
||||
|
||||
func (f *fakeGiftsService) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if id <= 0 {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationLottie,
|
||||
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeGiftsService) Catalog(context.Context) ([]domain.StarGift, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return f.PrepareAnimation(name, data)
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
f.createCalls++
|
||||
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
|
||||
f.createCalls++
|
||||
f.lastBundle = write
|
||||
entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1}
|
||||
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
|
||||
if write.Collectible != nil {
|
||||
revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true}
|
||||
result.Collectible = &revision
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (*fakeGiftsService) SetCatalogSortOrder(context.Context, int64, int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (*fakeGiftsService) AnimationJSON(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7"}`), true, nil
|
||||
}
|
||||
func (f *fakeGiftsService) CreateCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
f.createCalls++
|
||||
return domain.StarGiftCollectibleRevision{ID: 33, GiftID: write.GiftID, Revision: 2, Published: true}, nil
|
||||
}
|
||||
func (*fakeGiftsService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
func (*fakeGiftsService) CollectibleAnimationJSON(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7"}`), true, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
|
||||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
|
|
@ -1627,82 +1107,6 @@ func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsern
|
|||
return domain.CollectibleUsername{}
|
||||
}
|
||||
|
||||
// fakeAccountRatingService recomputes from the manual component only, which
|
||||
// keeps the arithmetic in the domain and the fake focused on ledger replay.
|
||||
type fakeAccountRatingService struct {
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
commandKeys map[string]bool
|
||||
recomputeCalls int
|
||||
adjustCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountRating() *fakeAccountRatingService {
|
||||
return &fakeAccountRatingService{
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
commandKeys: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.recomputeCalls++
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
|
||||
}, domain.DefaultAccountRatingWeights(), fixedNow())
|
||||
rating.Version = f.ratings[userID].Version + 1
|
||||
f.ratings[userID] = rating
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
f.adjustCalls++
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
applied := true
|
||||
if f.commandKeys[req.CommandKey] {
|
||||
applied = false
|
||||
} else {
|
||||
f.commandKeys[req.CommandKey] = true
|
||||
f.manual[req.UserID] += req.Amount
|
||||
}
|
||||
rating, err := f.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level < filter.MinLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
if f.manual[userID] == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
|
|
@ -1929,101 +1333,7 @@ func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
|
||||
UserID: 1001, Amount: -2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
|
||||
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
|
||||
}
|
||||
|
||||
execReq := AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
|
||||
UserID: 1001, Amount: -2500,
|
||||
}
|
||||
exec, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute adjust: %v", err)
|
||||
}
|
||||
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
|
||||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
|
||||
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
|
||||
}
|
||||
|
||||
again, err := svc.AdjustAccountRating(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay adjust: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
|
||||
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
|
||||
}
|
||||
|
||||
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
|
||||
UserID: 1001, Amount: amount,
|
||||
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
|
||||
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
|
||||
}
|
||||
}
|
||||
if _, journalled := repo.items["bad-adjust"]; journalled {
|
||||
t.Fatal("journalled a rejected adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
rating := newFakeAccountRating()
|
||||
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
|
||||
|
||||
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
|
||||
}); err == nil || !strings.Contains(err.Error(), "user_id") {
|
||||
t.Fatalf("recompute without user err=%v", err)
|
||||
}
|
||||
|
||||
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
|
||||
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
|
||||
UserID: 1001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute recompute: %v", err)
|
||||
}
|
||||
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
|
||||
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
|
||||
}
|
||||
|
||||
stored, err := svc.AccountRating(ctx, 1001)
|
||||
if err != nil || stored.Stars != 5000 {
|
||||
t.Fatalf("AccountRating = %+v err=%v", stored, err)
|
||||
}
|
||||
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
|
||||
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
func TestCollectibleCommandsRequireConfiguredDependencies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
|
||||
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
|
||||
|
|
@ -2032,18 +1342,9 @@ func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T)
|
|||
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
|
||||
t.Fatalf("mint without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
|
||||
UserID: 1001, Amount: 5,
|
||||
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
|
||||
t.Fatalf("adjust without dependency err=%v", err)
|
||||
}
|
||||
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
|
||||
t.Fatal("listing without dependency succeeded")
|
||||
}
|
||||
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
|
||||
t.Fatal("rating listing without dependency succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -44,7 +42,6 @@ type Service interface {
|
|||
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
|
||||
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
|
||||
|
|
@ -70,18 +67,6 @@ type Service interface {
|
|||
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
|
||||
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportDefaultStarGift(ctx context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportAllDefaultStarGifts(ctx context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error)
|
||||
DefaultStarGifts() []giftdemo.GiftInfo
|
||||
DefaultStarGiftAnimation(ctx context.Context, id int) ([]byte, bool, error)
|
||||
ImportOfficialStarGift(ctx context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error)
|
||||
ImportAllOfficialStarGifts(ctx context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error)
|
||||
OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSummary, error)
|
||||
OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error)
|
||||
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
|
||||
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
|
||||
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
|
||||
SetStickerSetArchived(ctx context.Context, req admin.SetStickerSetArchivedRequest) (admin.CommandResult, error)
|
||||
SetStickerSetSortOrder(ctx context.Context, req admin.SetStickerSetSortOrderRequest) (admin.CommandResult, error)
|
||||
RenameStickerSet(ctx context.Context, req admin.RenameStickerSetRequest) (admin.CommandResult, error)
|
||||
|
|
@ -90,11 +75,7 @@ type Service interface {
|
|||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
||||
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||
GiveGift(ctx context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error)
|
||||
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
|
||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||
|
|
@ -109,11 +90,6 @@ type Service interface {
|
|||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
|
||||
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
|
|
@ -195,7 +171,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
|
||||
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
|
||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
|
||||
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
|
||||
|
|
@ -221,18 +196,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
|
||||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
|
||||
mux.HandleFunc("GET /v1/default-gifts", s.authenticated(s.handleDefaultStarGifts))
|
||||
mux.HandleFunc("GET /v1/default-gifts/{id}/animation", s.authenticated(s.handleDefaultStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import", s.authenticated(s.handleImportDefaultStarGift))
|
||||
mux.HandleFunc("POST /v1/default-gifts/import-all", s.authenticated(s.handleImportAllDefaultStarGifts))
|
||||
mux.HandleFunc("GET /v1/official-gifts", s.authenticated(s.handleOfficialStarGifts))
|
||||
mux.HandleFunc("GET /v1/official-gifts/{id}/animation", s.authenticated(s.handleOfficialStarGiftAnimation))
|
||||
mux.HandleFunc("POST /v1/official-gifts/import", s.authenticated(s.handleImportOfficialStarGift))
|
||||
mux.HandleFunc("POST /v1/official-gifts/import-all", s.authenticated(s.handleImportAllOfficialStarGifts))
|
||||
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
|
||||
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
|
||||
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
|
||||
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
|
||||
mux.HandleFunc("POST /v1/stickers/set-sort-order", s.authenticated(s.handleSetStickerSetSortOrder))
|
||||
mux.HandleFunc("POST /v1/stickers/rename", s.authenticated(s.handleRenameStickerSet))
|
||||
|
|
@ -241,11 +204,7 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
||||
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
||||
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
||||
mux.HandleFunc("POST /v1/gifts/give", s.authenticated(s.handleGiveGift))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
|
||||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
|
||||
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
|
||||
|
|
@ -259,10 +218,6 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
|
||||
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
|
||||
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
|
||||
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
|
|
@ -337,15 +292,6 @@ func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleGrantStars(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GrantStarsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GrantStars(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetVerified(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetVerifiedRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -634,231 +580,6 @@ func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.ImportStarGiftRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "animation file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
writeError(w, http.StatusBadRequest, "animation file is empty or too large")
|
||||
return
|
||||
}
|
||||
req.FileName = header.Filename
|
||||
req.Data = data
|
||||
result, err := s.svc.ImportStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
items := s.svc.DefaultStarGifts()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gifts": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleDefaultStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.DefaultStarGiftAnimation(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "default gift animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportDefaultStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportDefaultStarGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportDefaultStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportAllDefaultStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportAllDefaultStarGiftsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportAllDefaultStarGifts(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.svc.OfficialStarGifts(r.Context())
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, officialgifts.ErrUnavailable) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, officialStarGiftListItem(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gifts": result})
|
||||
}
|
||||
|
||||
func officialStarGiftListItem(item officialgifts.GiftSummary) map[string]any {
|
||||
return map[string]any{
|
||||
"source_gift_id": strconv.FormatInt(item.ID, 10), "title": item.Title,
|
||||
"stars": strconv.FormatInt(item.Stars, 10), "convert_stars": strconv.FormatInt(item.ConvertStars, 10),
|
||||
"upgrade_stars": strconv.FormatInt(item.UpgradeStars, 10),
|
||||
"availability_total": item.AvailabilityTotal, "limited": item.Limited, "sold_out": item.SoldOut,
|
||||
"model_count": item.ModelCount, "pattern_count": item.PatternCount, "backdrop_count": item.BackdropCount,
|
||||
"crafted_model_count": item.CraftedModelCount, "can_upgrade": item.CanUpgrade(), "can_craft": item.CanCraft(),
|
||||
"document_id": strconv.FormatInt(item.DocumentID, 10), "animation_validated": item.AnimationValidated,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleOfficialStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
raw, found, err := s.svc.OfficialStarGiftAnimation(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "official gift animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportOfficialStarGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportOfficialStarGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportOfficialStarGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportAllOfficialStarGifts(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ImportAllOfficialStarGiftsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ImportAllOfficialStarGifts(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.PublishStarGiftCollectiblesRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.GiftID = giftID
|
||||
seen := make(map[string]struct{}, len(req.Models)+len(req.Patterns))
|
||||
if len(req.Models)+len(req.Patterns) > 128 {
|
||||
writeError(w, http.StatusBadRequest, "too many collectible animation files")
|
||||
return
|
||||
}
|
||||
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
|
||||
upload.FileKey = strings.TrimSpace(upload.FileKey)
|
||||
if upload.FileKey == "" {
|
||||
return fmt.Errorf("animation file key is required")
|
||||
}
|
||||
if _, ok := seen[upload.FileKey]; ok {
|
||||
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
|
||||
}
|
||||
seen[upload.FileKey] = struct{}{}
|
||||
file, header, err := r.FormFile(upload.FileKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("animation file %q is required", upload.FileKey)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 4<<20 {
|
||||
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
|
||||
}
|
||||
upload.FileName = header.Filename
|
||||
upload.Data = data
|
||||
return nil
|
||||
}
|
||||
for i := range req.Models {
|
||||
if err := load(&req.Models[i]); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range req.Patterns {
|
||||
if err := load(&req.Patterns[i]); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.svc.PublishStarGiftCollectibles(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStarGiftEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStarGiftEnabledRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetStarGiftEnabled(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStarGiftSortOrderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetStarGiftSortOrder(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetStickerSetArchived(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetStickerSetArchivedRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -993,36 +714,6 @@ func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.R
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleGiveGift(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GiveGiftRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.GiveGift(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.StarGiftAnimation(r.Context(), giftID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "gift animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || documentID <= 0 {
|
||||
|
|
@ -1044,86 +735,6 @@ func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || giftID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift id")
|
||||
return
|
||||
}
|
||||
preview, found, err := s.svc.StarGiftCollectibles(r.Context(), giftID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": strconv.FormatInt(giftID, 10)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
|
||||
}
|
||||
|
||||
func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
|
||||
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
|
||||
result := map[string]any{
|
||||
"id": strconv.FormatInt(value.ID, 10), "name": value.Name, "rarity_kind": value.RarityKind,
|
||||
"rarity_permille": value.RarityPermille, "crafted": value.Crafted,
|
||||
"official_document_id": strconv.FormatInt(value.OfficialDocumentID, 10),
|
||||
"sort_order": value.SortOrder, "kind": value.Kind,
|
||||
}
|
||||
if value.Animation != nil {
|
||||
result["source_name"] = value.Animation.SourceName
|
||||
result["source_format"] = value.Animation.SourceFormat
|
||||
}
|
||||
if value.Kind == domain.StarGiftCollectibleBackdrop {
|
||||
result["backdrop_id"] = value.BackdropID
|
||||
result["center_color"] = value.CenterColor
|
||||
result["edge_color"] = value.EdgeColor
|
||||
result["pattern_color"] = value.PatternColor
|
||||
result["text_color"] = value.TextColor
|
||||
}
|
||||
return result
|
||||
}
|
||||
mapAttributes := func(values []domain.StarGiftCollectibleAttribute) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, attribute(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return map[string]any{
|
||||
"found": true, "gift_id": strconv.FormatInt(preview.GiftID, 10), "revision": preview.Revision,
|
||||
"upgrade_stars": strconv.FormatInt(preview.UpgradeStars, 10),
|
||||
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
|
||||
"slug_prefix": preview.SlugPrefix,
|
||||
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
|
||||
"backdrops": mapAttributes(preview.Backdrops),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *http.Request) {
|
||||
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
|
||||
kind := domain.StarGiftCollectibleAttributeKind(r.PathValue("kind"))
|
||||
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 ||
|
||||
(kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern) {
|
||||
writeError(w, http.StatusBadRequest, "invalid collectible animation")
|
||||
return
|
||||
}
|
||||
raw, found, err := s.svc.StarGiftCollectibleAnimation(r.Context(), giftID, kind, attributeID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "collectible animation not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
type moderationClaimRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
|
|
@ -1450,24 +1061,6 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RecomputeAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.AdjustAccountRatingRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.AdjustAccountRating(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
|
|
@ -1533,66 +1126,6 @@ func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Reques
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
minLevel, ok := optionalQueryInt(w, query, "min_level")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := optionalQueryInt64(w, query, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
|
||||
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ratings := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
ratings = append(ratings, accountRatingResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rating, err := s.svc.AccountRating(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
|
||||
if err != nil {
|
||||
writeAccountRatingError(w, err)
|
||||
return
|
||||
}
|
||||
ledger := make([]map[string]any, 0, len(events))
|
||||
for _, item := range events {
|
||||
ledger = append(ledger, accountRatingEventResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": accountRatingResponse(rating), "events": ledger,
|
||||
})
|
||||
}
|
||||
|
||||
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
|
||||
// identifiers may be present, mirroring the mint/transfer request shape.
|
||||
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
|
||||
|
|
@ -1695,50 +1228,6 @@ func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer
|
|||
|
||||
// accountRatingResponse renders one composite rating. The score and every
|
||||
// component stay decimal strings for the same exactness reason as the asset ids.
|
||||
func accountRatingResponse(rating domain.AccountRating) map[string]any {
|
||||
out := map[string]any{
|
||||
"user_id": strconv.FormatInt(rating.UserID, 10),
|
||||
"level": rating.Level,
|
||||
"stars": strconv.FormatInt(rating.Stars, 10),
|
||||
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
|
||||
"has_next_level": rating.HasNextLevel,
|
||||
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
|
||||
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
|
||||
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
|
||||
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
|
||||
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
|
||||
"version": strconv.FormatInt(rating.Version, 10),
|
||||
}
|
||||
if rating.HasNextLevel {
|
||||
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
|
||||
}
|
||||
if !rating.PendingDate.IsZero() {
|
||||
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.ComputedAt.IsZero() {
|
||||
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !rating.UpdatedAt.IsZero() {
|
||||
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(event.ID, 10),
|
||||
"user_id": strconv.FormatInt(event.UserID, 10),
|
||||
"kind": string(event.Kind),
|
||||
"amount": strconv.FormatInt(event.Amount, 10),
|
||||
"reason": event.Reason,
|
||||
"actor": event.Actor,
|
||||
"command_key": event.CommandKey,
|
||||
}
|
||||
if !event.CreatedAt.IsZero() {
|
||||
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeCollectibleUsernameError maps a collectible-username failure onto its
|
||||
// stable admin code and the matching HTTP status, the way writeModerationError
|
||||
|
|
@ -1760,18 +1249,6 @@ func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
|
|||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeAccountRatingError(w http.ResponseWriter, err error) {
|
||||
code := admin.AccountRatingErrorCode(err)
|
||||
status := http.StatusInternalServerError
|
||||
switch code {
|
||||
case admin.CodeRatingNotFound:
|
||||
status = http.StatusNotFound
|
||||
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeCodedError(w, status, code, err.Error())
|
||||
}
|
||||
|
||||
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
|
||||
body := map[string]string{"error": msg}
|
||||
if code != "" {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -12,8 +10,6 @@ import (
|
|||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/officialgifts"
|
||||
"telesrv/internal/seed/giftdemo"
|
||||
)
|
||||
|
||||
func TestAdminAPIRequiresBearerToken(t *testing.T) {
|
||||
|
|
@ -278,20 +274,6 @@ func TestAdminAPISetVerified(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIGrantStars(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/grant-stars", strings.NewReader(`{"command_id":"c-stars","actor":"ops","reason":"manual grant","dry_run":true,"user_id":1001,"amount":500}`))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"command_id":"c-stars"`) {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPISetChannelVerified(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/channels/set-verified", strings.NewReader(`{"command_id":"c3","actor":"ops","reason":"official","dry_run":true,"channel_id":2001,"verified":true}`))
|
||||
|
|
@ -306,103 +288,6 @@ func TestAdminAPISetChannelVerified(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("metadata", `{"command_id":"gift-1","actor":"ops","reason":"catalog","dry_run":true,"title":"Gift","stars":50,"convert_stars":25,"enabled":true,"sort_order":3}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", "gift.lottie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
animation := []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`)
|
||||
if _, err := part.Write(animation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &captureGiftService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/import", &body)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.CommandID != "gift-1" || svc.req.FileName != "gift.lottie" || !bytes.Equal(svc.req.Data, animation) || svc.req.Stars != 50 || svc.req.ConvertStars != 25 {
|
||||
t.Fatalf("decoded gift request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":500,"sort_order":0,"file_key":"model-0"},{"name":"Sapphire","rarity_permille":500,"sort_order":1,"file_key":"model-1"}],"patterns":[{"name":"Stars","rarity_permille":500,"sort_order":0,"file_key":"pattern-0"},{"name":"Moons","rarity_permille":500,"sort_order":1,"file_key":"pattern-1"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":500,"sort_order":0},{"name":"Day","backdrop_id":2,"center_color":11189196,"edge_color":7833753,"pattern_color":14544639,"text_color":1118481,"rarity_permille":500,"sort_order":1}]}`
|
||||
if err := writer.WriteField("metadata", metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, name := range map[string]string{
|
||||
"model-0": "ruby.lottie", "model-1": "sapphire.lottie",
|
||||
"pattern-0": "stars.tgs", "pattern-1": "moons.tgs",
|
||||
} {
|
||||
part, err := writer.CreateFormFile(key, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write([]byte(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &captureCollectibleService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/11/collectibles/publish", &body)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.req.GiftID != 11 || len(svc.req.Models) != 2 || svc.req.Models[0].FileName != "ruby.lottie" ||
|
||||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 2 || svc.req.Backdrops[1].BackdropID != 2 {
|
||||
t.Fatalf("decoded collectible request = %+v", svc.req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectiblePreviewResponsePreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
got := collectiblePreviewResponse(domain.StarGiftUpgradePreview{
|
||||
GiftID: maxInt64,
|
||||
UpgradeStars: maxInt64,
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
ID: maxInt64,
|
||||
Kind: domain.StarGiftCollectibleModel,
|
||||
Name: "Exact",
|
||||
RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000,
|
||||
OfficialDocumentID: maxInt64,
|
||||
}},
|
||||
})
|
||||
if got["gift_id"] != "9223372036854775807" || got["upgrade_stars"] != "9223372036854775807" {
|
||||
t.Fatalf("preview ids = %#v", got)
|
||||
}
|
||||
models, ok := got["models"].([]map[string]any)
|
||||
if !ok || len(models) != 1 {
|
||||
t.Fatalf("preview models = %#v", got["models"])
|
||||
}
|
||||
if models[0]["id"] != "9223372036854775807" || models[0]["official_document_id"] != "9223372036854775807" {
|
||||
t.Fatalf("preview model ids = %#v", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
type captureFreezeService struct {
|
||||
|
|
@ -410,31 +295,11 @@ type captureFreezeService struct {
|
|||
req admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
type captureGiftService struct {
|
||||
fakeService
|
||||
req admin.ImportStarGiftRequest
|
||||
}
|
||||
|
||||
type captureCollectibleService struct {
|
||||
fakeService
|
||||
req admin.PublishStarGiftCollectiblesRequest
|
||||
}
|
||||
|
||||
func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureGiftService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
s.req = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -443,10 +308,6 @@ func (fakeService) GrantPremium(_ context.Context, req admin.GrantPremiumRequest
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GrantStars(_ context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetVerified(_ context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -483,10 +344,6 @@ func (fakeService) SetSupport(_ context.Context, req admin.SetSupportRequest) (a
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) GiveGift(_ context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
@ -551,18 +408,6 @@ func (fakeService) DeletePrivateHistory(context.Context, admin.DeletePrivateHist
|
|||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportDefaultStarGift(_ context.Context, req admin.ImportDefaultStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportAllDefaultStarGifts(_ context.Context, req admin.ImportAllDefaultStarGiftsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountAvatar(context.Context, int64) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
|
@ -599,58 +444,10 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
|
|||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
func (fakeService) DefaultStarGifts() []giftdemo.GiftInfo {
|
||||
return giftdemo.List()
|
||||
}
|
||||
|
||||
func (fakeService) DefaultStarGiftAnimation(context.Context, int) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportOfficialStarGift(_ context.Context, req admin.ImportOfficialStarGiftRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ImportAllOfficialStarGifts(_ context.Context, req admin.ImportAllOfficialStarGiftsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGiftAnimation(context.Context, string) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetStarGiftEnabled(_ context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetStarGiftSortOrder(_ context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -747,64 +544,12 @@ func maxInt64Collectible() domain.CollectibleUsername {
|
|||
}
|
||||
}
|
||||
|
||||
type captureAccountRatingService struct {
|
||||
fakeService
|
||||
recompute admin.RecomputeAccountRatingRequest
|
||||
adjust admin.AdjustAccountRatingRequest
|
||||
filter domain.AccountRatingFilter
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.recompute = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
s.adjust = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
s.filter = filter
|
||||
return []domain.AccountRating{maxInt64Rating()}, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating := maxInt64Rating()
|
||||
rating.UserID = userID
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
|
||||
return []domain.AccountRatingEvent{{
|
||||
ID: 9223372036854775807, UserID: userID,
|
||||
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
|
||||
Actor: "ops", Reason: "abuse",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func maxInt64Rating() domain.AccountRating {
|
||||
return domain.AccountRating{
|
||||
UserID: 1001,
|
||||
Level: 7,
|
||||
Stars: 9223372036854775807,
|
||||
CurrentLevelStars: 4900,
|
||||
NextLevelStars: 6400,
|
||||
HasNextLevel: true,
|
||||
StarsComponent: 9223372036854775807,
|
||||
ManualComponent: -1500,
|
||||
Version: 9223372036854775807,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
for _, path := range []string{
|
||||
"/v1/collectible-usernames/mint",
|
||||
"/v1/collectible-usernames/transfer",
|
||||
"/v1/collectible-usernames/revoke",
|
||||
"/v1/account-ratings/recompute",
|
||||
"/v1/account-ratings/adjust",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -816,8 +561,6 @@ func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
|
|||
for _, path := range []string{
|
||||
"/v1/collectible-usernames",
|
||||
"/v1/collectible-usernames/7",
|
||||
"/v1/account-ratings",
|
||||
"/v1/account-ratings/7",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -875,28 +618,6 @@ func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingCommands(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
|
||||
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
|
||||
recompute.Header.Set("Authorization", "Bearer secret")
|
||||
recomputeRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(recomputeRec, recompute)
|
||||
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
|
||||
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
|
||||
}
|
||||
|
||||
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
|
||||
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
|
||||
adjust.Header.Set("Authorization", "Bearer secret")
|
||||
adjustRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(adjustRec, adjust)
|
||||
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
|
||||
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
|
|
@ -939,44 +660,7 @@ func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
|
||||
svc := &captureAccountRatingService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
listRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(listRec, list)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
|
||||
t.Fatalf("rating filter = %+v", svc.filter)
|
||||
}
|
||||
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
|
||||
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
|
||||
}
|
||||
|
||||
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
|
||||
detail.Header.Set("Authorization", "Bearer secret")
|
||||
detailRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(detailRec, detail)
|
||||
if detailRec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Rating map[string]any `json:"rating"`
|
||||
Events []map[string]any `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
|
||||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating detail payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
|
||||
func TestAdminAPIMissingCollectibleReportsCodedError(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
|
||||
asset.Header.Set("Authorization", "Bearer secret")
|
||||
|
|
@ -986,15 +670,6 @@ func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
|
|||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
|
||||
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
|
||||
}
|
||||
|
||||
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
|
||||
rating.Header.Set("Authorization", "Bearer secret")
|
||||
ratingRec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(ratingRec, rating)
|
||||
if ratingRec.Code != http.StatusNotFound ||
|
||||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
|
||||
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
|
|
@ -1024,23 +699,3 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
|||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -981,21 +981,6 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
|
|||
return s.channels.SetChannelMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本
|
||||
// Debit 完成,本方法只负责累计与聚合。
|
||||
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.AddChannelMessagePaidReaction(ctx, req)
|
||||
}
|
||||
|
||||
// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。
|
||||
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
|
|
|
|||
|
|
@ -24,15 +24,3 @@ func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, sende
|
|||
}
|
||||
return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息,
|
||||
// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。
|
||||
func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,454 +0,0 @@
|
|||
// Package rating implements the composite account rating use cases: reading the
|
||||
// stored projection, recomputing it from the raw contribution signals, and
|
||||
// applying operator adjustments through the contribution ledger.
|
||||
//
|
||||
// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
|
||||
// private algorithm. The service gathers signals, applies the configured
|
||||
// weights and pending-delay policy, and persists the result under optimistic
|
||||
// concurrency for both admin and read-only client projection.
|
||||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultPendingDelay parks a rating increase for a day, matching the
|
||||
// shipped TELESRV_RATING_PENDING_DELAY default.
|
||||
defaultPendingDelay = 24 * time.Hour
|
||||
// defaultStaleAfter is the recompute horizon used when none is configured.
|
||||
defaultStaleAfter = 6 * time.Hour
|
||||
// defaultListLimit / maxListLimit bound one leaderboard page.
|
||||
defaultListLimit = 50
|
||||
maxListLimit = 200
|
||||
// defaultEventLimit / maxEventLimit bound one ledger page.
|
||||
defaultEventLimit = 50
|
||||
maxEventLimit = 200
|
||||
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
|
||||
defaultRecomputeBatch = 500
|
||||
maxRecomputeBatch = 10000
|
||||
)
|
||||
|
||||
// ErrDisabled reports that the local composite rating feature is switched off.
|
||||
// Reads degrade to an empty admin projection; writes are refused so an operator
|
||||
// never believes an adjustment was recorded when it was not.
|
||||
var ErrDisabled = errors.New("account rating is disabled")
|
||||
|
||||
// Service is the composite account rating use-case layer.
|
||||
type Service struct {
|
||||
store store.AccountRatingStore
|
||||
weights domain.AccountRatingWeights
|
||||
pendingDelay time.Duration
|
||||
staleAfter time.Duration
|
||||
enabled bool
|
||||
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Option adjusts optional service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithStore injects the rating read model and ledger store.
|
||||
func WithStore(st store.AccountRatingStore) Option {
|
||||
return func(s *Service) { s.store = st }
|
||||
}
|
||||
|
||||
// WithWeights installs the composite formula. An invalid set is rejected in
|
||||
// favour of the shipped defaults, so a misconfigured deployment produces a
|
||||
// conservative rating instead of an inconsistent one.
|
||||
func WithWeights(weights domain.AccountRatingWeights) Option {
|
||||
return func(s *Service) {
|
||||
if err := weights.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
s.weights = weights
|
||||
}
|
||||
}
|
||||
|
||||
// WithPendingDelay configures how long a rating increase stays parked as
|
||||
// pending. Zero applies every change immediately.
|
||||
func WithPendingDelay(delay time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if delay >= 0 {
|
||||
s.pendingDelay = delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStaleAfter configures the projection age after which the background
|
||||
// worker recomputes a user.
|
||||
func WithStaleAfter(staleAfter time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if staleAfter > 0 {
|
||||
s.staleAfter = staleAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnabled toggles the feature.
|
||||
func WithEnabled(enabled bool) Option {
|
||||
return func(s *Service) { s.enabled = enabled }
|
||||
}
|
||||
|
||||
// WithClock injects the clock (tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the service logger.
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return func(s *Service) {
|
||||
if log != nil {
|
||||
s.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates the rating service. It is enabled by default so that the
|
||||
// only switch is the configuration flag, and it stays safe without a store:
|
||||
// reads answer empty and writes report a configuration error.
|
||||
func NewService(opts ...Option) *Service {
|
||||
s := &Service{
|
||||
weights: domain.DefaultAccountRatingWeights(),
|
||||
pendingDelay: defaultPendingDelay,
|
||||
staleAfter: defaultStaleAfter,
|
||||
enabled: true,
|
||||
now: time.Now,
|
||||
log: zap.NewNop(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
if s.pendingDelay < 0 {
|
||||
s.pendingDelay = 0
|
||||
}
|
||||
if s.staleAfter <= 0 {
|
||||
s.staleAfter = defaultStaleAfter
|
||||
}
|
||||
if err := s.weights.Validate(); err != nil {
|
||||
s.weights = domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether the feature is switched on.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
||||
|
||||
// Ready reports whether the feature is on and backed by a store.
|
||||
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
|
||||
|
||||
// Weights returns the configured composite formula, so the admin panel can
|
||||
// explain a level with the same numbers that produced it.
|
||||
func (s *Service) Weights() domain.AccountRatingWeights {
|
||||
if s == nil {
|
||||
return domain.DefaultAccountRatingWeights()
|
||||
}
|
||||
return s.weights
|
||||
}
|
||||
|
||||
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
return s.store, nil
|
||||
}
|
||||
|
||||
// Rating returns the stored projection.
|
||||
//
|
||||
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
|
||||
// value so the admin API can distinguish "not computed" from a computed zero.
|
||||
// A missing store reports a configuration error an operator can diagnose.
|
||||
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return st.AccountRating(ctx, userID)
|
||||
}
|
||||
|
||||
// RatingBatch resolves several users in one round trip. Users without a stored
|
||||
// projection are absent from the map, so a disabled feature and an unconfigured
|
||||
// store both read as "nobody has a rating" -- the batch shape already encodes
|
||||
// absence and needs no error to express it.
|
||||
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || s.store == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
unique := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
unique = append(unique, userID)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
batch, err := s.store.AccountRatingBatch(ctx, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batch == nil {
|
||||
return map[int64]domain.AccountRating{}, nil
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// Recompute gathers the contribution signals, applies the configured weights
|
||||
// and the pending-delay policy relative to the stored value, and persists the
|
||||
// result.
|
||||
//
|
||||
// The save is guarded by the stored version. A concurrent writer (another
|
||||
// recompute, an adjustment, the worker) only invalidates the base the pending
|
||||
// policy was resolved against, so exactly one retry against the freshly
|
||||
// returned row is both sufficient and terminating.
|
||||
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
// The service accounts are infrastructure, not participants. Refusing here as
|
||||
// well as in the seeding query means an operator cannot create a rating for one
|
||||
// by hand either -- the platform account is not flagged is_bot, so nothing else
|
||||
// would stop it.
|
||||
if !domain.RatableAccount(userID, false) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
signals, err := st.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
signals.UserID = userID
|
||||
prev, err := s.previous(ctx, st, userID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
computed := domain.ComputeAccountRating(signals, s.weights, now)
|
||||
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if changed {
|
||||
return stored, nil
|
||||
}
|
||||
// One retry: `stored` is the row that won the race, so resolving the pending
|
||||
// policy against it produces the correct next version.
|
||||
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if !changed {
|
||||
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// Adjust records an operator adjustment in the contribution ledger and
|
||||
// immediately recomputes the projection, so the manual component is visible
|
||||
// without waiting for the background worker. Replaying the same CommandKey
|
||||
// records nothing and reports applied=false; the current rating is still
|
||||
// returned so a retried admin command stays idempotent.
|
||||
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return domain.AccountRating{}, false, ErrDisabled
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
_, applied, err := st.AdjustAccountRating(ctx, req)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, err
|
||||
}
|
||||
rating, err := s.Recompute(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, applied, err
|
||||
}
|
||||
return rating, applied, nil
|
||||
}
|
||||
|
||||
// List is the admin leaderboard query with a bounded page size.
|
||||
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.MinLevel < 0 {
|
||||
filter.MinLevel = 0
|
||||
}
|
||||
if filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
filter.MinLevel = domain.MaxAccountRatingLevel
|
||||
}
|
||||
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
||||
return st.ListAccountRatings(ctx, filter)
|
||||
}
|
||||
|
||||
// Events returns one user's contribution ledger, newest first.
|
||||
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
|
||||
}
|
||||
|
||||
// RunRecomputeCycle advances the read model by one bounded batch and returns how
|
||||
// many users it wrote. A single user's failure is logged and skipped: one poisoned
|
||||
// row must not stall the whole cycle.
|
||||
//
|
||||
// The cycle does two things, and the order matters. It first refreshes projections
|
||||
// that have gone stale, because those are rows somebody is already looking at.
|
||||
// Whatever batch budget is left it spends seeding accounts that have no projection
|
||||
// at all -- without that pass the read model can never populate itself, since
|
||||
// StaleAccountRatings walks account_rating and cannot return a user who is not in
|
||||
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
|
||||
// all, which is what makes the admin leaderboard populate without an operator
|
||||
// opening every account first.
|
||||
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || !s.enabled {
|
||||
return 0, nil
|
||||
}
|
||||
st, err := s.ratingStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
|
||||
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
|
||||
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
|
||||
// can never turn one cycle into an unbounded amount of work.
|
||||
remaining := limit - len(userIDs)
|
||||
if remaining <= 0 {
|
||||
return processed, nil
|
||||
}
|
||||
unrated, err := st.UnratedAccounts(ctx, remaining)
|
||||
if err != nil {
|
||||
// Seeding extends the cycle rather than being its purpose: a store that
|
||||
// cannot enumerate accounts must not turn a successful stale pass into a
|
||||
// failed cycle.
|
||||
s.log.Warn("list unrated accounts failed", zap.Error(err))
|
||||
return processed, nil
|
||||
}
|
||||
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
|
||||
return processed + seeded, err
|
||||
}
|
||||
|
||||
// recomputeEach recomputes a list of users, skipping the ones that fail, and
|
||||
// giving up early only when the context is done.
|
||||
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
|
||||
processed := 0
|
||||
for _, userID := range userIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Recompute(ctx, userID); err != nil {
|
||||
s.log.Warn(failureMessage,
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// EnsureRating returns the stored local-admin projection, computing and storing
|
||||
// it first when an administrative caller needs an immediate value.
|
||||
//
|
||||
// The background cycle reaches every account eventually; callers that require a
|
||||
// local rating immediately use this bounded materialization path instead.
|
||||
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || !s.enabled || userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := s.Rating(ctx, userID)
|
||||
if err == nil {
|
||||
return rating, nil
|
||||
}
|
||||
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return s.Recompute(ctx, userID)
|
||||
}
|
||||
|
||||
// previous reads the stored projection the pending policy is resolved against.
|
||||
// A never-computed user yields the zero value, which domain.ResolveAccountRating
|
||||
// Pending treats as "apply immediately" -- a first rating is never parked.
|
||||
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
|
||||
prev, err := st.AccountRating(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
return domain.AccountRating{}, nil
|
||||
}
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
func clampLimit(limit, fallback, maximum int) int {
|
||||
if limit <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if limit > maximum {
|
||||
return maximum
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
|
@ -1,719 +0,0 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
|
||||
// concurrency contract as PostgreSQL: a save whose version does not follow the
|
||||
// stored one reports changed=false and returns the row that won.
|
||||
type fakeRatingStore struct {
|
||||
signals map[int64]domain.AccountRatingSignals
|
||||
ratings map[int64]domain.AccountRating
|
||||
manual map[int64]int64
|
||||
events map[int64][]domain.AccountRatingEvent
|
||||
keys map[string]domain.AccountRatingEvent
|
||||
|
||||
stale []int64
|
||||
staleOlderThan int64
|
||||
staleLimit int
|
||||
|
||||
unrated []int64
|
||||
unratedLimit int
|
||||
unratedCalls int
|
||||
unratedErr error
|
||||
|
||||
saves []domain.AccountRating
|
||||
forceConflicts int
|
||||
signalsErr error
|
||||
}
|
||||
|
||||
func newFakeRatingStore() *fakeRatingStore {
|
||||
return &fakeRatingStore{
|
||||
signals: map[int64]domain.AccountRatingSignals{},
|
||||
ratings: map[int64]domain.AccountRating{},
|
||||
manual: map[int64]int64{},
|
||||
events: map[int64][]domain.AccountRatingEvent{},
|
||||
keys: map[string]domain.AccountRatingEvent{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
rating, ok := f.ratings[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if rating, ok := f.ratings[userID]; ok {
|
||||
out[userID] = rating
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
f.saves = append(f.saves, rating)
|
||||
current := f.ratings[rating.UserID]
|
||||
if f.forceConflicts > 0 {
|
||||
f.forceConflicts--
|
||||
return current, false, nil
|
||||
}
|
||||
if rating.Version != current.Version+1 {
|
||||
return current, false, nil
|
||||
}
|
||||
f.ratings[rating.UserID] = rating
|
||||
return rating, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if f.signalsErr != nil {
|
||||
return domain.AccountRatingSignals{}, f.signalsErr
|
||||
}
|
||||
signals := f.signals[userID]
|
||||
signals.UserID = userID
|
||||
signals.Manual = f.manual[userID]
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if req.CommandKey != "" {
|
||||
if event, ok := f.keys[req.CommandKey]; ok {
|
||||
return event, false, nil
|
||||
}
|
||||
}
|
||||
event := domain.AccountRatingEvent{
|
||||
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
|
||||
}
|
||||
f.events[req.UserID] = append(f.events[req.UserID], event)
|
||||
f.manual[req.UserID] += req.Amount
|
||||
if req.CommandKey != "" {
|
||||
f.keys[req.CommandKey] = event
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
out := make([]domain.AccountRating, 0, len(f.ratings))
|
||||
for _, rating := range f.ratings {
|
||||
if rating.Level >= filter.MinLevel {
|
||||
out = append(out, rating)
|
||||
}
|
||||
if len(out) >= filter.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
events := f.events[userID]
|
||||
if len(events) > limit {
|
||||
events = events[:limit]
|
||||
}
|
||||
return append([]domain.AccountRatingEvent(nil), events...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
f.staleOlderThan = olderThanUnix
|
||||
f.staleLimit = limit
|
||||
if len(f.stale) > limit {
|
||||
return append([]int64(nil), f.stale[:limit]...), nil
|
||||
}
|
||||
return append([]int64(nil), f.stale...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
||||
f.unratedCalls++
|
||||
f.unratedLimit = limit
|
||||
if f.unratedErr != nil {
|
||||
return nil, f.unratedErr
|
||||
}
|
||||
out := make([]int64, 0, len(f.unrated))
|
||||
for _, userID := range f.unrated {
|
||||
if _, rated := f.ratings[userID]; rated {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
|
||||
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
|
||||
return NewService(append(base, opts...)...)
|
||||
}
|
||||
|
||||
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{
|
||||
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
weights := domain.DefaultAccountRatingWeights()
|
||||
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
||||
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
||||
GiftsReceived: 2, ModerationCases: 1,
|
||||
}, weights, testNow)
|
||||
if rating.Stars != want.Stars || rating.Level != want.Level ||
|
||||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
|
||||
rating.PenaltyComponent != want.PenaltyComponent {
|
||||
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
|
||||
}
|
||||
if rating.Version != 1 {
|
||||
t.Fatalf("first stored version = %d, want 1", rating.Version)
|
||||
}
|
||||
if !rating.ComputedAt.Equal(testNow) {
|
||||
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePendingPolicy(t *testing.T) {
|
||||
t.Run("increase is parked", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 100 {
|
||||
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
|
||||
}
|
||||
if rating.PendingStars != 400 {
|
||||
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
|
||||
}
|
||||
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
|
||||
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
|
||||
}
|
||||
if rating.Version != 5 {
|
||||
t.Fatalf("version = %d, want 5", rating.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrease applies immediately", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 0 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
|
||||
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{
|
||||
UserID: 7, Stars: 100, Level: 1, Version: 2,
|
||||
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
|
||||
}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
|
||||
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero delay never parks", func(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if rating.Stars != 500 || rating.PendingStars != 0 {
|
||||
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 1
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, err := service.Recompute(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("Recompute: %v", err)
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
|
||||
}
|
||||
if rating.Version != 4 || rating.Stars != 200 {
|
||||
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
||||
st.forceConflicts = 2
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute reported success while every save lost the version race")
|
||||
}
|
||||
if len(st.saves) != 2 {
|
||||
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
|
||||
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
|
||||
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
|
||||
})
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("Adjust = %v, %v", applied, err)
|
||||
}
|
||||
if rating.ManualComponent != 300 || rating.Stars != 400 {
|
||||
t.Fatalf("rating = %#v, want the manual component folded in", rating)
|
||||
}
|
||||
if len(st.events[7]) != 1 {
|
||||
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st, WithPendingDelay(0))
|
||||
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
|
||||
|
||||
first, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("first Adjust = %v, %v", applied, err)
|
||||
}
|
||||
second, applied, err := service.Adjust(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("replayed Adjust: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("replayed Adjust reported applied=true")
|
||||
}
|
||||
if len(st.events[7]) != 1 || st.manual[7] != 300 {
|
||||
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
|
||||
}
|
||||
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
|
||||
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustValidatesRequest(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st)
|
||||
tests := []domain.AdjustAccountRatingRequest{
|
||||
{UserID: 0, Amount: 10},
|
||||
{UserID: 7, Amount: 0},
|
||||
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
|
||||
}
|
||||
for _, req := range tests {
|
||||
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
|
||||
}
|
||||
}
|
||||
if len(st.events) != 0 || len(st.saves) != 0 {
|
||||
t.Fatal("store was touched by an invalid adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2, 3}
|
||||
for _, userID := range st.stale {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st, WithStaleAfter(6*time.Hour))
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want 3", processed)
|
||||
}
|
||||
if st.staleLimit != 10 {
|
||||
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
|
||||
}
|
||||
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
|
||||
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
|
||||
}
|
||||
for _, userID := range st.stale {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("user %d was not recomputed", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 0, 2}
|
||||
st.forceConflicts = 2 // both saves of the first user lose the race
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the surviving user only", processed)
|
||||
}
|
||||
if st.staleLimit != defaultRecomputeBatch {
|
||||
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("disabled service reported enabled/ready")
|
||||
}
|
||||
// The userFull projection omits both TL flags on this error, which is exactly
|
||||
// the pre-rating wire shape.
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
batch, err := service.RatingBatch(context.Background(), []int64{7})
|
||||
if err != nil || len(batch) != 0 {
|
||||
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
|
||||
}
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil || processed != 0 {
|
||||
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
|
||||
service := NewService()
|
||||
if service.Ready() {
|
||||
t.Fatal("Ready = true without a store")
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); err == nil {
|
||||
t.Fatal("Rating accepted a missing store")
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
||||
t.Fatal("Recompute accepted a missing store")
|
||||
}
|
||||
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
|
||||
t.Fatal("Adjust accepted a missing store")
|
||||
}
|
||||
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
|
||||
t.Fatal("RunRecomputeCycle accepted a missing store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilServiceIsSafe(t *testing.T) {
|
||||
var service *Service
|
||||
if service.Enabled() || service.Ready() {
|
||||
t.Fatal("nil service reported enabled/ready")
|
||||
}
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("nil service weights = %#v, want the defaults", got)
|
||||
}
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
|
||||
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
|
||||
}
|
||||
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
|
||||
}
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
|
||||
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
||||
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndEventsBoundThePage(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
|
||||
for i := range maxEventLimit + 10 {
|
||||
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List = %d rows, want 1", len(list))
|
||||
}
|
||||
events, err := service.Events(context.Background(), 7, 100000)
|
||||
if err != nil {
|
||||
t.Fatalf("Events: %v", err)
|
||||
}
|
||||
if len(events) != maxEventLimit {
|
||||
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
|
||||
}
|
||||
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Events accepted a zero user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
|
||||
service := newTestService(st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
|
||||
}()
|
||||
// The first cycle runs before the ticker, so cancelling immediately still
|
||||
// leaves exactly one recompute behind.
|
||||
<-time.After(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not stop on context cancellation")
|
||||
}
|
||||
if _, ok := st.ratings[1]; !ok {
|
||||
t.Fatal("worker did not recompute the stale user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
|
||||
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
worker.Run(context.Background())
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled worker kept running")
|
||||
}
|
||||
if worker.batch != defaultRecomputeBatch {
|
||||
t.Fatalf("batch = %d, want the default fallback", worker.batch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
|
||||
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
|
||||
// so it can only ever refresh rows that already exist; without a seeding pass the
|
||||
// very first row for a user has to come from an operator recomputing that user by
|
||||
// hand, and the read model stays permanently empty.
|
||||
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.unrated = []int64{11, 12, 13}
|
||||
for _, userID := range st.unrated {
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
||||
}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the three seeded accounts", processed)
|
||||
}
|
||||
for _, userID := range st.unrated {
|
||||
if _, ok := st.ratings[userID]; !ok {
|
||||
t.Fatalf("account %d was not seeded", userID)
|
||||
}
|
||||
}
|
||||
// A second cycle has nothing left to seed, so seeding converges instead of
|
||||
// rewriting the same rows every interval.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
|
||||
// must not let one cycle do an unbounded amount of work.
|
||||
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1, 2}
|
||||
st.unrated = []int64{11, 12, 13, 14}
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("processed = %d, want the batch bound of 3", processed)
|
||||
}
|
||||
if st.unratedLimit != 1 {
|
||||
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
|
||||
}
|
||||
|
||||
// A cycle whose stale pass already fills the batch does not query for seeds at
|
||||
// all: refreshing rows somebody is looking at comes first.
|
||||
full := newFakeRatingStore()
|
||||
full.stale = []int64{1, 2, 3}
|
||||
full.unrated = []int64{11}
|
||||
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
|
||||
t.Fatalf("RunRecomputeCycle: %v", err)
|
||||
}
|
||||
if full.unratedCalls != 0 {
|
||||
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
|
||||
// accounts must not turn a successful stale pass into a failed cycle.
|
||||
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.stale = []int64{1}
|
||||
st.unratedErr = errors.New("no users table")
|
||||
service := newTestService(st)
|
||||
|
||||
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want the one stale row", processed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
|
||||
// when the worker has not reached an account yet, the first read materializes the
|
||||
// local projection and the second read must not write again.
|
||||
func TestEnsureRatingMaterializesOnce(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
rating, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRating: %v", err)
|
||||
}
|
||||
if rating.UserID != 7 || rating.Stars == 0 {
|
||||
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
|
||||
}
|
||||
writes := len(st.saves)
|
||||
again, err := service.EnsureRating(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureRating: %v", err)
|
||||
}
|
||||
if again.Version != rating.Version {
|
||||
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
|
||||
}
|
||||
if len(st.saves) != writes {
|
||||
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled feature materialises nothing. Telegram wire fields remain unset
|
||||
// independently of this local feature flag.
|
||||
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
||||
service := newTestService(st, WithEnabled(false))
|
||||
|
||||
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
if len(st.saves) != 0 {
|
||||
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
|
||||
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
|
||||
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
|
||||
// rating in the first place -- and an operator must not be able to create one by
|
||||
// hand either.
|
||||
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
|
||||
for _, userID := range domain.SystemUserIDs() {
|
||||
st := newFakeRatingStore()
|
||||
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
st.unrated = []int64{userID}
|
||||
service := newTestService(st)
|
||||
|
||||
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
|
||||
}
|
||||
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
|
||||
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
|
||||
}
|
||||
if len(st.ratings) != 0 {
|
||||
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
|
||||
}
|
||||
// A seeding pass that is somehow handed one skips it rather than failing the
|
||||
// whole cycle.
|
||||
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
||||
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary account is unaffected.
|
||||
st := newFakeRatingStore()
|
||||
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
|
||||
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
|
||||
t.Fatalf("Recompute of an ordinary account: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package rating
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultRecomputeInterval matches the shipped
|
||||
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
|
||||
defaultRecomputeInterval = 15 * time.Minute
|
||||
)
|
||||
|
||||
// RecomputeWorker keeps the rating read model fresh.
|
||||
//
|
||||
// The projection is derived from signals that change outside the rating write
|
||||
// path (Stars flow, message activity, moderation decisions, account age), so no
|
||||
// single writer can keep it current. This worker walks the stale projections in
|
||||
// bounded batches; it never recomputes the whole table in one pass, and a
|
||||
// cancelled context stops it between users rather than mid-write.
|
||||
type RecomputeWorker struct {
|
||||
service *Service
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
|
||||
// interval/batch fall back to the shipped defaults, matching the retention
|
||||
// worker's contract.
|
||||
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultRecomputeInterval
|
||||
}
|
||||
if batch <= 0 {
|
||||
batch = defaultRecomputeBatch
|
||||
}
|
||||
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
|
||||
}
|
||||
|
||||
// Run recomputes one batch immediately and then on every tick until ctx is
|
||||
// done. A disabled or store-less service exits immediately with one explicit
|
||||
// log line instead of ticking forever over a no-op.
|
||||
func (w *RecomputeWorker) Run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !w.service.Ready() {
|
||||
w.logger.Info("account rating recompute worker disabled",
|
||||
zap.Bool("enabled", w.service.Enabled()))
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RecomputeWorker) runOnce(ctx context.Context) {
|
||||
if w == nil || w.service == nil {
|
||||
return
|
||||
}
|
||||
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("account rating recompute cycle failed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if processed > 0 {
|
||||
w.logger.Info("account rating recompute cycle completed",
|
||||
zap.Int("processed", processed),
|
||||
zap.Int("batch", w.batch))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
|
||||
// single canonical pair used by both the Telegram download path and admin preview.
|
||||
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimation(fileName, data)
|
||||
}
|
||||
|
||||
// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official
|
||||
// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary
|
||||
// operator uploads continue through PrepareAnimation and reject expressions.
|
||||
func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimationWithPolicy(fileName, data, true)
|
||||
}
|
||||
|
||||
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
||||
return prepareAnimationWithPolicy(fileName, data, false)
|
||||
}
|
||||
|
||||
func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) {
|
||||
fileName = strings.TrimSpace(filepath.Base(fileName))
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
format := domain.StarGiftAnimationLottie
|
||||
var rawJSON []byte
|
||||
if ext == ".tgs" || isGzip(data) {
|
||||
format = domain.StarGiftAnimationTGS
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
var err error
|
||||
rawJSON, err = decompressSingleTGS(data)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
} else {
|
||||
if ext != ".json" && ext != ".lottie" {
|
||||
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
rawJSON = data
|
||||
}
|
||||
|
||||
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
|
||||
if err != nil {
|
||||
return domain.StarGiftAnimation{}, err
|
||||
}
|
||||
tgs, err := gzipLottie(normalized)
|
||||
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
|
||||
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
sum := sha256.Sum256(tgs)
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: fileName,
|
||||
SourceFormat: format,
|
||||
JSON: normalized,
|
||||
TGS: tgs,
|
||||
SHA256: append([]byte(nil), sum[:]...),
|
||||
Width: meta.W,
|
||||
Height: meta.H,
|
||||
FrameRate: meta.FrameRate,
|
||||
InPoint: meta.InPoint,
|
||||
OutPoint: meta.OutPoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type lottieMetadata struct {
|
||||
Version string `json:"v"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
FrameRate float64 `json:"fr"`
|
||||
InPoint float64 `json:"ip"`
|
||||
OutPoint float64 `json:"op"`
|
||||
Layers []json.RawMessage `json:"layers"`
|
||||
Assets []json.RawMessage `json:"assets"`
|
||||
}
|
||||
|
||||
func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) {
|
||||
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
|
||||
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
var root any
|
||||
if err := dec.Decode(&root); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if _, ok := root.(map[string]any); !ok {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
if !allowExpressions && containsLottieExpression(root) {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
var meta lottieMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
frameSpan := meta.OutPoint - meta.InPoint
|
||||
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
|
||||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
|
||||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
|
||||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
|
||||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
|
||||
// pre-composition assets with only an id/layers payload remain valid.
|
||||
for _, raw := range meta.Assets {
|
||||
var asset map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &asset) != nil {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
for _, key := range []string{"p", "u"} {
|
||||
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
|
||||
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
|
||||
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return compact.Bytes(), meta, nil
|
||||
}
|
||||
|
||||
func containsLottieExpression(value any) bool {
|
||||
switch node := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range node {
|
||||
if key == "x" {
|
||||
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range node {
|
||||
if containsLottieExpression(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGzip(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
|
||||
func decompressSingleTGS(data []byte) ([]byte, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
gz, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
gz.Multistream(false)
|
||||
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
|
||||
closeErr := gz.Close()
|
||||
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
|
||||
return nil, domain.ErrStarGiftFileInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func gzipLottie(data []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gz.Header.ModTime = time.Unix(0, 0)
|
||||
gz.Header.OS = 255
|
||||
if _, err := gz.Write(data); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
|
||||
|
||||
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
|
||||
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare lottie: %v", err)
|
||||
}
|
||||
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
|
||||
t.Fatalf("prepared lottie = %+v", fromJSON)
|
||||
}
|
||||
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare tgs: %v", err)
|
||||
}
|
||||
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
|
||||
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
|
||||
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
|
||||
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
|
||||
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
|
||||
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *testGiftBlob) Name() string { return "localfs" }
|
||||
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
b.data[key] = append([]byte(nil), data...)
|
||||
return key, nil
|
||||
}
|
||||
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return append([]byte(nil), b.data[key]...), nil
|
||||
}
|
||||
|
||||
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
|
||||
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
|
||||
t.Fatalf("current=%+v found=%v", current, found)
|
||||
}
|
||||
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
|
||||
if !found || historical.Stars != 50 || historical.Title != "OwpenGram Pin" {
|
||||
t.Fatalf("historical=%+v found=%v", historical, found)
|
||||
}
|
||||
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
|
||||
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := make([]byte, sha256.Size)
|
||||
_, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
|
||||
Catalog: domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation,
|
||||
OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`),
|
||||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
OfficialGiftID: 11, SourceManifestSHA256: hash,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStarGiftStore()
|
||||
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
|
||||
animation, err := svc.PrepareOfficialAnimation("official.json", []byte(validGiftLottie))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestSHA := make([]byte, sha256.Size)
|
||||
result, err := svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
|
||||
Catalog: domain.StarGiftCatalogWrite{
|
||||
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, Animation: animation,
|
||||
Actor: "test", CommandID: "official-catalog", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA, OfficialSourceJSON: []byte(`{"id":10}`),
|
||||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create official collectible bundle: %v", err)
|
||||
}
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 2 || len(result.Collectible.Patterns) != 2 {
|
||||
t.Fatalf("collectible result = %+v", result.Collectible)
|
||||
}
|
||||
model := result.Collectible.Models[0].Document
|
||||
pattern := result.Collectible.Patterns[0].Document
|
||||
if model == nil || !model.IsSticker() || model.IsCustomEmoji() {
|
||||
t.Fatalf("model document = %+v, want ordinary sticker", model)
|
||||
}
|
||||
if pattern == nil || pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
|
||||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
|
||||
t.Fatalf("pattern document = %+v, want text-color custom emoji with inline path", pattern)
|
||||
}
|
||||
if !pattern.Attributes[1].TextColor {
|
||||
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
|
||||
}
|
||||
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
|
||||
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
|
||||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
|
||||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) {
|
||||
pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor {
|
||||
t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern)
|
||||
}
|
||||
model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel)
|
||||
if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor {
|
||||
t.Fatalf("model attributes = %+v, want ordinary sticker", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) {
|
||||
pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern)
|
||||
if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath ||
|
||||
pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern)
|
||||
}
|
||||
pattern[0].Bytes[0] ^= 0xff
|
||||
if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
|
||||
t.Fatal("collectibleDocumentThumbs returned shared mutable bytes")
|
||||
}
|
||||
if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 {
|
||||
t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const localWithdrawalTTL = 15 * time.Minute
|
||||
|
||||
// LocalWithdrawalProvider implements the TON/export UX entirely inside
|
||||
// telesrv. It mints an unguessable, short-lived bearer URL; no external
|
||||
// blockchain, Fragment endpoint, wallet or network RPC is contacted.
|
||||
type LocalWithdrawalProvider struct {
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) {
|
||||
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||
parsed, err := url.Parse(publicBaseURL)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, fmt.Errorf("invalid local star gift withdrawal base URL")
|
||||
}
|
||||
return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil
|
||||
}
|
||||
|
||||
func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" }
|
||||
|
||||
func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) {
|
||||
if p == nil || p.publicBaseURL == "" {
|
||||
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured")
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
return StarGiftWithdrawalProviderResult{
|
||||
RequestID: token,
|
||||
URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token),
|
||||
ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) {
|
||||
for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} {
|
||||
if _, err := NewLocalWithdrawalProvider(invalid); err == nil {
|
||||
t.Fatalf("invalid withdrawal base URL %q accepted", invalid)
|
||||
}
|
||||
}
|
||||
provider, err := NewLocalWithdrawalProvider("https://example.test/base/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 ||
|
||||
result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID ||
|
||||
strings.ContainsAny(result.RequestID, "+/=") {
|
||||
t.Fatalf("local withdrawal result = %+v", result)
|
||||
}
|
||||
expires := time.Unix(int64(result.ExpiresAt), 0)
|
||||
if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) {
|
||||
t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
package stargifts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/officialgifts"
|
||||
)
|
||||
|
||||
// This opt-in test is run by the official import audit. It validates every distinct base,
|
||||
// model and pattern document with the trusted official animation policy, including the
|
||||
// small set of Telegram-authored expression animations.
|
||||
func TestConfiguredOfficialSnapshotAnimations(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
|
||||
}
|
||||
catalog := officialgifts.New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &stargifts.Service{}
|
||||
seen := map[int64]struct{}{}
|
||||
validate := func(document officialgifts.Document) {
|
||||
t.Helper()
|
||||
if _, ok := seen[document.ID]; ok {
|
||||
return
|
||||
}
|
||||
seen[document.ID] = struct{}{}
|
||||
if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil {
|
||||
t.Fatalf("document %d (%s): %v", document.ID, document.Path, err)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0)
|
||||
if err != nil {
|
||||
t.Fatalf("gift %d: %v", item.ID, err)
|
||||
}
|
||||
validate(bundle.BaseDocument)
|
||||
if bundle.Collectible == nil {
|
||||
continue
|
||||
}
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
validate(model.Document)
|
||||
}
|
||||
for _, pattern := range bundle.Collectible.Patterns {
|
||||
validate(pattern.Document)
|
||||
}
|
||||
}
|
||||
if len(seen) != 8333 {
|
||||
t.Fatalf("validated %d documents, want 8333", len(seen))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
// Package stargifts implements the durable Star Gift catalog and received-gift state.
|
||||
package stargifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// BlobBackend is the content-addressed media boundary used by the catalog importer.
|
||||
type BlobBackend interface {
|
||||
Name() string
|
||||
Put(ctx context.Context, data []byte) (string, error)
|
||||
Get(ctx context.Context, objectKey string) ([]byte, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store.StarGiftStore
|
||||
upgrades store.StarGiftUpgradeStore
|
||||
lifecycle store.StarGiftLifecycleStore
|
||||
withdrawal StarGiftWithdrawalProvider
|
||||
blobs BlobBackend
|
||||
dc int
|
||||
|
||||
mu sync.RWMutex
|
||||
built bool
|
||||
gifts []domain.StarGift
|
||||
byID map[int64]domain.StarGift
|
||||
hash int
|
||||
|
||||
formMu sync.Mutex
|
||||
forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm
|
||||
}
|
||||
|
||||
type starGiftPurchaseFormKey struct {
|
||||
buyerUserID int64
|
||||
formID int64
|
||||
}
|
||||
|
||||
// AtomicPurchaseConfigured reports whether the production aggregate
|
||||
// coordinator is installed. It lets the RPC package keep its isolated memory
|
||||
// test adapter without silently downgrading PostgreSQL deployments.
|
||||
func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil }
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
|
||||
return func(service *Service) { service.upgrades = upgrades }
|
||||
}
|
||||
|
||||
func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option {
|
||||
return func(service *Service) { service.lifecycle = lifecycle }
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProvider interface {
|
||||
Name() string
|
||||
CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error)
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProviderRequest struct {
|
||||
UserID int64
|
||||
Gift domain.UniqueStarGift
|
||||
}
|
||||
|
||||
type StarGiftWithdrawalProviderResult struct {
|
||||
RequestID string
|
||||
URL string
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option {
|
||||
return func(service *Service) { service.withdrawal = provider }
|
||||
}
|
||||
|
||||
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
||||
service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)}
|
||||
for _, opt := range opts {
|
||||
opt(service)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) ensureCatalog(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
built := s.built
|
||||
s.mu.RUnlock()
|
||||
if built {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.built {
|
||||
return nil
|
||||
}
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("star gift store is not configured")
|
||||
}
|
||||
gifts, err := s.store.Catalog(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gifts = gifts
|
||||
s.byID = make(map[int64]domain.StarGift, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
s.byID[gift.ID] = gift
|
||||
}
|
||||
s.hash = domain.StarGiftCatalogHash(gifts)
|
||||
s.built = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]domain.StarGift(nil), s.gifts...), nil
|
||||
}
|
||||
|
||||
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
|
||||
if err := s.ensureCatalog(ctx); err != nil {
|
||||
return domain.StarGift{}, false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
gift, ok := s.byID[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGift{}, false, nil
|
||||
}
|
||||
return s.store.CatalogRevision(ctx, revisionID)
|
||||
}
|
||||
|
||||
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
|
||||
func (s *Service) InvalidateStarGiftCatalog() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.built = false
|
||||
s.gifts = nil
|
||||
s.byID = nil
|
||||
s.hash = 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
|
||||
|
||||
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
|
||||
}
|
||||
write.Title = branding.UserVisibleText(strings.TrimSpace(write.Title), "")
|
||||
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
|
||||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
|
||||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
|
||||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if err := s.materializeCatalogWrite(ctx, &write); err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
entry, err := s.store.CreateCatalogRevision(ctx, write)
|
||||
if err != nil {
|
||||
return domain.StarGiftCatalogEntry{}, err
|
||||
}
|
||||
s.InvalidateStarGiftCatalog()
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error {
|
||||
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store star gift animation: %w", err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return fmt.Errorf("generate star gift file reference: %w", err)
|
||||
}
|
||||
write.Document = domain.Document{
|
||||
ID: documentID,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
DCID: s.dc,
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
{Kind: domain.DocAttrSticker, Alt: "🎁"},
|
||||
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
|
||||
},
|
||||
}
|
||||
write.Blob = domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(write.Animation.TGS)),
|
||||
SHA256: append([]byte(nil), write.Animation.SHA256...),
|
||||
MimeType: "application/x-tgsticker",
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCatalogBundle materializes every verified asset before publishing both active
|
||||
// revision pointers in one store transaction. Blob writes are content-addressed and may be
|
||||
// safely orphaned for later GC if the database transaction fails.
|
||||
func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured")
|
||||
}
|
||||
write.Catalog.Title = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "")
|
||||
write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "")
|
||||
if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars ||
|
||||
write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 ||
|
||||
len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
var officialSource map[string]any
|
||||
if write.Catalog.OfficialGiftID < 0 {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 ||
|
||||
json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
if write.Collectible != nil {
|
||||
write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix))
|
||||
brandCollectibleAttributes(write.Collectible.Models)
|
||||
brandCollectibleAttributes(write.Collectible.Patterns)
|
||||
brandCollectibleAttributes(write.Collectible.Backdrops)
|
||||
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
|
||||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
|
||||
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
validation := *write.Collectible
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = write.Catalog.GiftID
|
||||
if validation.GiftID == 0 {
|
||||
validation.GiftID = 1
|
||||
}
|
||||
}
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
if write.Collectible != nil {
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil {
|
||||
return domain.StarGiftCatalogBundleResult{}, err
|
||||
}
|
||||
}
|
||||
result, err := s.store.CreateCatalogBundle(ctx, write)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
|
||||
return s.store.AnimationJSON(ctx, giftID)
|
||||
}
|
||||
|
||||
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
|
||||
}
|
||||
brandCollectibleAttributes(write.Models)
|
||||
brandCollectibleAttributes(write.Patterns)
|
||||
brandCollectibleAttributes(write.Backdrops)
|
||||
revision, err := s.store.PublishCollectibleRevision(ctx, write)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
|
||||
// atomically publishes the complete immutable attribute pool. Callers must pass animations
|
||||
// produced by PrepareAnimation; partial revisions are never exposed to clients.
|
||||
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if s == nil || s.store == nil || s.blobs == nil {
|
||||
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
|
||||
}
|
||||
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
|
||||
brandCollectibleAttributes(write.Models)
|
||||
brandCollectibleAttributes(write.Patterns)
|
||||
brandCollectibleAttributes(write.Backdrops)
|
||||
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
return s.PublishCollectibleRevision(ctx, write)
|
||||
}
|
||||
|
||||
func brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) {
|
||||
for i := range attributes {
|
||||
attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error {
|
||||
for i := range attributes {
|
||||
animation := attributes[i].Animation
|
||||
if animation == nil {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, animation.TGS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
|
||||
}
|
||||
documentID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileReference := make([]byte, 16)
|
||||
if _, err := rand.Read(fileReference); err != nil {
|
||||
return fmt.Errorf("generate collectible file reference: %w", err)
|
||||
}
|
||||
attributes[i].Document = &domain.Document{
|
||||
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
|
||||
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
|
||||
Size: int64(len(animation.TGS)), DCID: s.dc,
|
||||
Attributes: collectibleDocumentAttributes(attributes[i].Kind),
|
||||
Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
|
||||
}
|
||||
attributes[i].Blob = &domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
|
||||
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder.
|
||||
// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an
|
||||
// animated sticker only when document.thumbs is non-empty. The placeholder is
|
||||
// not used as the rendered collectible pattern: after classification Android
|
||||
// downloads and decodes the document's full TGS first frame. Keeping the
|
||||
// placeholder inline avoids introducing a second downloadable blob and matches
|
||||
// the shape used by official animated-sticker documents.
|
||||
var collectiblePatternPathThumb = []byte{
|
||||
0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e,
|
||||
0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c,
|
||||
0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3,
|
||||
0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c,
|
||||
0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90,
|
||||
0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86,
|
||||
0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79,
|
||||
0x87, 0x03, 0x47, 0x06, 0x87, 0x03,
|
||||
}
|
||||
|
||||
func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize {
|
||||
if kind != domain.StarGiftCollectiblePattern {
|
||||
return nil
|
||||
}
|
||||
return []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindPath,
|
||||
Type: "j",
|
||||
Bytes: append([]byte(nil), collectiblePatternPathThumb...),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute {
|
||||
renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"}
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
// DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the
|
||||
// pattern is a text-color custom emoji. Without this the gradient is
|
||||
// visible but the collectible pattern is rendered with its raw fill.
|
||||
renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
|
||||
}
|
||||
return []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
|
||||
renderAttribute,
|
||||
{Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return s.collectiblePreview(ctx, giftID, 0)
|
||||
}
|
||||
|
||||
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
|
||||
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
|
||||
// payments.getStarGiftUpgradeAttributes and the admin editor.
|
||||
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
const attributesPerKind = 3
|
||||
return s.collectiblePreview(ctx, giftID, attributesPerKind)
|
||||
}
|
||||
|
||||
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
if s == nil || s.store == nil || giftID <= 0 {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
|
||||
if err != nil || !ok || !revision.Published {
|
||||
return domain.StarGiftUpgradePreview{}, false, err
|
||||
}
|
||||
return domain.StarGiftUpgradePreview{
|
||||
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
|
||||
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
|
||||
SlugPrefix: revision.SlugPrefix,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
if s == nil || s.store == nil || len(giftIDs) == 0 {
|
||||
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
|
||||
}
|
||||
return s.store.CollectibleAvailability(ctx, giftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
return s.store.UniqueByID(ctx, uniqueGiftID)
|
||||
}
|
||||
|
||||
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
|
||||
return map[int64]domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if s == nil || s.store == nil || owner.ID <= 0 ||
|
||||
(owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit))
|
||||
}
|
||||
|
||||
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.UpgradeStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.StarGiftUpgradeReceipt{}, false, nil
|
||||
}
|
||||
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
|
||||
}
|
||||
|
||||
// GrantUnique atomically assigns a freshly minted collectible to a user.
|
||||
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
result, err := s.lifecycle.PurchaseStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the
|
||||
// intent so server restarts cannot turn a valid checkout into an unbound
|
||||
// payment. The bounded in-memory branch exists only for isolated RPC tests.
|
||||
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
|
||||
if !validPurchaseForm(form) {
|
||||
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
|
||||
}
|
||||
if s != nil && s.lifecycle != nil {
|
||||
return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form)
|
||||
}
|
||||
if s == nil {
|
||||
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
s.formMu.Lock()
|
||||
defer s.formMu.Unlock()
|
||||
for key, existing := range s.forms {
|
||||
if existing.ExpiresAt < form.IssuedAt {
|
||||
delete(s.forms, key)
|
||||
}
|
||||
}
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
formID, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.StarGiftPurchaseForm{}, err
|
||||
}
|
||||
key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID}
|
||||
if _, exists := s.forms[key]; exists {
|
||||
continue
|
||||
}
|
||||
form.FormID = formID
|
||||
s.forms[key] = form
|
||||
return form, nil
|
||||
}
|
||||
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
|
||||
// ValidatePurchaseForm is a read-only preflight used for precise RPC errors.
|
||||
// The PostgreSQL purchase transaction repeats this validation while holding a
|
||||
// row lock; callers must not treat this preflight as the atomicity boundary.
|
||||
func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
|
||||
if s != nil && s.lifecycle != nil {
|
||||
return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req)
|
||||
}
|
||||
if s == nil || req.FormID == 0 {
|
||||
return domain.ErrStarGiftFormExpired
|
||||
}
|
||||
s.formMu.Lock()
|
||||
defer s.formMu.Unlock()
|
||||
form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}]
|
||||
if !ok || form.ExpiresAt < req.Date {
|
||||
return domain.ErrStarGiftFormExpired
|
||||
}
|
||||
return validatePurchaseFormIntent(form, req)
|
||||
}
|
||||
|
||||
func validPurchaseForm(form domain.StarGiftPurchaseForm) bool {
|
||||
return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 &&
|
||||
(form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) &&
|
||||
form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 &&
|
||||
form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128
|
||||
}
|
||||
|
||||
func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error {
|
||||
if form.BuyerUserID != req.BuyerUserID || form.To != req.To || form.GiftID != req.GiftID ||
|
||||
form.IncludeUpgrade != req.IncludeUpgrade || form.HideName != req.HideName || form.Message != req.Message {
|
||||
return domain.ErrStarGiftFormPurposeInvalid
|
||||
}
|
||||
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
|
||||
return domain.ErrStarGiftFormAmountMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable
|
||||
}
|
||||
return s.lifecycle.ListResaleStarGifts(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable
|
||||
}
|
||||
return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID)
|
||||
}
|
||||
|
||||
func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable
|
||||
}
|
||||
result, err := s.lifecycle.SetStarGiftListing(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable
|
||||
}
|
||||
return s.lifecycle.TransferStarGift(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable
|
||||
}
|
||||
result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
|
||||
}
|
||||
return s.lifecycle.SendStarGiftOffer(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
|
||||
}
|
||||
return s.lifecycle.ResolveStarGiftOffer(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit)
|
||||
}
|
||||
|
||||
func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
|
||||
}
|
||||
return s.lifecycle.CraftStarGift(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable
|
||||
}
|
||||
return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now)
|
||||
}
|
||||
|
||||
func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, domain.ErrStarGiftAuctionUnavailable
|
||||
}
|
||||
return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now)
|
||||
}
|
||||
|
||||
func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, domain.ErrStarGiftAuctionUnavailable
|
||||
}
|
||||
return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID)
|
||||
}
|
||||
|
||||
func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable
|
||||
}
|
||||
return s.lifecycle.BidStarGiftAuction(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash)
|
||||
}
|
||||
|
||||
func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
return s.lifecycle.PrepayStarGiftUpgrade(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
|
||||
}
|
||||
return s.lifecycle.DropStarGiftOriginalDetails(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.ErrStarGiftUnavailable
|
||||
}
|
||||
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
|
||||
if s == nil {
|
||||
return false, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
// Isolated memory/RPC adapters have no settings table; production's
|
||||
// persisted default is enabled, so preserve that wire behavior.
|
||||
return true, nil
|
||||
}
|
||||
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return domain.SavedStarGiftRef{}, false, nil
|
||||
}
|
||||
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
|
||||
}
|
||||
|
||||
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
|
||||
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||
}
|
||||
saved, found, err := s.store.GetByRef(ctx, req.Ref)
|
||||
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
|
||||
saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date {
|
||||
if err != nil {
|
||||
return domain.StarGiftWithdrawal{}, err
|
||||
}
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
|
||||
}
|
||||
unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID)
|
||||
if err != nil || !found || unique.Burned || unique.Owner != saved.Owner {
|
||||
if err != nil {
|
||||
return domain.StarGiftWithdrawal{}, err
|
||||
}
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
|
||||
}
|
||||
providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique})
|
||||
if err != nil {
|
||||
return domain.StarGiftWithdrawal{}, err
|
||||
}
|
||||
if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date {
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||
}
|
||||
recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt)
|
||||
if err != nil {
|
||||
return domain.StarGiftWithdrawal{}, err
|
||||
}
|
||||
return recorded, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftWithdrawal{}, false, nil
|
||||
}
|
||||
return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
|
||||
}
|
||||
return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date)
|
||||
}
|
||||
|
||||
func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.lifecycle.TonBalance(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.TonTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.lifecycle.ChannelTonBalance(ctx, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.TonTransactionPage{}, nil
|
||||
}
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.TonTransactionPage{}, err
|
||||
}
|
||||
return s.lifecycle.ChannelTonTransactions(ctx, channelID, query)
|
||||
}
|
||||
|
||||
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
||||
return s.store.ListCollections(ctx, owner)
|
||||
}
|
||||
|
||||
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
return s.store.DeleteCollection(ctx, owner, collectionID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
return s.store.ReorderCollections(ctx, owner, collectionIDs)
|
||||
}
|
||||
|
||||
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
return s.store.SetPinned(ctx, owner, savedGiftIDs)
|
||||
}
|
||||
|
||||
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
|
||||
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
|
||||
var token [32]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
|
||||
}
|
||||
gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
|
||||
}
|
||||
}
|
||||
return s.store.Create(ctx, gift)
|
||||
}
|
||||
|
||||
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
offset := filter.Offset
|
||||
if len(offset) > domain.MaxStarGiftsOffsetBytes {
|
||||
filter.Offset = ""
|
||||
}
|
||||
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
|
||||
filter.Limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
return s.store.ListByOwnerFiltered(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
return s.store.GetByRef(ctx, ref)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
return s.store.ResolveSavedIDs(ctx, owner, refs)
|
||||
}
|
||||
|
||||
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
|
||||
return s.store.CountByOwner(ctx, owner)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
||||
return s.store.SetUnsaved(ctx, ref, unsaved)
|
||||
}
|
||||
|
||||
// Convert keeps the in-memory/catalog store primitive available to isolated
|
||||
// tests and non-production adapters. RPC production paths must use
|
||||
// ConvertAggregate so balance credit and terminal state cannot split.
|
||||
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
return s.store.MarkConverted(ctx, ref)
|
||||
}
|
||||
|
||||
func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable
|
||||
}
|
||||
return s.lifecycle.ConvertStarGift(ctx, req)
|
||||
}
|
||||
|
||||
func randomPositiveInt64() (int64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate star gift id: %w", err)
|
||||
}
|
||||
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package stargifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
|
||||
st := memory.NewStarGiftStore()
|
||||
st.SeedCatalog(gifts)
|
||||
return NewService(st, nil, 2), st
|
||||
}
|
||||
|
||||
func TestCatalogCachedAndHash(t *testing.T) {
|
||||
gifts := []domain.StarGift{
|
||||
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
|
||||
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
|
||||
}
|
||||
svc, _ := newTestService(gifts)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := svc.Catalog(ctx)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
|
||||
}
|
||||
// 再取一次命中进程内目录缓存。
|
||||
if _, err := svc.Catalog(ctx); err != nil {
|
||||
t.Fatalf("catalog#2: %v", err)
|
||||
}
|
||||
hash, err := svc.CatalogHash(ctx)
|
||||
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
|
||||
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
|
||||
}
|
||||
if g, ok, _ := svc.GiftByID(ctx, 2); !ok || g.Stars != 50 {
|
||||
t.Fatalf("GiftByID(2) = %+v ok %v, want Cake 50", g, ok)
|
||||
}
|
||||
if _, ok, _ := svc.GiftByID(ctx, 999); ok {
|
||||
t.Fatalf("GiftByID(999) found, want missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedGiftLifecycle(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
|
||||
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("RecordSavedGift = %d err %v", id, err)
|
||||
}
|
||||
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
|
||||
if err != nil || len(collection.GiftIDs) != 1 {
|
||||
t.Fatalf("CreateCollection = %+v err %v", collection, err)
|
||||
}
|
||||
|
||||
page, err := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
|
||||
t.Fatalf("list = %d count %d err %v, want 1/1", len(page.Gifts), page.Count, err)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
t.Fatalf("single page next_offset = %q, want empty", page.NextOffset)
|
||||
}
|
||||
|
||||
// 隐藏(unsave=true)→ excludeUnsaved 列表为空。
|
||||
ref := domain.SavedStarGiftRef{Owner: owner, MsgID: 50}
|
||||
if ok, err := svc.ToggleSaved(ctx, ref, true); err != nil || !ok {
|
||||
t.Fatalf("ToggleSaved hide = %v err %v", ok, err)
|
||||
}
|
||||
hidden, _ := svc.ListSaved(ctx, owner, true, "", 100)
|
||||
if len(hidden.Gifts) != 0 {
|
||||
t.Fatalf("excludeUnsaved list = %d, want 0 after hide", len(hidden.Gifts))
|
||||
}
|
||||
// 不带 exclude 仍能看到。
|
||||
all, _ := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if len(all.Gifts) != 1 {
|
||||
t.Fatalf("full list = %d, want 1 (hidden still listed)", len(all.Gifts))
|
||||
}
|
||||
|
||||
// 转换回 Stars → 标记 converted,从列表消失。
|
||||
saved, err := svc.Convert(ctx, ref)
|
||||
if err != nil || saved.ConvertStars != 15 {
|
||||
t.Fatalf("Convert = %+v err %v, want ConvertStars 15", saved, err)
|
||||
}
|
||||
after, _ := svc.ListSaved(ctx, owner, false, "", 100)
|
||||
if len(after.Gifts) != 0 {
|
||||
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
|
||||
}
|
||||
collections, err := svc.ListCollections(ctx, owner)
|
||||
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
|
||||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
|
||||
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
|
||||
}
|
||||
// 重复转换被拒。
|
||||
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
|
||||
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
|
||||
|
||||
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
|
||||
Date: 1700000000, ConvertStars: 15,
|
||||
})
|
||||
if err != nil || savedID == 0 {
|
||||
t.Fatalf("RecordSavedGift(channel) = %d err %v, want allocated saved_id", savedID, err)
|
||||
}
|
||||
|
||||
gift, found, err := svc.GetSaved(ctx, domain.SavedStarGiftRef{Owner: owner, SavedID: savedID})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetSaved(channel) found=%v err=%v, want hit", found, err)
|
||||
}
|
||||
if gift.MsgID != 0 || gift.SavedID != savedID {
|
||||
t.Fatalf("channel saved gift ids = msg_id %d saved_id %d, want 0/%d", gift.MsgID, gift.SavedID, savedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedGiftPagination(t *testing.T) {
|
||||
svc, _ := newTestService(nil)
|
||||
ctx := context.Background()
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
|
||||
}); err != nil {
|
||||
t.Fatalf("record#%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
page1, _ := svc.ListSaved(ctx, owner, false, "", 2)
|
||||
if len(page1.Gifts) != 2 || page1.NextOffset == "" {
|
||||
t.Fatalf("page1 = %d next=%q, want 2 + next", len(page1.Gifts), page1.NextOffset)
|
||||
}
|
||||
page2, _ := svc.ListSaved(ctx, owner, false, page1.NextOffset, 2)
|
||||
page3, _ := svc.ListSaved(ctx, owner, false, page2.NextOffset, 2)
|
||||
if len(page3.Gifts) != 1 || page3.NextOffset != "" {
|
||||
t.Fatalf("page3 = %d next=%q, want 1 + empty (terminal)", len(page3.Gifts), page3.NextOffset)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页,
|
||||
// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、
|
||||
// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。
|
||||
package stars
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 是 Stars 账本应用服务。
|
||||
type Service struct {
|
||||
store store.StarsStore
|
||||
purchaseStore store.StarsPurchaseStore
|
||||
grantAmount int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Option 配置 Service。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。
|
||||
func WithStartingGrant(amount int64) Option {
|
||||
return func(s *Service) { s.grantAmount = amount }
|
||||
}
|
||||
|
||||
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
|
||||
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
|
||||
return func(s *Service) { s.purchaseStore = st }
|
||||
}
|
||||
|
||||
// WithClock 注入时钟(测试用)。
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。
|
||||
func NewService(st store.StarsStore, opts ...Option) *Service {
|
||||
s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。
|
||||
func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
if s.grantAmount > 0 {
|
||||
bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix()))
|
||||
return bal, err
|
||||
}
|
||||
return s.store.GetBalance(ctx, userID)
|
||||
}
|
||||
|
||||
// GetBalance 返回账号余额,首读时惰性授予起始余额。
|
||||
func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
return s.ensureGranted(ctx, userID)
|
||||
}
|
||||
|
||||
// Credit 为账号入账(amount>0)。
|
||||
func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
|
||||
}
|
||||
|
||||
// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。
|
||||
// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。
|
||||
func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||||
return domain.StarsBalance{}, err
|
||||
}
|
||||
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
|
||||
}
|
||||
|
||||
// ListTransactions 按方向与顺序做 keyset 分页,首读时惰性授予。
|
||||
func (s *Service) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
query, err := domain.NormalizeStarsTransactionQuery(query)
|
||||
if err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
if _, err := s.ensureGranted(ctx, userID); err != nil {
|
||||
return domain.StarsTransactionPage{}, err
|
||||
}
|
||||
return s.store.ListTransactions(ctx, userID, query)
|
||||
}
|
||||
|
||||
// IssuePurchaseForm persists a short-lived, exact checkout intent.
|
||||
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
if s.purchaseStore == nil || !validPurchaseForm(form) {
|
||||
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
|
||||
}
|
||||
|
||||
// Purchase settles one exact persisted form. Package validation remains at
|
||||
// the RPC boundary as well, while the store revalidates the persisted tuple
|
||||
// under lock before performing any write.
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return s.purchaseStore.PurchaseStars(ctx, req)
|
||||
}
|
||||
|
||||
// GetGiveawayInfo resolves one launch card from the same aggregate that
|
||||
// persisted it. date is supplied by the RPC clock for deterministic tests.
|
||||
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
|
||||
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
|
||||
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
|
||||
}
|
||||
|
||||
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
|
||||
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
|
||||
}
|
||||
|
||||
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
|
||||
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
|
||||
return false
|
||||
}
|
||||
switch form.Kind {
|
||||
case domain.StarsPurchaseTopup:
|
||||
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
|
||||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
|
||||
case domain.StarsPurchaseGift:
|
||||
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
|
||||
case domain.StarsPurchaseGiveaway:
|
||||
g := form.Giveaway
|
||||
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
|
||||
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
|
||||
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
|
||||
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package stars
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newTestService(grant int64) *Service {
|
||||
return NewService(memory.NewStarsStore(), WithStartingGrant(grant))
|
||||
}
|
||||
|
||||
// 起始授予幂等:多次 GetBalance 只授予一次。
|
||||
func TestStartingGrantOnce(t *testing.T) {
|
||||
svc := newTestService(1000)
|
||||
ctx := context.Background()
|
||||
bal, err := svc.GetBalance(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance: %v", err)
|
||||
}
|
||||
if bal.Balance != 1000 || !bal.Granted {
|
||||
t.Fatalf("first balance = %+v, want 1000 granted", bal)
|
||||
}
|
||||
// 再读不应重复授予。
|
||||
bal2, err := svc.GetBalance(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance#2: %v", err)
|
||||
}
|
||||
if bal2.Balance != 1000 {
|
||||
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
|
||||
}
|
||||
// 流水里应恰有一条 grant。
|
||||
page, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTransactions: %v", err)
|
||||
}
|
||||
if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 {
|
||||
t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭授予(grant=0)时余额为 0、无 grant 流水。
|
||||
func TestGrantDisabled(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
bal, err := svc.GetBalance(context.Background(), 9)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalance: %v", err)
|
||||
}
|
||||
if bal.Balance != 0 || bal.Granted {
|
||||
t.Fatalf("balance = %+v, want 0 not granted", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。
|
||||
func TestDebitAndInsufficient(t *testing.T) {
|
||||
svc := newTestService(1000)
|
||||
ctx := context.Background()
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555}
|
||||
|
||||
bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Debit: %v", err)
|
||||
}
|
||||
if bal.Balance != 700 {
|
||||
t.Fatalf("after debit = %d, want 700", bal.Balance)
|
||||
}
|
||||
|
||||
// 余额不足。
|
||||
if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
// 余额未被改动。
|
||||
after, _ := svc.GetBalance(ctx, 7)
|
||||
if after.Balance != 700 {
|
||||
t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance)
|
||||
}
|
||||
|
||||
// 非法金额。
|
||||
if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) {
|
||||
t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 贷记增加余额并写正流水。
|
||||
func TestCredit(t *testing.T) {
|
||||
svc := newTestService(0) // 关闭起始授予,单测贷记
|
||||
ctx := context.Background()
|
||||
bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Credit: %v", err)
|
||||
}
|
||||
if bal.Balance != 250 {
|
||||
t.Fatalf("after credit = %d, want 250", bal.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。
|
||||
func TestListTransactionsPagination(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("Credit#%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
page1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page1: %v", err)
|
||||
}
|
||||
if len(page1.Transactions) != 2 || page1.NextOffset == "" {
|
||||
t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset)
|
||||
}
|
||||
// 倒序:最新(id 最大,amount=14)在前。
|
||||
if page1.Transactions[0].Amount != 14 {
|
||||
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
|
||||
}
|
||||
page2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page1.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page2: %v", err)
|
||||
}
|
||||
if len(page2.Transactions) != 2 {
|
||||
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
|
||||
}
|
||||
page3, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page2.NextOffset, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("page3: %v", err)
|
||||
}
|
||||
if len(page3.Transactions) != 1 {
|
||||
t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions))
|
||||
}
|
||||
if page3.NextOffset != "" {
|
||||
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTransactionsDirectionAndAscending(t *testing.T) {
|
||||
svc := newTestService(0)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.Credit(ctx, 7, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 100: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 40: %v", err)
|
||||
}
|
||||
if _, err := svc.Credit(ctx, 7, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 20: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, 7, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 10: %v", err)
|
||||
}
|
||||
|
||||
all, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("all transactions: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, all.Transactions, []int64{-10, 20, -40, 100})
|
||||
if all.Balance != 70 {
|
||||
t.Fatalf("all balance = %d, want 70", all.Balance)
|
||||
}
|
||||
|
||||
incoming1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page1: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming1.Transactions, []int64{20})
|
||||
if incoming1.NextOffset == "" {
|
||||
t.Fatal("incoming page1 missing next offset")
|
||||
}
|
||||
incoming2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incoming page2: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, incoming2.Transactions, []int64{100})
|
||||
if incoming2.NextOffset != "" {
|
||||
t.Fatalf("terminal incoming next offset = %q", incoming2.NextOffset)
|
||||
}
|
||||
|
||||
outgoing, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
|
||||
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ascending outgoing: %v", err)
|
||||
}
|
||||
assertStarsAmounts(t, outgoing.Transactions, []int64{-40, -10})
|
||||
|
||||
_, err = svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Direction: 99})
|
||||
if !errors.Is(err, domain.ErrStarsTransactionQueryInvalid) {
|
||||
t.Fatalf("invalid direction error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
|
||||
t.Helper()
|
||||
if len(transactions) != len(want) {
|
||||
t.Fatalf("transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
|
||||
}
|
||||
for i, amount := range want {
|
||||
if transactions[i].Amount != amount {
|
||||
t.Fatalf("transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -575,7 +575,7 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
|
|||
// to the account's other sessions and offline difference stream.
|
||||
func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||
if !status.Valid() {
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid
|
||||
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrEmojiStatusCollectibleInvalid
|
||||
}
|
||||
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
|
|
|
|||
|
|
@ -552,7 +552,7 @@ func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, s
|
|||
return domain.User{}, err
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
return domain.User{}, domain.ErrEmojiStatusCollectibleInvalid
|
||||
}
|
||||
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
|
||||
return domain.User{}, domain.ErrPremiumRequired
|
||||
|
|
|
|||
|
|
@ -546,32 +546,6 @@ func StoryAlbums() tg.StoriesAlbumsClass {
|
|||
return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}}
|
||||
}
|
||||
|
||||
func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
|
||||
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
|
||||
}
|
||||
|
||||
func StarGifts() tg.PaymentsStarGiftsClass {
|
||||
return &tg.PaymentsStarGifts{
|
||||
Gifts: []tg.StarGiftClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
|
||||
return &tg.PaymentsSavedStarGifts{
|
||||
Gifts: []tg.SavedStarGift{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func StarGiftCollections() tg.PaymentsStarGiftCollectionsClass {
|
||||
return &tg.PaymentsStarGiftCollections{
|
||||
Collections: []tg.StarGiftCollection{},
|
||||
}
|
||||
}
|
||||
|
||||
func StarsRevenueStats(ton bool) *tg.PaymentsStarsRevenueStats {
|
||||
zeroAmount := tg.StarsAmountClass(&tg.StarsAmount{})
|
||||
if ton {
|
||||
|
|
|
|||
|
|
@ -246,11 +246,6 @@ type Config struct {
|
|||
WebPagePreviewRatePerMin int
|
||||
// LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。
|
||||
LangPackSeedDir string
|
||||
// OfficialGiftsDir 是 cmd/giftfetch 生成的只读官方礼物快照目录。
|
||||
OfficialGiftsDir string
|
||||
// StarGiftTONStartingGrant 是 telesrv 内部 TON 账本首次访问时授予的 nanoton。
|
||||
// 该账本只用于自建服务端礼物链路,不连接任何外部区块链。
|
||||
StarGiftTONStartingGrant int64
|
||||
// BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。
|
||||
BlobDir string
|
||||
// BlobBackendKind selects the blob storage backend: "localfs" (default)
|
||||
|
|
@ -432,63 +427,11 @@ type Config struct {
|
|||
// PasskeyAllowedOrigins 是允许的 WebAuthn origin 白名单;为空=不强校验 origin
|
||||
//(服务端通常不预知 Android apk-key-hash origin)。
|
||||
PasskeyAllowedOrigins []string
|
||||
// StarsStartingGrant 是 Stars 本地账本的起始余额(首读时惰性授予、granted 布尔幂等,
|
||||
// 新老账号都覆盖、免回填迁移);0 关闭自动授予。
|
||||
StarsStartingGrant int64
|
||||
// PremiumSweepInterval 是会员到期 sweeper 的轮询间隔。premium 下发正确性
|
||||
// 由读取路径即时派生,sweeper 只负责清理过期行并推 updateUser 通知。
|
||||
PremiumSweepInterval time.Duration
|
||||
// PremiumSweepBatch 是单次到期清理的最大行数。
|
||||
PremiumSweepBatch int
|
||||
// StarGiftSweepInterval drives offer expiry/refunds, auction rounds and their
|
||||
// durable notification/delivery outboxes. It is entirely server-local.
|
||||
StarGiftSweepInterval time.Duration
|
||||
// StarGiftSweepBatch bounds rows/aggregates claimed by one sweep.
|
||||
StarGiftSweepBatch int
|
||||
StarGiftTransferStars int64
|
||||
StarGiftDropOriginalDetailsStars int64
|
||||
StarGiftOfferMinStars int
|
||||
StarGiftStarsProceedsPermille int
|
||||
StarGiftTONProceedsPermille int
|
||||
StarGiftExportDelay time.Duration
|
||||
StarGiftTransferDelay time.Duration
|
||||
StarGiftResellDelay time.Duration
|
||||
StarGiftCraftDelay time.Duration
|
||||
StarGiftCraftChancePermille int
|
||||
|
||||
// RatingEnabled controls the local admin-only composite account rating.
|
||||
// Disabled keeps every local projection empty and refuses rating writes; no
|
||||
// client-facing Telegram field changes in either mode.
|
||||
RatingEnabled bool
|
||||
// RatingPendingDelay is how long a rating increase stays parked as a pending
|
||||
// local score before it becomes the visible admin level. A decrease is
|
||||
// always applied immediately: a penalty must not sit behind a delay.
|
||||
// 0 applies every change immediately.
|
||||
RatingPendingDelay time.Duration
|
||||
// RatingRecomputeInterval / RatingRecomputeBatch drive the background
|
||||
// recompute worker. The rating derives from signals owned by other
|
||||
// subsystems, so freshness is a worker property, not a write-path one.
|
||||
RatingRecomputeInterval time.Duration
|
||||
RatingRecomputeBatch int
|
||||
// RatingStaleAfter is the projection age after which the worker recomputes a
|
||||
// user.
|
||||
RatingStaleAfter time.Duration
|
||||
// Rating weights are the integer composite formula. Defaults mirror
|
||||
// domain.DefaultAccountRatingWeights() exactly, so the shipped behaviour is
|
||||
// identical whether or not these keys are set. Every weight is a magnitude:
|
||||
// the penalties are subtracted by the domain formula, so all values are
|
||||
// non-negative and a negative value fails startup.
|
||||
RatingWeightStarsReceivedPermille int64
|
||||
RatingWeightStarsSpentPermille int64
|
||||
RatingWeightMessageSent int64
|
||||
RatingWeightAccountAgeDay int64
|
||||
RatingWeightGiftReceived int64
|
||||
RatingWeightModerationCase int64
|
||||
RatingWeightScamPenalty int64
|
||||
RatingWeightFakePenalty int64
|
||||
// RatingActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh Stars and moderation; 0 leaves it uncapped.
|
||||
RatingActivityCap int64
|
||||
// VerificationEnabled controls official platform verification: the @verifybot
|
||||
// application flow and the panel's review queue. Disabled refuses every
|
||||
// verification use case explicitly; already-verified peers keep their badge,
|
||||
|
|
@ -697,9 +640,6 @@ func Load() (Config, error) {
|
|||
if err != nil {
|
||||
return Config{}, fmt.Errorf("TELESRV_ADVERTISE_IP: %w", err)
|
||||
}
|
||||
// The composite rating weight defaults are the domain formula's own defaults;
|
||||
// see RatingWeight* below.
|
||||
defaultRatingWeights := domain.DefaultAccountRatingWeights()
|
||||
adminScopedTokens, err := parseAdminScopedTokens(envAllowEmptyOr("TELESRV_ADMIN_SCOPED_TOKENS", ""))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
|
@ -809,8 +749,6 @@ func Load() (Config, error) {
|
|||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
OfficialGiftsDir: envOr("TELESRV_OFFICIAL_GIFTS_DIR", "data/official-gifts"),
|
||||
StarGiftTONStartingGrant: envInt64Or("TELESRV_STARGIFT_TON_STARTING_GRANT", 10_000_000_000),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
// s3 (MinIO by default, see deploy/docker-compose.yml's minio service) is
|
||||
// the default blob backend; localfs remains fully supported as an
|
||||
|
|
@ -894,43 +832,13 @@ func Load() (Config, error) {
|
|||
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
|
||||
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
|
||||
|
||||
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
|
||||
DefaultStickerSetID: envInt64Or("TELESRV_DEFAULT_STICKER_SET_ID", 0),
|
||||
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
|
||||
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
|
||||
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
|
||||
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
|
||||
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
|
||||
StarGiftSweepInterval: envDurationOr("TELESRV_STARGIFT_SWEEP_INTERVAL", 15*time.Second),
|
||||
StarGiftSweepBatch: envIntOr("TELESRV_STARGIFT_SWEEP_BATCH", 1000),
|
||||
StarGiftTransferStars: int64(envIntOr("TELESRV_STARGIFT_TRANSFER_STARS", 25)),
|
||||
StarGiftDropOriginalDetailsStars: int64(envIntOr("TELESRV_STARGIFT_DROP_DETAILS_STARS", 25)),
|
||||
StarGiftOfferMinStars: envIntOr("TELESRV_STARGIFT_OFFER_MIN_STARS", 1),
|
||||
StarGiftStarsProceedsPermille: envIntOr("TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE", 1000),
|
||||
StarGiftTONProceedsPermille: envIntOr("TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE", 1000),
|
||||
StarGiftExportDelay: envDurationOr("TELESRV_STARGIFT_EXPORT_DELAY", 0),
|
||||
StarGiftTransferDelay: envDurationOr("TELESRV_STARGIFT_TRANSFER_DELAY", 0),
|
||||
StarGiftResellDelay: envDurationOr("TELESRV_STARGIFT_RESELL_DELAY", 0),
|
||||
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
|
||||
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
|
||||
|
||||
RatingEnabled: envBoolOr("TELESRV_RATING_ENABLED", true),
|
||||
RatingPendingDelay: envDurationOr("TELESRV_RATING_PENDING_DELAY", 24*time.Hour),
|
||||
RatingRecomputeInterval: envDurationOr("TELESRV_RATING_RECOMPUTE_INTERVAL", 15*time.Minute),
|
||||
RatingRecomputeBatch: envIntOr("TELESRV_RATING_RECOMPUTE_BATCH", 500),
|
||||
RatingStaleAfter: envDurationOr("TELESRV_RATING_STALE_AFTER", 6*time.Hour),
|
||||
// Weight defaults are read from the domain formula itself so the shipped
|
||||
// behaviour cannot drift from domain.DefaultAccountRatingWeights().
|
||||
RatingWeightStarsReceivedPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", defaultRatingWeights.StarsReceivedPermille),
|
||||
RatingWeightStarsSpentPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE", defaultRatingWeights.StarsSpentPermille),
|
||||
RatingWeightMessageSent: envInt64Or("TELESRV_RATING_WEIGHT_MESSAGE_SENT", defaultRatingWeights.PerMessageSent),
|
||||
RatingWeightAccountAgeDay: envInt64Or("TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY", defaultRatingWeights.PerAccountAgeDay),
|
||||
RatingWeightGiftReceived: envInt64Or("TELESRV_RATING_WEIGHT_GIFT_RECEIVED", defaultRatingWeights.PerGiftReceived),
|
||||
RatingWeightModerationCase: envInt64Or("TELESRV_RATING_WEIGHT_MODERATION_CASE", defaultRatingWeights.PerModerationCase),
|
||||
RatingWeightScamPenalty: envInt64Or("TELESRV_RATING_WEIGHT_SCAM_PENALTY", defaultRatingWeights.ScamPenalty),
|
||||
RatingWeightFakePenalty: envInt64Or("TELESRV_RATING_WEIGHT_FAKE_PENALTY", defaultRatingWeights.FakePenalty),
|
||||
RatingActivityCap: envInt64Or("TELESRV_RATING_ACTIVITY_CAP", defaultRatingWeights.ActivityCap),
|
||||
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
|
||||
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
|
||||
DefaultStickerSetID: envInt64Or("TELESRV_DEFAULT_STICKER_SET_ID", 0),
|
||||
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
|
||||
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
|
||||
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
|
||||
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
|
||||
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
|
||||
|
||||
// Official verification defaults ship the feature on with the official bar
|
||||
// in place: user accounts are not accepted, a rejection costs a month, and
|
||||
|
|
@ -988,12 +896,6 @@ func Load() (Config, error) {
|
|||
if err := validateRPCExecutionConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateStarGiftConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateAccountRatingConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateCollectibleUsernameConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
|
@ -1078,77 +980,6 @@ func validateTelegramLoginConfig(cfg Config) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateStarGiftConfig(cfg Config) error {
|
||||
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")
|
||||
}
|
||||
if cfg.StarGiftTONStartingGrant < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_TON_STARTING_GRANT must be non-negative")
|
||||
}
|
||||
if cfg.StarGiftTransferStars < 0 || cfg.StarGiftDropOriginalDetailsStars < 0 || cfg.StarGiftOfferMinStars < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_TRANSFER_STARS, TELESRV_STARGIFT_DROP_DETAILS_STARS and TELESRV_STARGIFT_OFFER_MIN_STARS must be non-negative")
|
||||
}
|
||||
if cfg.StarGiftExportDelay < 0 || cfg.StarGiftTransferDelay < 0 || cfg.StarGiftResellDelay < 0 || cfg.StarGiftCraftDelay < 0 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays must be non-negative")
|
||||
}
|
||||
const maxProtocolDelay = time.Duration(1<<31-1) * time.Second
|
||||
if cfg.StarGiftExportDelay > maxProtocolDelay || cfg.StarGiftTransferDelay > maxProtocolDelay ||
|
||||
cfg.StarGiftResellDelay > maxProtocolDelay || cfg.StarGiftCraftDelay > maxProtocolDelay {
|
||||
return fmt.Errorf("TELESRV_STARGIFT lifecycle delays exceed the protocol int32 date range")
|
||||
}
|
||||
if cfg.StarGiftCraftChancePermille < 0 || cfg.StarGiftCraftChancePermille > 1000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE must be 0..1000")
|
||||
}
|
||||
if cfg.StarGiftStarsProceedsPermille < 0 || cfg.StarGiftStarsProceedsPermille > 1000 ||
|
||||
cfg.StarGiftTONProceedsPermille < 0 || cfg.StarGiftTONProceedsPermille > 1000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_*_PROCEEDS_PERMILLE must be 0..1000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingWeights renders the configured composite rating formula. It is
|
||||
// the single conversion point between env keys and the domain formula, so the
|
||||
// app service and the admin explanation always use the same numbers.
|
||||
func (c Config) AccountRatingWeights() domain.AccountRatingWeights {
|
||||
return domain.AccountRatingWeights{
|
||||
StarsReceivedPermille: c.RatingWeightStarsReceivedPermille,
|
||||
StarsSpentPermille: c.RatingWeightStarsSpentPermille,
|
||||
PerMessageSent: c.RatingWeightMessageSent,
|
||||
PerAccountAgeDay: c.RatingWeightAccountAgeDay,
|
||||
PerGiftReceived: c.RatingWeightGiftReceived,
|
||||
PerModerationCase: c.RatingWeightModerationCase,
|
||||
ScamPenalty: c.RatingWeightScamPenalty,
|
||||
FakePenalty: c.RatingWeightFakePenalty,
|
||||
ActivityCap: c.RatingActivityCap,
|
||||
}
|
||||
}
|
||||
|
||||
// validateAccountRatingConfig rejects a formula or worker cadence that cannot
|
||||
// produce a reproducible rating. Weights are validated even when the feature is
|
||||
// disabled: enabling it later must not be the moment a typo is discovered.
|
||||
func validateAccountRatingConfig(cfg Config) error {
|
||||
if err := cfg.AccountRatingWeights().Validate(); err != nil {
|
||||
return fmt.Errorf("TELESRV_RATING_WEIGHT_* and TELESRV_RATING_ACTIVITY_CAP must be non-negative: %w", err)
|
||||
}
|
||||
if cfg.RatingPendingDelay < 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must be non-negative")
|
||||
}
|
||||
const maxRatingPendingDelay = 30 * 24 * time.Hour
|
||||
if cfg.RatingPendingDelay > maxRatingPendingDelay {
|
||||
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must not exceed 720h")
|
||||
}
|
||||
if cfg.RatingRecomputeInterval <= 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_INTERVAL must be positive")
|
||||
}
|
||||
if cfg.RatingStaleAfter <= 0 {
|
||||
return fmt.Errorf("TELESRV_RATING_STALE_AFTER must be positive")
|
||||
}
|
||||
if cfg.RatingRecomputeBatch <= 0 || cfg.RatingRecomputeBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_BATCH must be 1..10000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminPermissionAll is the wildcard permission: a session or token carrying it
|
||||
// may perform every admin action.
|
||||
const adminPermissionAll = "*"
|
||||
|
|
|
|||
|
|
@ -785,102 +785,6 @@ func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) {
|
||||
cfg := Config{
|
||||
StarGiftSweepInterval: time.Second,
|
||||
StarGiftSweepBatch: 1,
|
||||
StarGiftTONStartingGrant: -1,
|
||||
StarGiftStarsProceedsPermille: 1000,
|
||||
StarGiftTONProceedsPermille: 1000,
|
||||
}
|
||||
if err := validateStarGiftConfig(cfg); err == nil {
|
||||
t.Fatal("negative internal TON starting grant was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAccountRatingDefaults(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.RatingEnabled {
|
||||
t.Fatal("RatingEnabled = false, want the feature on by default")
|
||||
}
|
||||
if cfg.RatingPendingDelay != 24*time.Hour || cfg.RatingRecomputeInterval != 15*time.Minute ||
|
||||
cfg.RatingRecomputeBatch != 500 || cfg.RatingStaleAfter != 6*time.Hour {
|
||||
t.Fatalf("rating worker defaults = %v/%v/%d/%v, want 24h/15m/500/6h",
|
||||
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
|
||||
}
|
||||
if got, want := cfg.AccountRatingWeights(), domain.DefaultAccountRatingWeights(); got != want {
|
||||
t.Fatalf("rating weights = %#v, want the domain defaults %#v", got, want)
|
||||
}
|
||||
if cfg.CollectibleUsernameURLTemplate != "" {
|
||||
t.Fatalf("CollectibleUsernameURLTemplate = %q, want empty (derived from the public base URL)",
|
||||
cfg.CollectibleUsernameURLTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAccountRatingOverrides(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_RATING_ENABLED", "false")
|
||||
t.Setenv("TELESRV_RATING_PENDING_DELAY", "1h")
|
||||
t.Setenv("TELESRV_RATING_RECOMPUTE_INTERVAL", "90s")
|
||||
t.Setenv("TELESRV_RATING_RECOMPUTE_BATCH", "42")
|
||||
t.Setenv("TELESRV_RATING_STALE_AFTER", "30m")
|
||||
t.Setenv("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", "500")
|
||||
t.Setenv("TELESRV_RATING_WEIGHT_MESSAGE_SENT", "0")
|
||||
t.Setenv("TELESRV_RATING_ACTIVITY_CAP", "0")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.RatingEnabled {
|
||||
t.Fatal("RatingEnabled = true, want the explicit override")
|
||||
}
|
||||
weights := cfg.AccountRatingWeights()
|
||||
if weights.StarsReceivedPermille != 500 || weights.PerMessageSent != 0 || weights.ActivityCap != 0 {
|
||||
t.Fatalf("weights = %#v, want the overridden values", weights)
|
||||
}
|
||||
if weights.StarsSpentPermille != domain.DefaultAccountRatingWeights().StarsSpentPermille {
|
||||
t.Fatalf("unset weight = %d, want the domain default", weights.StarsSpentPermille)
|
||||
}
|
||||
if cfg.RatingPendingDelay != time.Hour || cfg.RatingRecomputeInterval != 90*time.Second ||
|
||||
cfg.RatingRecomputeBatch != 42 || cfg.RatingStaleAfter != 30*time.Minute {
|
||||
t.Fatalf("rating worker overrides = %v/%v/%d/%v",
|
||||
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidAccountRatingConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{name: "negative stars weight", key: "TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", value: "-1"},
|
||||
{name: "negative moderation weight", key: "TELESRV_RATING_WEIGHT_MODERATION_CASE", value: "-150"},
|
||||
{name: "negative scam penalty", key: "TELESRV_RATING_WEIGHT_SCAM_PENALTY", value: "-1"},
|
||||
{name: "negative activity cap", key: "TELESRV_RATING_ACTIVITY_CAP", value: "-5000"},
|
||||
{name: "negative pending delay", key: "TELESRV_RATING_PENDING_DELAY", value: "-1h"},
|
||||
{name: "zero recompute interval", key: "TELESRV_RATING_RECOMPUTE_INTERVAL", value: "0s"},
|
||||
{name: "zero stale horizon", key: "TELESRV_RATING_STALE_AFTER", value: "0s"},
|
||||
{name: "zero recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "0"},
|
||||
{name: "oversized recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "20000"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv(test.key, test.value)
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCollectibleUsernameURLTemplate(t *testing.T) {
|
||||
t.Run("absolute template accepted", func(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Composite account rating.
|
||||
//
|
||||
// This is gramsrv's server-local account score. It deliberately uses its own
|
||||
// inputs and thresholds (Stars, activity and moderation), rather than claiming
|
||||
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
|
||||
// stored level through userFull's existing rating fields so official clients can
|
||||
// render it without a client patch.
|
||||
const (
|
||||
// MaxAccountRatingLevel bounds the local gramsrv level.
|
||||
MaxAccountRatingLevel = 50
|
||||
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
|
||||
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
|
||||
accountRatingLevelUnit = 100
|
||||
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
|
||||
MaxAccountRatingReasonLength = 512
|
||||
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
|
||||
MaxAccountRatingActorLength = 128
|
||||
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
|
||||
MaxAccountRatingCommandKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountRatingNotFound reports a user with no rating row yet.
|
||||
ErrAccountRatingNotFound = errors.New("account rating not found")
|
||||
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
|
||||
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
|
||||
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
|
||||
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
|
||||
)
|
||||
|
||||
// AccountRatingEventKind is the contribution source of a ledger row. Only
|
||||
// 'manual' rows survive a full recompute; the rest are audit trail.
|
||||
type AccountRatingEventKind string
|
||||
|
||||
const (
|
||||
AccountRatingEventStars AccountRatingEventKind = "stars"
|
||||
AccountRatingEventActivity AccountRatingEventKind = "activity"
|
||||
AccountRatingEventModeration AccountRatingEventKind = "moderation"
|
||||
AccountRatingEventManual AccountRatingEventKind = "manual"
|
||||
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
|
||||
)
|
||||
|
||||
// Valid reports whether the kind is modelled.
|
||||
func (k AccountRatingEventKind) Valid() bool {
|
||||
switch k {
|
||||
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
|
||||
AccountRatingEventManual, AccountRatingEventRecompute:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRating is the stored read model for one user.
|
||||
type AccountRating struct {
|
||||
UserID int64
|
||||
Level int
|
||||
Stars int64
|
||||
CurrentLevelStars int64
|
||||
// NextLevelStars is meaningful only when HasNextLevel is true.
|
||||
NextLevelStars int64
|
||||
HasNextLevel bool
|
||||
// Components are the explainable breakdown. PenaltyComponent is a
|
||||
// non-negative magnitude that is subtracted.
|
||||
StarsComponent int64
|
||||
ActivityComponent int64
|
||||
PenaltyComponent int64
|
||||
ManualComponent int64
|
||||
// PendingStars is a score delta not yet applied to the visible level, with
|
||||
// PendingDate reporting when it becomes effective.
|
||||
PendingStars int64
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// AccountRatingLevel is the local client/admin-facing level snapshot.
|
||||
type AccountRatingLevel struct {
|
||||
Level int
|
||||
CurrentLevelStars int64
|
||||
Stars int64
|
||||
NextLevelStars int64
|
||||
HasNextLevelStars bool
|
||||
}
|
||||
|
||||
// RatableAccount reports whether an account may carry a composite rating.
|
||||
//
|
||||
// The rating measures what an account did with Stars -- gifts bought, paid
|
||||
// messages sent, activity, moderation history. Two kinds of account have no
|
||||
// meaningful answer there and are excluded everywhere the rating is computed,
|
||||
// seeded or projected:
|
||||
//
|
||||
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
|
||||
// its score would only ever be the flat account-age term.
|
||||
// - The built-in service accounts (the platform account, BotFather, @Stickers,
|
||||
// @ChatBot, the verification bots). They are infrastructure rather than
|
||||
// participants: a leaderboard entry for the platform account is noise, and a
|
||||
// level badge on it would claim something about transaction volume that means
|
||||
// nothing.
|
||||
//
|
||||
// Note that the platform account is not flagged is_bot, so the bot check alone
|
||||
// does not cover it -- which is exactly how it ended up in the seeding pass.
|
||||
func RatableAccount(userID int64, bot bool) bool {
|
||||
return userID > 0 && !bot && !IsSystemUserID(userID)
|
||||
}
|
||||
|
||||
// LevelSnapshot returns the current visible local level.
|
||||
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
|
||||
return AccountRatingLevel{
|
||||
Level: r.Level,
|
||||
CurrentLevelStars: r.CurrentLevelStars,
|
||||
Stars: r.Stars,
|
||||
NextLevelStars: r.NextLevelStars,
|
||||
HasNextLevelStars: r.HasNextLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// PendingLevel returns the local level after the pending score is applied and
|
||||
// reports whether a pending record exists at all.
|
||||
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
|
||||
if r.PendingStars == 0 || r.PendingDate.IsZero() {
|
||||
return AccountRatingLevel{}, false
|
||||
}
|
||||
total := r.Stars + r.PendingStars
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
return AccountRatingLevel{
|
||||
Level: level,
|
||||
CurrentLevelStars: current,
|
||||
Stars: total,
|
||||
NextLevelStars: next,
|
||||
HasNextLevelStars: hasNext,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AccountRatingWeights is the composite formula. All weights are integers so the
|
||||
// score is exactly reproducible across a recompute and across store backends.
|
||||
type AccountRatingWeights struct {
|
||||
// StarsReceivedPermille weighs Stars credited to the account (gifts,
|
||||
// reactions, paid messages received), in permille of the raw amount.
|
||||
StarsReceivedPermille int64
|
||||
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
|
||||
// signal than receiving, so the default is lower.
|
||||
StarsSpentPermille int64
|
||||
// PerMessageSent rewards sustained use.
|
||||
PerMessageSent int64
|
||||
// PerAccountAgeDay rewards account longevity.
|
||||
PerAccountAgeDay int64
|
||||
// PerGiftReceived rewards collectible gifts held.
|
||||
PerGiftReceived int64
|
||||
// PerModerationCase is the penalty for each upheld moderation case.
|
||||
PerModerationCase int64
|
||||
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
|
||||
ScamPenalty int64
|
||||
FakePenalty int64
|
||||
// ActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh everything else. Zero means uncapped.
|
||||
ActivityCap int64
|
||||
}
|
||||
|
||||
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
|
||||
// activity contributes a bounded floor, and moderation subtracts.
|
||||
func DefaultAccountRatingWeights() AccountRatingWeights {
|
||||
return AccountRatingWeights{
|
||||
StarsReceivedPermille: 1000,
|
||||
StarsSpentPermille: 250,
|
||||
PerMessageSent: 1,
|
||||
PerAccountAgeDay: 2,
|
||||
PerGiftReceived: 25,
|
||||
PerModerationCase: 150,
|
||||
ScamPenalty: 5000,
|
||||
FakePenalty: 5000,
|
||||
ActivityCap: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects negative weights and an impossible cap.
|
||||
func (w AccountRatingWeights) Validate() error {
|
||||
values := []int64{
|
||||
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
|
||||
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
|
||||
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
|
||||
}
|
||||
for _, v := range values {
|
||||
if v < 0 {
|
||||
return ErrAccountRatingWeightsInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals is the raw snapshot gathered from the contributing
|
||||
// sources for one user. It is deliberately a plain value: the same snapshot must
|
||||
// produce the same score in a unit test and in production.
|
||||
type AccountRatingSignals struct {
|
||||
UserID int64
|
||||
StarsReceived int64
|
||||
StarsSpent int64
|
||||
MessagesSent int64
|
||||
AccountAgeDays int64
|
||||
GiftsReceived int64
|
||||
ModerationCases int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
// Manual is the sum of admin adjustments, carried across recomputes.
|
||||
Manual int64
|
||||
}
|
||||
|
||||
// ComputeAccountRating turns a signal snapshot into the read model. The score is
|
||||
// clamped at zero: penalties can erase this local score but never invert it.
|
||||
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
|
||||
if err := weights.Validate(); err != nil {
|
||||
weights = DefaultAccountRatingWeights()
|
||||
}
|
||||
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
|
||||
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
|
||||
|
||||
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
|
||||
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
|
||||
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
|
||||
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
|
||||
activityComponent = weights.ActivityCap
|
||||
}
|
||||
|
||||
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
|
||||
if signals.Scam {
|
||||
penalty += weights.ScamPenalty
|
||||
}
|
||||
if signals.Fake {
|
||||
penalty += weights.FakePenalty
|
||||
}
|
||||
|
||||
total := starsComponent + activityComponent + signals.Manual - penalty
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
|
||||
return AccountRating{
|
||||
UserID: signals.UserID,
|
||||
Level: level,
|
||||
Stars: total,
|
||||
CurrentLevelStars: current,
|
||||
NextLevelStars: next,
|
||||
HasNextLevel: hasNext,
|
||||
StarsComponent: starsComponent,
|
||||
ActivityComponent: activityComponent,
|
||||
PenaltyComponent: penalty,
|
||||
ManualComponent: signals.Manual,
|
||||
ComputedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRatingLevelThreshold returns the score needed to reach the given level.
|
||||
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
|
||||
// later ones stay meaningful.
|
||||
func AccountRatingLevelThreshold(level int) int64 {
|
||||
if level <= 0 {
|
||||
return 0
|
||||
}
|
||||
if level > MaxAccountRatingLevel {
|
||||
level = MaxAccountRatingLevel
|
||||
}
|
||||
n := int64(level)
|
||||
return accountRatingLevelUnit * n * n
|
||||
}
|
||||
|
||||
// AccountRatingLevelForStars maps a score onto the level and the surrounding
|
||||
// thresholds. hasNext is false at MaxAccountRatingLevel.
|
||||
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
|
||||
if stars < 0 {
|
||||
stars = 0
|
||||
}
|
||||
level = 0
|
||||
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
|
||||
if stars < AccountRatingLevelThreshold(candidate) {
|
||||
break
|
||||
}
|
||||
level = candidate
|
||||
}
|
||||
currentLevelStars = AccountRatingLevelThreshold(level)
|
||||
if level >= MaxAccountRatingLevel {
|
||||
return level, currentLevelStars, 0, false
|
||||
}
|
||||
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
|
||||
}
|
||||
|
||||
// ResolveAccountRatingPending decides whether a freshly computed score becomes
|
||||
// visible immediately or is parked as pending.
|
||||
//
|
||||
// A score that dropped is applied at once -- a penalty must not sit behind a
|
||||
// delay. A score that grew is parked until delay has elapsed; once the parked
|
||||
// window has passed the pending delta is folded into the visible local rating.
|
||||
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
|
||||
out := computed
|
||||
out.Version = prev.Version + 1
|
||||
if out.Version <= 0 {
|
||||
out.Version = 1
|
||||
}
|
||||
if delay <= 0 || prev.UserID == 0 {
|
||||
return out
|
||||
}
|
||||
if computed.Stars <= prev.Stars {
|
||||
return out
|
||||
}
|
||||
// A previously parked delta whose date has arrived is applied now.
|
||||
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
|
||||
return out
|
||||
}
|
||||
pendingSince := prev.PendingDate
|
||||
if prev.PendingStars == 0 || pendingSince.IsZero() {
|
||||
pendingSince = now.Add(delay)
|
||||
}
|
||||
visible := prev
|
||||
visible.StarsComponent = computed.StarsComponent
|
||||
visible.ActivityComponent = computed.ActivityComponent
|
||||
visible.PenaltyComponent = computed.PenaltyComponent
|
||||
visible.ManualComponent = computed.ManualComponent
|
||||
visible.PendingStars = computed.Stars - prev.Stars
|
||||
visible.PendingDate = pendingSince
|
||||
visible.ComputedAt = now
|
||||
visible.UpdatedAt = now
|
||||
visible.Version = out.Version
|
||||
return visible
|
||||
}
|
||||
|
||||
// AccountRatingEvent is one contribution ledger row.
|
||||
type AccountRatingEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind AccountRatingEventKind
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
|
||||
type AdjustAccountRatingRequest struct {
|
||||
UserID int64
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate rejects a no-op or oversized adjustment.
|
||||
func (r AdjustAccountRatingRequest) Validate() error {
|
||||
if r.UserID <= 0 || r.Amount == 0 {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxAccountRatingReasonLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxAccountRatingActorLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingFilter bounds an admin listing query.
|
||||
type AccountRatingFilter struct {
|
||||
MinLevel int
|
||||
UserID int64
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func permille(value, weight int64) int64 {
|
||||
if value <= 0 || weight <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value * weight / 1000
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -611,11 +611,6 @@ const (
|
|||
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice:
|
||||
// 广播频道 Direct Messages 开关/价格变更的服务消息。
|
||||
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
|
||||
// ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。
|
||||
ChannelActionStarGift ChannelMessageActionType = "star_gift"
|
||||
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUnique:collectible
|
||||
// 升级、转赠等所有权变更只进入 Recent Actions,不伪造频道历史/pts。
|
||||
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
|
||||
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。
|
||||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
|
||||
|
|
@ -655,9 +650,6 @@ type ChannelMessageAction struct {
|
|||
Completed []int
|
||||
Incompleted []int
|
||||
TodoItems []MessageTodoItem
|
||||
// StarGift 仅 star_gift 服务消息使用。
|
||||
StarGift *MessageStarGiftAction
|
||||
StarGiftUnique *MessageStarGiftUniqueAction
|
||||
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
|
||||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
|
|
@ -852,9 +844,6 @@ type ChannelMessageReactions struct {
|
|||
AsTags bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions
|
||||
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
|
||||
Paid *ChannelMessagePaidReactions
|
||||
}
|
||||
|
||||
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
|
||||
|
|
@ -1685,19 +1674,17 @@ func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
|
|||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
// optional Published result is the broadcast post.
|
||||
type ToggleSuggestedPostApprovalResult struct {
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
PayerStarsBalance *StarsBalance
|
||||
PayerTONBalance *int64
|
||||
Duplicate bool
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
|
||||
|
|
@ -1824,8 +1811,6 @@ type SendChannelMessageResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。
|
||||
SenderStarsBalance *StarsBalance
|
||||
// ReplayDeleteEvent is the existing durable channel delete event paired
|
||||
// with a deleted exact-random_id replay. It must be returned only to the
|
||||
// caller echo and must never be fanned out as a fresh event.
|
||||
|
|
|
|||
|
|
@ -591,18 +591,6 @@ const (
|
|||
// 状态切换与关闭请求。会话级保护不能写入普通消息的 NoForwards 字段。
|
||||
MessageServiceActionNoForwardsToggle MessageServiceActionKind = "no_forwards_toggle"
|
||||
MessageServiceActionNoForwardsRequest MessageServiceActionKind = "no_forwards_request"
|
||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
||||
// MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased
|
||||
// Stars credited directly to a friend, distinct from collectible Star Gifts.
|
||||
MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars"
|
||||
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
|
||||
// immutable collectible snapshot is carried by the service message so an
|
||||
// exact replay/difference never depends on mutable catalog state.
|
||||
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
|
||||
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
|
||||
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
|
||||
)
|
||||
|
||||
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
|
||||
|
|
@ -685,92 +673,6 @@ type MessageServiceAction struct {
|
|||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
|
||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
|
||||
}
|
||||
|
||||
// MessageGiftStarsAction is the immutable service-message projection. The
|
||||
// recipient-only BalanceAfter field is not encoded in messageActionGiftStars;
|
||||
// it lets online push and offline difference attach the matching non-PTS
|
||||
// updateStarsBalance without querying mutable current state.
|
||||
type MessageGiftStarsAction struct {
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Stars int64 `json:"stars"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
Converted bool `json:"converted,omitempty"`
|
||||
CanUpgrade bool `json:"can_upgrade,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
|
||||
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
|
||||
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
|
||||
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
|
||||
// outer messageActionStarGift and is only the amount already prepaid by the
|
||||
// sender. TDesktop uses these two fields to choose the paid vs free flow.
|
||||
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
||||
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
|
||||
GiftMsgID int `json:"gift_msg_id,omitempty"`
|
||||
GiftNum int `json:"gift_num,omitempty"`
|
||||
AuctionAcquired bool `json:"auction_acquired,omitempty"`
|
||||
To Peer `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftUniqueAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
Peer Peer `json:"peer"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
Upgrade bool `json:"upgrade,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
Transferred bool `json:"transferred,omitempty"`
|
||||
Refunded bool `json:"refunded,omitempty"`
|
||||
Assigned bool `json:"assigned,omitempty"`
|
||||
FromOffer bool `json:"from_offer,omitempty"`
|
||||
Craft bool `json:"craft,omitempty"`
|
||||
CanExportAt int `json:"can_export_at,omitempty"`
|
||||
TransferStars int64 `json:"transfer_stars,omitempty"`
|
||||
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
|
||||
CanTransferAt int `json:"can_transfer_at,omitempty"`
|
||||
CanResellAt int `json:"can_resell_at,omitempty"`
|
||||
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
|
||||
CanCraftAt int `json:"can_craft_at,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
ExpiresAt int `json:"expires_at"`
|
||||
Accepted bool `json:"accepted,omitempty"`
|
||||
Declined bool `json:"declined,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferDeclinedAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
package domain
|
||||
|
||||
// 频道帖子付费 reaction(messages.sendPaidReaction):用户花 Stars 为一条频道消息「点赞」,
|
||||
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
|
||||
// 与 Stars 账本(stars.go)配合:rpc 先 Debit 再在此累计。
|
||||
|
||||
const (
|
||||
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max)。
|
||||
MaxPaidReactionStarsPerRequest = 10000
|
||||
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
|
||||
MaxPaidReactionTopReactors = 3
|
||||
)
|
||||
|
||||
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
|
||||
type PaidReactor struct {
|
||||
UserID int64
|
||||
Stars int64
|
||||
Anonymous bool
|
||||
My bool // 是否为当前 viewer(投影时按视角置位)
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
|
||||
type ChannelMessagePaidReactions struct {
|
||||
TotalStars int64 // 全体 reactor 投入星数之和
|
||||
MyStars int64 // 当前 viewer 投入的星数(0 = 未投)
|
||||
MyAnonymous bool // 当前 viewer 是否匿名投入
|
||||
TopReactors []PaidReactor // 按 Stars DESC,含当前 viewer
|
||||
}
|
||||
|
||||
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
|
||||
type SendChannelPaidReactionRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Stars int64
|
||||
Anonymous bool // 隐私:是否匿名投入
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
|
||||
type ChannelMessagePaidReactionResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Paid ChannelMessagePaidReactions
|
||||
Recipients []int64
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,194 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
|
||||
user := Peer{Type: PeerTypeUser, ID: 42}
|
||||
channel := Peer{Type: PeerTypeChannel, ID: 84}
|
||||
tests := []struct {
|
||||
name string
|
||||
ref SavedStarGiftRef
|
||||
want bool
|
||||
}{
|
||||
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
|
||||
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
|
||||
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
|
||||
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
|
||||
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
|
||||
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
|
||||
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
|
||||
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.ref.Valid(); got != tt.want {
|
||||
t.Fatalf("Valid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
|
||||
if StarGiftLifecycleStatus("").Live() {
|
||||
t.Fatal("empty lifecycle status must not be treated as active")
|
||||
}
|
||||
if !StarGiftLifecycleActive.Live() {
|
||||
t.Fatal("active lifecycle status must be live")
|
||||
}
|
||||
}
|
||||
|
||||
func validCollectibleDraft() StarGiftCollectibleWrite {
|
||||
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
|
||||
return StarGiftCollectibleWrite{
|
||||
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
|
||||
Models: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.OfficialGiftID = 10
|
||||
write.SourceManifestSHA256 = make([]byte, sha256.Size)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
t.Fatalf("valid official draft: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]StarGiftCollectibleWrite{}
|
||||
withoutHash := write
|
||||
withoutHash.SourceManifestSHA256 = nil
|
||||
tests["official ID without hash"] = withoutHash
|
||||
withoutID := write
|
||||
withoutID.OfficialGiftID = 0
|
||||
tests["hash without official ID"] = withoutID
|
||||
negativeID := write
|
||||
negativeID.OfficialGiftID = -1
|
||||
tests["negative official ID"] = negativeID
|
||||
for name, invalid := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.Models[0].RarityKind = ""
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func storedCollectibleWrite() StarGiftCollectibleWrite {
|
||||
write := validCollectibleDraft()
|
||||
for i := range write.Models {
|
||||
write.Models[i].Document = &Document{
|
||||
ID: int64(100 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
|
||||
}
|
||||
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
|
||||
}
|
||||
for i := range write.Patterns {
|
||||
write.Patterns[i].Document = &Document{
|
||||
ID: int64(200 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
|
||||
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
|
||||
}
|
||||
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
|
||||
}
|
||||
return write
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"one selectable model": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models = append(write.Models[:1], write.Models[2:]...)
|
||||
},
|
||||
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns = write.Patterns[:1]
|
||||
},
|
||||
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops = write.Backdrops[:1]
|
||||
},
|
||||
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
|
||||
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
if kind == StarGiftCollectibleModel {
|
||||
write.Models[1].Document = write.Models[0].Document
|
||||
} else {
|
||||
write.Patterns[1].Document = write.Patterns[0].Document
|
||||
}
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
|
||||
t.Fatalf("valid stored collectible: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
|
||||
},
|
||||
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes[0].TextColor = false
|
||||
},
|
||||
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Thumbs = nil
|
||||
},
|
||||
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
|
||||
},
|
||||
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
|
||||
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
|
||||
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
|
||||
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
|
||||
got, ok := DecodeSavedStarGiftListCursor(encoded)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
|
||||
}
|
||||
|
||||
unpinned := SavedStarGiftListCursor{ID: 42}
|
||||
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
|
||||
if !ok || got != unpinned {
|
||||
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
|
||||
for _, cursor := range []string{
|
||||
"not-base64!",
|
||||
EncodeStarGiftCursor(42),
|
||||
EncodeSavedStarGiftListCursor(-1, 42),
|
||||
EncodeSavedStarGiftListCursor(1, 0),
|
||||
} {
|
||||
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
|
||||
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
|
||||
// 非真实支付:余额为整数 Stars(线上 Nanos 恒 0),借记原子、永不为负。
|
||||
|
||||
// StarsBalance 是一个账号的当前可用 Stars 余额。
|
||||
type StarsBalance struct {
|
||||
UserID int64
|
||||
Balance int64 // 当前可花费 Stars,恒 >= 0
|
||||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
||||
}
|
||||
|
||||
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
|
||||
// checkout. It is persisted with the form so a client cannot reinterpret a
|
||||
// self top-up as a friend gift (or vice versa) when submitting the form.
|
||||
type StarsPurchaseKind string
|
||||
|
||||
const (
|
||||
StarsPurchaseTopup StarsPurchaseKind = "topup"
|
||||
StarsPurchaseGift StarsPurchaseKind = "gift"
|
||||
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
|
||||
)
|
||||
|
||||
func (k StarsPurchaseKind) Valid() bool {
|
||||
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
|
||||
}
|
||||
|
||||
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
|
||||
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
|
||||
// eventual winner draw is a separate lifecycle transition.
|
||||
type StarsGiveawayPurchase struct {
|
||||
BoostPeer Peer `json:"boost_peer"`
|
||||
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
|
||||
CountriesISO2 []string `json:"countries_iso2,omitempty"`
|
||||
PrizeDescription string `json:"prize_description,omitempty"`
|
||||
RandomID int64 `json:"random_id"`
|
||||
UntilDate int `json:"until_date"`
|
||||
Users int `json:"users"`
|
||||
PerUserStars int64 `json:"per_user_stars"`
|
||||
YearlyBoosts int `json:"yearly_boosts"`
|
||||
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
|
||||
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
|
||||
}
|
||||
|
||||
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
|
||||
// authenticated buyer, purpose and exact server-advertised package. Recipient
|
||||
// is zero for a self top-up and mandatory for a friend gift.
|
||||
type StarsPurchaseForm struct {
|
||||
FormID int64
|
||||
Kind StarsPurchaseKind
|
||||
BuyerUserID int64
|
||||
RecipientUserID int64
|
||||
SpendPurposePeer Peer
|
||||
Giveaway *StarsGiveawayPurchase
|
||||
Stars int64
|
||||
Currency string
|
||||
Amount int64
|
||||
IssuedAt int
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
// StarsPurchaseRequest is the immutable settlement command carried by
|
||||
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
|
||||
// command after the ordinary fiat checkout has produced provider credentials.
|
||||
type StarsPurchaseRequest struct {
|
||||
StarsPurchaseForm
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// StarsPurchaseResult is the atomically committed credit and, for a friend
|
||||
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
|
||||
type StarsPurchaseResult struct {
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
ChannelSend SendChannelMessageResult
|
||||
TransactionID string
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
|
||||
// Winner selection/results are intentionally outside the purchase aggregate.
|
||||
type StarsGiveawayInfo struct {
|
||||
StartDate int
|
||||
Participating bool
|
||||
PreparingResults bool
|
||||
JoinedTooEarlyDate int
|
||||
AdminDisallowedChatID int64
|
||||
DisallowedCountry string
|
||||
}
|
||||
|
||||
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
type StarsTransaction struct {
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
Reason StarsTransactionReason
|
||||
Title string // 可选,投影到 tg.StarsTransaction.Title
|
||||
Description string // 可选,投影到 tg.StarsTransaction.Description
|
||||
}
|
||||
|
||||
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
|
||||
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
|
||||
|
||||
// StarsTransactionDirection scopes one payments.getStarsTransactions view.
|
||||
// The zero value intentionally means the combined inbound/outbound history.
|
||||
type StarsTransactionDirection uint8
|
||||
|
||||
const (
|
||||
StarsTransactionDirectionAll StarsTransactionDirection = iota
|
||||
StarsTransactionDirectionIncoming
|
||||
StarsTransactionDirectionOutgoing
|
||||
)
|
||||
|
||||
func (d StarsTransactionDirection) Valid() bool {
|
||||
return d <= StarsTransactionDirectionOutgoing
|
||||
}
|
||||
|
||||
func (d StarsTransactionDirection) IncludesAmount(amount int64) bool {
|
||||
switch d {
|
||||
case StarsTransactionDirectionAll:
|
||||
return true
|
||||
case StarsTransactionDirectionIncoming:
|
||||
return amount > 0
|
||||
case StarsTransactionDirectionOutgoing:
|
||||
return amount < 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor
|
||||
// together so filtering is applied before LIMIT in every ledger backend.
|
||||
type StarsTransactionQuery struct {
|
||||
Offset string
|
||||
Limit int
|
||||
Direction StarsTransactionDirection
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
// NormalizeStarsTransactionQuery preserves the existing bounded limit/offset
|
||||
// behavior while rejecting impossible internal direction values.
|
||||
func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) {
|
||||
if !query.Direction.Valid() {
|
||||
return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid
|
||||
}
|
||||
if len(query.Offset) > MaxStarsTransactionsOffsetBytes {
|
||||
query.Offset = ""
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit {
|
||||
query.Limit = MaxStarsTransactionsLimit
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
|
||||
type StarsTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []StarsTransaction
|
||||
NextOffset string // 空表示无更多页(DrKLO 据此停止翻页,勿在末页给非空值)
|
||||
Users []User // History 中提到的对手方用户,供 tg Users 富化
|
||||
}
|
||||
|
||||
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
|
||||
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
|
||||
// a TON node, or any blockchain service.
|
||||
type TonTransaction struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
GiftID int64
|
||||
Amount int64 // signed nanoton amount
|
||||
Date int
|
||||
Reason StarsTransactionReason
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type TonTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []TonTransaction
|
||||
NextOffset string
|
||||
Users []User
|
||||
}
|
||||
|
||||
// Stars 账本边界常量。
|
||||
const (
|
||||
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
|
||||
DefaultStarsStartingGrant = 1000
|
||||
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
|
||||
MaxStarsTransactionsLimit = 100
|
||||
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
|
||||
MaxStarsTransactionsOffsetBytes = 64
|
||||
)
|
||||
|
||||
// Stars 账本哨兵错误(rpc 层 errors.Is 匹配后映射为 tgerr,仿 ErrPremiumRequired)。
|
||||
var (
|
||||
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW)。
|
||||
ErrStarsInsufficient = errors.New("stars: insufficient balance")
|
||||
// ErrStarsInvalidAmount 表示金额非法(<=0)。
|
||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
||||
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
|
||||
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
|
||||
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
|
||||
// ErrStarsPurchaseFormExpired is returned before any settlement write.
|
||||
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
|
||||
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
|
||||
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
|
||||
)
|
||||
|
||||
// StarsPaymentRequiredError reports the minimum paid-message authorization the
|
||||
// sender must include in allow_paid_stars. The authorization is a ceiling; the
|
||||
// ledger debits only the channel's current configured price.
|
||||
type StarsPaymentRequiredError struct {
|
||||
Stars int64
|
||||
}
|
||||
|
||||
func (e *StarsPaymentRequiredError) Error() string {
|
||||
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
|
||||
}
|
||||
|
||||
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
|
||||
func EncodeStarsCursor(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
// DecodeStarsCursor 反解 EncodeStarsCursor;无法解析(含空串)时返回 ok=false,
|
||||
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
|
||||
func DecodeStarsCursor(s string) (int64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrEmojiStatusCollectibleInvalid is returned when a collectible emoji-status
|
||||
// snapshot fails EmojiStatusCollectible.Valid() -- partial/malformed rather
|
||||
// than either fully empty or fully populated.
|
||||
var ErrEmojiStatusCollectibleInvalid = errors.New("emoji status collectible invalid")
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,533 +0,0 @@
|
|||
// Package officialgifts reads the local, immutable snapshot produced by cmd/giftfetch.
|
||||
// It never performs network I/O. Selected document bytes are accepted only after their
|
||||
// manifest size and SHA-256 have been verified beneath the configured snapshot root.
|
||||
package officialgifts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
)
|
||||
|
||||
const manifestSchema = 2
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("official gifts snapshot is unavailable")
|
||||
ErrInvalid = errors.New("official gifts snapshot is invalid")
|
||||
ErrNotFound = errors.New("official gift not found")
|
||||
)
|
||||
|
||||
type Catalog struct {
|
||||
root string
|
||||
once sync.Once
|
||||
snap *snapshot
|
||||
err error
|
||||
}
|
||||
|
||||
type GiftSummary struct {
|
||||
ID int64
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
UpgradeStars int64
|
||||
AvailabilityTotal int
|
||||
AvailabilityRemains int
|
||||
AvailabilityResale int64
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
PerUserTotal int
|
||||
PerUserRemains int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *Background
|
||||
ModelCount int
|
||||
PatternCount int
|
||||
BackdropCount int
|
||||
CraftedModelCount int
|
||||
DocumentID int64
|
||||
AnimationValidated bool
|
||||
}
|
||||
|
||||
// CanUpgrade reports whether this snapshot contains the complete immutable
|
||||
// attribute pool and a positive official upgrade price required to mint a
|
||||
// collectible from the regular gift.
|
||||
func (g GiftSummary) CanUpgrade() bool {
|
||||
return g.UpgradeStars > 0 && g.ModelCount > 0 && g.PatternCount > 0 && g.BackdropCount > 0
|
||||
}
|
||||
|
||||
// CanCraft reports whether upgraded collectibles from this gift can reach an
|
||||
// official craft-only model. Craft is not advertised without a valid upgrade
|
||||
// path even if a malformed snapshot were to contain a crafted model.
|
||||
func (g GiftSummary) CanCraft() bool {
|
||||
return g.CanUpgrade() && g.CraftedModelCount > 0
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
ManifestSHA256 []byte
|
||||
SourceJSON []byte
|
||||
Gift Gift
|
||||
BaseDocument Document
|
||||
Collectible *CollectibleSet
|
||||
}
|
||||
|
||||
type Gift struct {
|
||||
ID int64
|
||||
Title string
|
||||
Stars int64
|
||||
ConvertStars int64
|
||||
UpgradeStars int64
|
||||
AvailabilityTotal int
|
||||
AvailabilityRemains int
|
||||
Limited bool
|
||||
SoldOut bool
|
||||
Birthday bool
|
||||
RequirePremium bool
|
||||
LimitedPerUser bool
|
||||
PeerColorAvailable bool
|
||||
Auction bool
|
||||
AvailabilityResale int64
|
||||
FirstSaleDate int
|
||||
LastSaleDate int
|
||||
ResellMinStars int64
|
||||
PerUserTotal int
|
||||
PerUserRemains int
|
||||
LockedUntilDate int
|
||||
AuctionSlug string
|
||||
GiftsPerRound int
|
||||
AuctionStartDate int
|
||||
UpgradeVariants int
|
||||
Background *Background
|
||||
DocumentID int64
|
||||
}
|
||||
|
||||
type Background struct {
|
||||
CenterColor int `json:"center_color"`
|
||||
EdgeColor int `json:"edge_color"`
|
||||
TextColor int `json:"text_color"`
|
||||
}
|
||||
|
||||
type CollectibleSet struct {
|
||||
Models []Model
|
||||
Patterns []Pattern
|
||||
Backdrops []Backdrop
|
||||
}
|
||||
|
||||
type Rarity struct {
|
||||
Kind string `json:"kind"`
|
||||
Permille *int `json:"permille,omitempty"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string
|
||||
DocumentID int64
|
||||
Crafted bool
|
||||
Rarity Rarity
|
||||
Document Document
|
||||
}
|
||||
|
||||
type Pattern struct {
|
||||
Name string
|
||||
DocumentID int64
|
||||
Rarity Rarity
|
||||
Document Document
|
||||
}
|
||||
|
||||
type Backdrop struct {
|
||||
Name string
|
||||
BackdropID int
|
||||
CenterColor int
|
||||
EdgeColor int
|
||||
PatternColor int
|
||||
TextColor int
|
||||
Rarity Rarity
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID int64
|
||||
FileName string
|
||||
Path string
|
||||
Size int64
|
||||
SHA256 string
|
||||
AnimationValidated bool
|
||||
ValidationError string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Schema int `json:"schema"`
|
||||
GiftCount int `json:"gift_count"`
|
||||
Gifts []giftManifest `json:"gifts"`
|
||||
UpgradeAttributeSets []collectibleManifest `json:"upgrade_attribute_sets"`
|
||||
Documents []documentManifest `json:"documents"`
|
||||
}
|
||||
|
||||
type giftManifest struct {
|
||||
Index int `json:"index"`
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars"`
|
||||
UpgradeStars int64 `json:"upgrade_stars"`
|
||||
Limited bool `json:"limited"`
|
||||
SoldOut bool `json:"sold_out"`
|
||||
Birthday bool `json:"birthday"`
|
||||
RequirePremium bool `json:"require_premium"`
|
||||
LimitedPerUser bool `json:"limited_per_user"`
|
||||
PeerColorAvailable bool `json:"peer_color_available"`
|
||||
Auction bool `json:"auction"`
|
||||
AvailabilityRemains int `json:"availability_remains"`
|
||||
AvailabilityTotal int `json:"availability_total"`
|
||||
AvailabilityResale int64 `json:"availability_resale"`
|
||||
FirstSaleDate int `json:"first_sale_date"`
|
||||
LastSaleDate int `json:"last_sale_date"`
|
||||
ResellMinStars int64 `json:"resell_min_stars"`
|
||||
PerUserTotal int `json:"per_user_total"`
|
||||
PerUserRemains int `json:"per_user_remains"`
|
||||
LockedUntilDate int `json:"locked_until_date"`
|
||||
AuctionSlug string `json:"auction_slug"`
|
||||
GiftsPerRound int `json:"gifts_per_round"`
|
||||
AuctionStartDate int `json:"auction_start_date"`
|
||||
UpgradeVariants int `json:"upgrade_variants"`
|
||||
Background *Background `json:"background"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
SourceJSON []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (g *giftManifest) UnmarshalJSON(data []byte) error {
|
||||
type plain giftManifest
|
||||
var value plain
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*g = giftManifest(value)
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil {
|
||||
return err
|
||||
}
|
||||
g.SourceJSON = append([]byte(nil), compact.Bytes()...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type collectibleManifest struct {
|
||||
GiftID64 int64 `json:"gift_id"`
|
||||
AttributeCount int `json:"attribute_count"`
|
||||
Models []modelManifest `json:"models"`
|
||||
Patterns []patternManifest `json:"patterns"`
|
||||
Backdrops []backdropManifest `json:"backdrops"`
|
||||
}
|
||||
|
||||
type modelManifest struct {
|
||||
Name string `json:"name"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Crafted bool `json:"crafted"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type patternManifest struct {
|
||||
Name string `json:"name"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type backdropManifest struct {
|
||||
Name string `json:"name"`
|
||||
BackdropID int `json:"backdrop_id"`
|
||||
CenterColor int `json:"center_color"`
|
||||
EdgeColor int `json:"edge_color"`
|
||||
PatternColor int `json:"pattern_color"`
|
||||
TextColor int `json:"text_color"`
|
||||
Rarity Rarity `json:"rarity"`
|
||||
}
|
||||
|
||||
type documentManifest struct {
|
||||
ID64 int64 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
File fileArtifact `json:"file"`
|
||||
AnimationValidated bool `json:"animation_validated"`
|
||||
ValidationError string `json:"validation_error"`
|
||||
}
|
||||
|
||||
type fileArtifact struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
manifestSHA []byte
|
||||
gifts map[int64]giftManifest
|
||||
sets map[int64]collectibleManifest
|
||||
documents map[int64]documentManifest
|
||||
ordered []int64
|
||||
}
|
||||
|
||||
func New(root string) *Catalog {
|
||||
return &Catalog{root: strings.TrimSpace(root)}
|
||||
}
|
||||
|
||||
func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) {
|
||||
snap, err := c.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]GiftSummary, 0, len(snap.ordered))
|
||||
for _, id := range snap.ordered {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gift := snap.gifts[id]
|
||||
doc := snap.documents[gift.DocumentIDs[0]]
|
||||
summary := GiftSummary{
|
||||
ID: id, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
|
||||
AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale,
|
||||
Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday,
|
||||
RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
|
||||
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
|
||||
FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate,
|
||||
ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal,
|
||||
PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate,
|
||||
AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""), GiftsPerRound: gift.GiftsPerRound,
|
||||
AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants,
|
||||
Background: cloneBackground(gift.Background), DocumentID: doc.ID64,
|
||||
AnimationValidated: doc.AnimationValidated,
|
||||
}
|
||||
if set, ok := snap.sets[id]; ok {
|
||||
summary.ModelCount, summary.PatternCount, summary.BackdropCount = len(set.Models), len(set.Patterns), len(set.Backdrops)
|
||||
for _, model := range set.Models {
|
||||
if model.Crafted {
|
||||
summary.CraftedModelCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, summary)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (Bundle, error) {
|
||||
snap, err := c.load()
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
gift, ok := snap.gifts[giftID]
|
||||
if !ok {
|
||||
return Bundle{}, ErrNotFound
|
||||
}
|
||||
base, err := c.readDocument(ctx, snap.documents[gift.DocumentIDs[0]])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("base document %d: %w", gift.DocumentIDs[0], err)
|
||||
}
|
||||
out := Bundle{
|
||||
ManifestSHA256: append([]byte(nil), snap.manifestSHA...),
|
||||
SourceJSON: append([]byte(nil), gift.SourceJSON...),
|
||||
Gift: Gift{ID: gift.ID, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
|
||||
AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut,
|
||||
Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
|
||||
PeerColorAvailable: gift.PeerColorAvailable, Auction: gift.Auction,
|
||||
AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate,
|
||||
LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars,
|
||||
PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains,
|
||||
LockedUntilDate: gift.LockedUntilDate, AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""),
|
||||
GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate,
|
||||
UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background),
|
||||
DocumentID: gift.DocumentIDs[0]},
|
||||
BaseDocument: base,
|
||||
}
|
||||
if !includeCollectible {
|
||||
return out, nil
|
||||
}
|
||||
set, ok := snap.sets[giftID]
|
||||
if !ok {
|
||||
return Bundle{}, fmt.Errorf("%w: gift %d has no collectible attribute set", ErrInvalid, giftID)
|
||||
}
|
||||
collectible := &CollectibleSet{
|
||||
Models: make([]Model, 0, len(set.Models)), Patterns: make([]Pattern, 0, len(set.Patterns)),
|
||||
Backdrops: make([]Backdrop, 0, len(set.Backdrops)),
|
||||
}
|
||||
for _, value := range set.Models {
|
||||
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err)
|
||||
}
|
||||
collectible.Models = append(collectible.Models, Model{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc})
|
||||
}
|
||||
for _, value := range set.Patterns {
|
||||
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err)
|
||||
}
|
||||
collectible.Patterns = append(collectible.Patterns, Pattern{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc})
|
||||
}
|
||||
for _, value := range set.Backdrops {
|
||||
collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: branding.UserVisibleText(value.Name, ""), BackdropID: value.BackdropID,
|
||||
CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor,
|
||||
TextColor: value.TextColor, Rarity: value.Rarity})
|
||||
}
|
||||
out.Collectible = collectible
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneBackground(value *Background) *Background {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (c *Catalog) load() (*snapshot, error) {
|
||||
c.once.Do(func() {
|
||||
if c.root == "" {
|
||||
c.err = ErrUnavailable
|
||||
return
|
||||
}
|
||||
manifestPath := filepath.Join(c.root, "manifest.json")
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
c.err = fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
return
|
||||
}
|
||||
var value manifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
c.err = fmt.Errorf("%w: decode manifest: %v", ErrInvalid, err)
|
||||
return
|
||||
}
|
||||
if value.Schema != manifestSchema || value.GiftCount != len(value.Gifts) || len(value.Gifts) == 0 {
|
||||
c.err = fmt.Errorf("%w: unexpected schema or gift count", ErrInvalid)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
snap := &snapshot{manifestSHA: append([]byte(nil), sum[:]...), gifts: make(map[int64]giftManifest, len(value.Gifts)),
|
||||
sets: make(map[int64]collectibleManifest, len(value.UpgradeAttributeSets)), documents: make(map[int64]documentManifest, len(value.Documents))}
|
||||
for _, doc := range value.Documents {
|
||||
if doc.ID64 <= 0 || doc.File.Size <= 0 || len(doc.File.SHA256) != 64 || strings.TrimSpace(doc.File.Path) == "" {
|
||||
c.err = fmt.Errorf("%w: invalid document %d", ErrInvalid, doc.ID64)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.documents[doc.ID64]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate document %d", ErrInvalid, doc.ID64)
|
||||
return
|
||||
}
|
||||
snap.documents[doc.ID64] = doc
|
||||
}
|
||||
for _, gift := range value.Gifts {
|
||||
if gift.Kind != "regular" || gift.ID <= 0 || gift.Stars <= 0 || len(gift.DocumentIDs) != 1 {
|
||||
c.err = fmt.Errorf("%w: invalid gift %d", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
if _, ok := snap.documents[gift.DocumentIDs[0]]; !ok {
|
||||
c.err = fmt.Errorf("%w: gift %d document missing", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.gifts[gift.ID]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate gift %d", ErrInvalid, gift.ID)
|
||||
return
|
||||
}
|
||||
snap.gifts[gift.ID] = gift
|
||||
snap.ordered = append(snap.ordered, gift.ID)
|
||||
}
|
||||
for _, set := range value.UpgradeAttributeSets {
|
||||
if _, ok := snap.gifts[set.GiftID64]; !ok || set.AttributeCount != len(set.Models)+len(set.Patterns)+len(set.Backdrops) ||
|
||||
len(set.Models) == 0 || len(set.Patterns) == 0 || len(set.Backdrops) == 0 {
|
||||
c.err = fmt.Errorf("%w: invalid collectible set %d", ErrInvalid, set.GiftID64)
|
||||
return
|
||||
}
|
||||
if _, duplicate := snap.sets[set.GiftID64]; duplicate {
|
||||
c.err = fmt.Errorf("%w: duplicate collectible set %d", ErrInvalid, set.GiftID64)
|
||||
return
|
||||
}
|
||||
for _, model := range set.Models {
|
||||
if _, ok := snap.documents[model.DocumentID]; !ok || !validRarity(model.Rarity, model.Crafted) {
|
||||
c.err = fmt.Errorf("%w: invalid model %q", ErrInvalid, model.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, pattern := range set.Patterns {
|
||||
if _, ok := snap.documents[pattern.DocumentID]; !ok || !validRarity(pattern.Rarity, false) {
|
||||
c.err = fmt.Errorf("%w: invalid pattern %q", ErrInvalid, pattern.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, backdrop := range set.Backdrops {
|
||||
if backdrop.BackdropID < 0 || !validRarity(backdrop.Rarity, false) {
|
||||
c.err = fmt.Errorf("%w: invalid backdrop %q", ErrInvalid, backdrop.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
snap.sets[set.GiftID64] = set
|
||||
}
|
||||
sort.SliceStable(snap.ordered, func(i, j int) bool { return snap.gifts[snap.ordered[i]].Index < snap.gifts[snap.ordered[j]].Index })
|
||||
c.snap = snap
|
||||
})
|
||||
return c.snap, c.err
|
||||
}
|
||||
|
||||
func validRarity(rarity Rarity, crafted bool) bool {
|
||||
if rarity.Kind == "permille" {
|
||||
return !crafted && rarity.Permille != nil && *rarity.Permille > 0 && *rarity.Permille <= 1000
|
||||
}
|
||||
return crafted && rarity.Permille == nil && (rarity.Kind == "uncommon" || rarity.Kind == "rare" || rarity.Kind == "epic" || rarity.Kind == "legendary")
|
||||
}
|
||||
|
||||
func (c *Catalog) readDocument(ctx context.Context, doc documentManifest) (Document, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
root, err := filepath.Abs(c.root)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(doc.File.Path))
|
||||
if filepath.IsAbs(clean) {
|
||||
return Document{}, ErrInvalid
|
||||
}
|
||||
full := filepath.Join(root, clean)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return Document{}, ErrInvalid
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if int64(len(data)) != doc.File.Size {
|
||||
return Document{}, fmt.Errorf("%w: size mismatch", ErrInvalid)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if !strings.EqualFold(hex.EncodeToString(sum[:]), doc.File.SHA256) {
|
||||
return Document{}, fmt.Errorf("%w: sha256 mismatch", ErrInvalid)
|
||||
}
|
||||
name := strings.TrimSpace(doc.FileName)
|
||||
if name == "" {
|
||||
name = filepath.Base(clean)
|
||||
}
|
||||
return Document{ID: doc.ID64, FileName: name, Path: doc.File.Path, Size: doc.File.Size,
|
||||
SHA256: strings.ToLower(doc.File.SHA256), AnimationValidated: doc.AnimationValidated,
|
||||
ValidationError: doc.ValidationError, Data: data}, nil
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
package officialgifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogVerifiesSelectedDocument(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
data := []byte("official-tgs")
|
||||
sum := sha256.Sum256(data)
|
||||
if err := os.MkdirAll(filepath.Join(root, "documents"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := manifest{Schema: manifestSchema, GiftCount: 1,
|
||||
Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Title: "Telegram Pin", Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}},
|
||||
Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}},
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "manifest.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].ID != 1 || items[0].Title != "OwpenGram Pin" {
|
||||
t.Fatalf("items=%+v err=%v", items, err)
|
||||
}
|
||||
bundle, err := catalog.Bundle(context.Background(), 1, false)
|
||||
if err != nil || string(bundle.BaseDocument.Data) != string(data) || bundle.Gift.Title != "OwpenGram Pin" {
|
||||
t.Fatalf("bundle=%+v err=%v", bundle, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := catalog.Bundle(context.Background(), 1, false); err == nil {
|
||||
t.Fatal("tampered document was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredOfficialSnapshotIsComplete(t *testing.T) {
|
||||
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
|
||||
if root == "" {
|
||||
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
|
||||
}
|
||||
catalog := New(root)
|
||||
items, err := catalog.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sets, attributes, crafted, upgradable, craftable int
|
||||
verifiedDocuments := map[int64]struct{}{}
|
||||
for _, item := range items {
|
||||
if item.CanUpgrade() {
|
||||
upgradable++
|
||||
}
|
||||
if item.CanCraft() {
|
||||
craftable++
|
||||
}
|
||||
include := item.ModelCount+item.PatternCount+item.BackdropCount > 0
|
||||
bundle, err := catalog.Bundle(context.Background(), item.ID, include)
|
||||
if err != nil {
|
||||
t.Fatalf("gift %d: %v", item.ID, err)
|
||||
}
|
||||
verifiedDocuments[bundle.BaseDocument.ID] = struct{}{}
|
||||
if bundle.Collectible == nil {
|
||||
continue
|
||||
}
|
||||
sets++
|
||||
attributes += len(bundle.Collectible.Models) + len(bundle.Collectible.Patterns) + len(bundle.Collectible.Backdrops)
|
||||
for _, model := range bundle.Collectible.Models {
|
||||
verifiedDocuments[model.Document.ID] = struct{}{}
|
||||
if model.Crafted {
|
||||
crafted++
|
||||
}
|
||||
}
|
||||
for _, pattern := range bundle.Collectible.Patterns {
|
||||
verifiedDocuments[pattern.Document.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(items) != 149 || sets != 116 || attributes != 40332 || crafted != 108 || upgradable != 114 || craftable != 2 || len(verifiedDocuments) != 8333 {
|
||||
t.Fatalf("gifts=%d sets=%d attributes=%d crafted=%d upgradable=%d craftable=%d documents=%d",
|
||||
len(items), sets, attributes, crafted, upgradable, craftable, len(verifiedDocuments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftSummaryCapabilitiesRequireCompleteOfficialFacts(t *testing.T) {
|
||||
complete := GiftSummary{UpgradeStars: 25, ModelCount: 1, PatternCount: 1, BackdropCount: 1}
|
||||
if !complete.CanUpgrade() || complete.CanCraft() {
|
||||
t.Fatalf("complete regular pool capabilities = upgrade:%v craft:%v", complete.CanUpgrade(), complete.CanCraft())
|
||||
}
|
||||
craftable := complete
|
||||
craftable.CraftedModelCount = 1
|
||||
if !craftable.CanUpgrade() || !craftable.CanCraft() {
|
||||
t.Fatalf("crafted pool capabilities = upgrade:%v craft:%v", craftable.CanUpgrade(), craftable.CanCraft())
|
||||
}
|
||||
for name, invalid := range map[string]GiftSummary{
|
||||
"zero upgrade price": {ModelCount: 1, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing model": {UpgradeStars: 25, PatternCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing pattern": {UpgradeStars: 25, ModelCount: 1, BackdropCount: 1, CraftedModelCount: 1},
|
||||
"missing backdrop": {UpgradeStars: 25, ModelCount: 1, PatternCount: 1, CraftedModelCount: 1},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if invalid.CanUpgrade() || invalid.CanCraft() {
|
||||
t.Fatalf("invalid facts advertised capabilities: %+v", invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1792,7 +1792,7 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
|
|||
if errors.Is(err, domain.ErrPremiumRequired) {
|
||||
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) {
|
||||
return false, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return false, internalErr()
|
||||
|
|
@ -1842,29 +1842,9 @@ func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input
|
|||
}
|
||||
return value, nil
|
||||
case *tg.InputEmojiStatusCollectible:
|
||||
if r.deps.Gifts == nil || status.CollectibleID <= 0 {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID)
|
||||
if err != nil {
|
||||
return domain.UserEmojiStatus{}, internalErr()
|
||||
}
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
collectible, valid := domain.CollectibleEmojiStatus(gift)
|
||||
if !valid {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
if until, ok := status.GetUntil(); ok {
|
||||
value.Until = until
|
||||
}
|
||||
if !value.Valid() {
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
}
|
||||
return value, nil
|
||||
// telesrv has no Star Gifts, so no account can ever own the collectible
|
||||
// this would reference.
|
||||
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
|
||||
default:
|
||||
return domain.UserEmojiStatus{}, inputConstructorInvalidErr()
|
||||
}
|
||||
|
|
@ -1969,35 +1949,11 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6
|
|||
// owned unique gifts as complete emojiStatusCollectible values. The bounded
|
||||
// list order and hash are stable, so Android can safely reuse its cache.
|
||||
func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
}
|
||||
gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
ids := make([]int64, 0, len(gifts))
|
||||
statuses := make([]tg.EmojiStatusClass, 0, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(gift)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, collectible.CollectibleID)
|
||||
statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{
|
||||
DocumentID: collectible.DocumentID,
|
||||
Collectible: collectible,
|
||||
}))
|
||||
}
|
||||
catalogHash := mediaCatalogHash(ids)
|
||||
if hash != 0 && hash == catalogHash {
|
||||
return &tg.AccountEmojiStatusesNotModified{}, nil
|
||||
}
|
||||
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
|
||||
// telesrv has no Star Gifts, so no account ever owns a collectible to list.
|
||||
return tdesktop.CollectibleEmojiStatuses(), nil
|
||||
}
|
||||
|
||||
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) *tg.User {
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type collectibleEmojiGiftService struct {
|
||||
GiftsService
|
||||
gifts map[int64]domain.UniqueStarGift
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) {
|
||||
gift, ok := s.gifts[id]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
out := make([]domain.UniqueStarGift, 0, len(s.gifts))
|
||||
for _, gift := range s.gifts {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift {
|
||||
return domain.UniqueStarGift{
|
||||
ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1",
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}},
|
||||
Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}},
|
||||
Backdrop: domain.StarGiftCollectibleAttribute{
|
||||
CenterColor: 0x102030, EdgeColor: 0x405060,
|
||||
PatternColor: 0x708090, TextColor: 0xa0b0c0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := appusers.NewService(userStore)
|
||||
if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil {
|
||||
t.Fatalf("grant premium: %v", err)
|
||||
}
|
||||
gift := collectibleEmojiTestGift(owner.ID)
|
||||
gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}}
|
||||
r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System)
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
|
||||
listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get collectible statuses: %v", err)
|
||||
}
|
||||
statuses, ok := listed.(*tg.AccountEmojiStatuses)
|
||||
if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 {
|
||||
t.Fatalf("collectible list = %T %#v", listed, listed)
|
||||
}
|
||||
collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible)
|
||||
if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID ||
|
||||
collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor {
|
||||
t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0])
|
||||
}
|
||||
if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil {
|
||||
t.Fatalf("get cached collectible statuses: %v", err)
|
||||
} else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok {
|
||||
t.Fatalf("cached collectible statuses = %T, want notModified", cached)
|
||||
}
|
||||
|
||||
input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}
|
||||
input.SetUntil(2_000_000_000)
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok {
|
||||
t.Fatalf("set collectible status: ok=%v err=%v", ok, err)
|
||||
}
|
||||
self, err := users.Self(ctx, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID ||
|
||||
self.EmojiStatusUntil != 2_000_000_000 {
|
||||
t.Fatalf("persisted collectible status = %+v", self.EmojiStatus())
|
||||
}
|
||||
wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible)
|
||||
if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor {
|
||||
t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire)
|
||||
}
|
||||
|
||||
stolen := gift
|
||||
stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
gifts.gifts[gift.ID] = stolen
|
||||
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") {
|
||||
t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) {
|
||||
collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1))
|
||||
if !ok {
|
||||
t.Fatal("test gift should project")
|
||||
}
|
||||
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
|
||||
update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{
|
||||
UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value,
|
||||
}).(*tg.UpdateUserEmojiStatus)
|
||||
if !ok {
|
||||
t.Fatal("durable event did not produce updateUserEmojiStatus")
|
||||
}
|
||||
if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID {
|
||||
t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeAccountRatingProjection struct {
|
||||
byUser map[int64]domain.AccountRating
|
||||
err error
|
||||
ratingCalls int
|
||||
ensureCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.ratingCalls++
|
||||
if f.err != nil {
|
||||
return domain.AccountRating{}, f.err
|
||||
}
|
||||
rating, ok := f.byUser[userID]
|
||||
if !ok {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// EnsureRating deliberately exists on the fake even though it is not part of
|
||||
// AccountRatingService. The assertion below pins that profile reads stay
|
||||
// read-only if a future implementation happens to expose a materializer.
|
||||
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
||||
f.ensureCalls++
|
||||
return f.byUser[userID], nil
|
||||
}
|
||||
|
||||
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
|
||||
|
||||
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 11,
|
||||
Phone: "15550004001",
|
||||
FirstName: "Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
other, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 22,
|
||||
Phone: "15550004002",
|
||||
FirstName: "Other",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
AccountRatings: ratings,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return router, owner, other
|
||||
}
|
||||
|
||||
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
|
||||
pendingDate := time.Unix(1800000000, 0).UTC()
|
||||
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
||||
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
|
||||
ratings.byUser[owner.ID] = domain.AccountRating{
|
||||
UserID: owner.ID,
|
||||
Level: 3,
|
||||
Stars: 1200,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
|
||||
NextLevelStars: domain.AccountRatingLevelThreshold(4),
|
||||
HasNextLevel: true,
|
||||
PendingStars: 500,
|
||||
PendingDate: pendingDate,
|
||||
}
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
|
||||
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
|
||||
if err != nil {
|
||||
t.Fatalf("get self full user: %v", err)
|
||||
}
|
||||
rating, ok := full.FullUser.GetStarsRating()
|
||||
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
|
||||
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
|
||||
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
|
||||
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
|
||||
}
|
||||
pending, ok := full.FullUser.GetStarsMyPendingRating()
|
||||
if !ok || pending.Stars != 1700 {
|
||||
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
|
||||
}
|
||||
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
|
||||
t.Fatalf("self pending date = %d (present=%v)", date, ok)
|
||||
}
|
||||
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
||||
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
||||
}
|
||||
|
||||
// The second response is served from the existing UserFull projection cache:
|
||||
// the rating read cannot become a per-request database query.
|
||||
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
|
||||
t.Fatalf("get cached self full user: %v", err)
|
||||
}
|
||||
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
||||
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
|
||||
pendingDate := time.Unix(1800000000, 0).UTC()
|
||||
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
||||
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
|
||||
ratings.byUser[other.ID] = domain.AccountRating{
|
||||
UserID: other.ID,
|
||||
Level: 1,
|
||||
Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
NextLevelStars: domain.AccountRatingLevelThreshold(2),
|
||||
HasNextLevel: true,
|
||||
PendingStars: 500,
|
||||
PendingDate: pendingDate,
|
||||
}
|
||||
|
||||
full, err := router.onUsersGetFullUser(
|
||||
WithUserID(context.Background(), owner.ID),
|
||||
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get other full user: %v", err)
|
||||
}
|
||||
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
|
||||
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
||||
t.Fatal("other pending rating is visible")
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
|
||||
t.Fatal("other pending rating date is visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ratings AccountRatingService
|
||||
}{
|
||||
{name: "service absent"},
|
||||
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
|
||||
{name: "read failure", ratings: &fakeAccountRatingProjection{
|
||||
byUser: map[int64]domain.AccountRating{},
|
||||
err: errors.New("rating unavailable"),
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
|
||||
full, err := router.onUsersGetFullUser(
|
||||
WithUserID(context.Background(), owner.ID),
|
||||
&tg.InputUserSelf{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get full user: %v", err)
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("rating set without a stored projection")
|
||||
}
|
||||
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
||||
t.Fatal("pending rating set without a stored projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
viewer, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 31,
|
||||
Phone: "15550004101",
|
||||
FirstName: "Viewer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
bot, err := userStore.Create(ctx, domain.User{
|
||||
AccessHash: 32,
|
||||
Phone: "15550004102",
|
||||
FirstName: "Helper",
|
||||
Bot: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
|
||||
viewer.ID: {
|
||||
UserID: viewer.ID,
|
||||
Level: domain.MaxAccountRatingLevel,
|
||||
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
||||
},
|
||||
bot.ID: {
|
||||
UserID: bot.ID,
|
||||
Level: 4,
|
||||
Stars: 2000,
|
||||
},
|
||||
domain.OfficialSystemUserID: {
|
||||
UserID: domain.OfficialSystemUserID,
|
||||
Level: 5,
|
||||
Stars: 3000,
|
||||
},
|
||||
}}
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
AccountRatings: ratings,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
viewerCtx := WithUserID(ctx, viewer.ID)
|
||||
|
||||
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
|
||||
if err != nil {
|
||||
t.Fatalf("get own full user: %v", err)
|
||||
}
|
||||
rating, ok := own.FullUser.GetStarsRating()
|
||||
if !ok || rating.Level != domain.MaxAccountRatingLevel {
|
||||
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
|
||||
}
|
||||
if _, ok := rating.GetNextLevelStars(); ok {
|
||||
t.Fatal("next_level_stars set at the maximum level")
|
||||
}
|
||||
|
||||
botFull, err := router.onUsersGetFullUser(
|
||||
viewerCtx,
|
||||
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot full user: %v", err)
|
||||
}
|
||||
if _, ok := botFull.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("bot rating is visible")
|
||||
}
|
||||
|
||||
official, err := router.onUsersGetFullUser(
|
||||
viewerCtx,
|
||||
&tg.InputUser{
|
||||
UserID: domain.OfficialSystemUserID,
|
||||
AccessHash: domain.OfficialSystemUser().AccessHash,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get official system user: %v", err)
|
||||
}
|
||||
if _, ok := official.FullUser.GetStarsRating(); ok {
|
||||
t.Fatal("system account rating is visible")
|
||||
}
|
||||
// One read for the ratable viewer and none for the bot/system guards.
|
||||
if ratings.ratingCalls != 1 {
|
||||
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -34,19 +32,6 @@ func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) er
|
|||
return nil
|
||||
}
|
||||
|
||||
// NotifyStarsBalanceChanged is the domain-only hook used by the internal Admin
|
||||
// API after the local Stars ledger balance has changed outside a client RPC.
|
||||
func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
|
||||
if r == nil || balance.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
r.pushUserUpdates(ctx, balance.UserID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAccountFreezeChanged invalidates target-scoped projections immediately
|
||||
// and wakes the durable audience nudge worker. Cross-instance cache invalidation
|
||||
// is also carried by the committed user_visibility read-model notification.
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
|
||||
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
|
||||
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
|
||||
r.applyBotVerificationToChannelFull(ctx, ref.ID, &full)
|
||||
|
|
@ -192,7 +191,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
return nil, err
|
||||
}
|
||||
full := tgChannelFull(view, r.cfg.PublicBaseURL)
|
||||
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
|
||||
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
|
||||
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
|
||||
// (此前审计误判为死代码,已由单测纠正——勿删。)
|
||||
|
|
@ -237,16 +235,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) applyStarGiftsCountToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
|
||||
if r.deps.Gifts == nil || channelID == 0 || full == nil {
|
||||
return
|
||||
}
|
||||
n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||||
if err == nil && n > 0 {
|
||||
full.SetStargiftsCount(n)
|
||||
}
|
||||
}
|
||||
|
||||
type channelReadModelResolver interface {
|
||||
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,12 +175,6 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
|
|||
if !ok || len(some.Reactions) != catalogSize {
|
||||
t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize)
|
||||
}
|
||||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
||||
|
|
@ -230,9 +224,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
|||
if fullChannel.ReactionsLimit != 7 {
|
||||
t.Fatalf("reactions limit after omitted flag = %d, want preserved 7", fullChannel.ReactionsLimit)
|
||||
}
|
||||
if !fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after omitted flag = false, want preserved true")
|
||||
}
|
||||
|
||||
disablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: peer,
|
||||
|
|
@ -253,12 +244,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
|||
if fullChannel.ReactionsLimit != 7 {
|
||||
t.Fatalf("reactions limit after paid-only update = %d, want preserved 7", fullChannel.ReactionsLimit)
|
||||
}
|
||||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after explicit false = true, want false")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
||||
|
|
@ -299,10 +284,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get full channel after TDesktop sentinel set: %v", err)
|
||||
}
|
||||
fullChannel := full.FullChat.(*tg.ChannelFull)
|
||||
if !fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
|
||||
}
|
||||
some := mustChannelFullSomeReactions(t, full)
|
||||
if len(some.Reactions) != 2 {
|
||||
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
|
||||
|
|
@ -336,81 +317,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Broadcast",
|
||||
Broadcast: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast channel: %v", err)
|
||||
}
|
||||
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel: %v", err)
|
||||
}
|
||||
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed = false, want true")
|
||||
}
|
||||
if broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
|
||||
}
|
||||
|
||||
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enablePaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
|
||||
t.Fatalf("enable broadcast paid reactions: %v", err)
|
||||
}
|
||||
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel after paid enable: %v", err)
|
||||
}
|
||||
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
|
||||
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
|
||||
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Mega",
|
||||
Megagroup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enableMegaPaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
|
||||
t.Fatalf("set megagroup paid_enabled request: %v", err)
|
||||
}
|
||||
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get megagroup full channel: %v", err)
|
||||
}
|
||||
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
|
||||
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
|
||||
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -304,10 +304,6 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
BroadcastMessagesAllowed: action.BroadcastMessagesAllowed,
|
||||
Stars: action.Stars,
|
||||
}
|
||||
case domain.ChannelActionStarGift:
|
||||
return tgMessageActionStarGift(action.StarGift)
|
||||
case domain.ChannelActionStarGiftUnique:
|
||||
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
|
||||
case domain.ChannelActionSetChatWallpaper:
|
||||
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
|
||||
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
|
||||
|
|
@ -649,19 +645,6 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
|||
if ch.ReactionPolicy.Limit > 0 {
|
||||
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
|
||||
}
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
// Android uses paid_media_allowed as the capability gate for showing the
|
||||
// paid-reaction setting; paid_reactions_available below remains the saved
|
||||
// on/off state.
|
||||
full.SetPaidMediaAllowed(true)
|
||||
full.SetStargiftsAvailable(true)
|
||||
}
|
||||
// paid_reactions_available reflects the saved chat policy, not mere broadcast
|
||||
// capability. Android counts this flag as an extra available reaction in the
|
||||
// settings row, so advertising it without paid_enabled corrupts the UI count.
|
||||
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
|
||||
full.SetPaidReactionsAvailable(true)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -242,90 +242,11 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
ButtonID: shared.ButtonID,
|
||||
Peers: tgPeerList(shared.Peers),
|
||||
}
|
||||
case domain.MessageServiceActionStarGift:
|
||||
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
|
||||
case domain.MessageServiceActionGiftStars:
|
||||
action := m.ServiceAction.GiftStars
|
||||
if action == nil || action.Currency == "" || action.Amount <= 0 || action.Stars <= 0 {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
out := &tg.MessageActionGiftStars{
|
||||
Currency: action.Currency,
|
||||
Amount: action.Amount,
|
||||
Stars: action.Stars,
|
||||
}
|
||||
// Telegram only exposes the provider transaction id to the receiver.
|
||||
if !msg.Out && action.TransactionID != "" {
|
||||
out.SetTransactionID(action.TransactionID)
|
||||
}
|
||||
return out
|
||||
case domain.MessageServiceActionStarGiftUnique:
|
||||
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
||||
case domain.MessageServiceActionStarGiftOffer:
|
||||
action := m.ServiceAction.StarGiftOffer
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
|
||||
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
|
||||
case domain.MessageServiceActionStarGiftOfferDeclined:
|
||||
action := m.ServiceAction.StarGiftOfferDeclined
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
|
||||
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
|
||||
default:
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
}
|
||||
|
||||
func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass {
|
||||
if action == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
out := &tg.MessageActionStarGiftUnique{
|
||||
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
|
||||
Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned,
|
||||
FromOffer: action.FromOffer, Craft: action.Craft,
|
||||
Gift: tgUniqueStarGift(action.Gift),
|
||||
}
|
||||
if action.CanExportAt > 0 {
|
||||
out.SetCanExportAt(action.CanExportAt)
|
||||
}
|
||||
if action.TransferStars > 0 {
|
||||
out.SetTransferStars(action.TransferStars)
|
||||
}
|
||||
if action.ResaleAmount != nil {
|
||||
out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount))
|
||||
}
|
||||
if action.CanTransferAt > 0 {
|
||||
out.SetCanTransferAt(action.CanTransferAt)
|
||||
}
|
||||
if action.CanResellAt > 0 {
|
||||
out.SetCanResellAt(action.CanResellAt)
|
||||
}
|
||||
if action.DropOriginalDetailsStars > 0 {
|
||||
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
|
||||
}
|
||||
// Channel Craft is not executable yet. Gate on the authoritative gift owner
|
||||
// as a final wire boundary so historical JSON/admin-log actions or a future
|
||||
// constructor cannot accidentally expose Android's Craft entry marker.
|
||||
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
|
||||
out.SetCanCraftAt(action.CanCraftAt)
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
|
||||
}
|
||||
if peer := tgPeer(action.Peer); peer != nil {
|
||||
out.SetPeer(peer)
|
||||
}
|
||||
if action.SavedID != 0 {
|
||||
out.SetSavedID(action.SavedID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
|
||||
out := make([]tg.PeerClass, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
|
|
@ -511,11 +432,6 @@ func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions)
|
|||
out.SetRecentReactions(recent)
|
||||
}
|
||||
}
|
||||
// 付费 reaction:注入 ReactionPaid 计数 + top reactors(My/chosen 由 in.Paid 的视角数据驱动,
|
||||
// 调用方对他人视角已抹除 My/MyStars)。统一在此注入,覆盖所有频道消息读路径。
|
||||
if in.Paid != nil {
|
||||
injectPaidReaction(out, *in.Paid)
|
||||
}
|
||||
if out.Results == nil {
|
||||
out.Results = []tg.ReactionCount{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,6 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
|
|||
out.NewMessages = append(out.NewMessages, msg)
|
||||
addMessageUsers(out, seenUsers, event.Message)
|
||||
}
|
||||
if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil {
|
||||
out.OtherUpdates = append(out.OtherUpdates, balance)
|
||||
}
|
||||
case domain.UpdateEventReadHistoryInbox:
|
||||
if update := tgReadHistoryInboxUpdate(event); update != nil {
|
||||
out.OtherUpdates = append(out.OtherUpdates, update)
|
||||
|
|
|
|||
|
|
@ -810,7 +810,6 @@ type ChannelsService interface {
|
|||
// SetActiveCall / AppendCallServiceMessage 是群通话模块的频道侧挂接点。
|
||||
SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error)
|
||||
AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error)
|
||||
AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error
|
||||
InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
|
||||
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
}
|
||||
|
|
@ -1004,16 +1003,6 @@ type UsernameRegistryService interface {
|
|||
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
||||
}
|
||||
|
||||
// AccountRatingService exposes the stored gramsrv composite rating used by the
|
||||
// userFull rating projection.
|
||||
//
|
||||
// It is deliberately read-only at the RPC boundary: ratings are computed by the
|
||||
// bounded background worker, while profile reads only fetch the latest stored
|
||||
// projection. A nil service or a read failure leaves every rating flag unset.
|
||||
type AccountRatingService interface {
|
||||
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
}
|
||||
|
||||
// BotVerificationService is the third-party bot verification boundary
|
||||
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
|
||||
// own icon and description, which official clients render as a badge distinct from
|
||||
|
|
@ -1065,7 +1054,6 @@ type Deps struct {
|
|||
Moderation ModerationService
|
||||
Users UsersService
|
||||
Usernames UsernameRegistryService
|
||||
AccountRatings AccountRatingService
|
||||
BotVerifications BotVerificationService
|
||||
TelegramLogin TelegramLoginService
|
||||
Updates UpdatesService
|
||||
|
|
@ -1096,8 +1084,6 @@ type Deps struct {
|
|||
Limiter RateLimiter
|
||||
Metrics Metrics
|
||||
SecretChats SecretChatService
|
||||
Stars StarsService
|
||||
Gifts GiftsService
|
||||
Passkey PasskeyService
|
||||
Themes ThemeService
|
||||
}
|
||||
|
|
@ -1127,71 +1113,6 @@ type PasskeyService interface {
|
|||
Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error)
|
||||
}
|
||||
|
||||
// GiftsService 抽象 Star 礼物(app/stargifts):目录 + peer 收到的礼物实例 CRUD。
|
||||
// 扣费/退款/服务消息投递由 rpc 层经 Stars 账本 + Messages.SendPrivateText 编排。
|
||||
type GiftsService interface {
|
||||
Catalog(ctx context.Context) ([]domain.StarGift, error)
|
||||
CatalogHash(ctx context.Context) (int, error)
|
||||
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
|
||||
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
|
||||
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
|
||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
|
||||
ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error)
|
||||
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
|
||||
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
|
||||
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
|
||||
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
|
||||
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
|
||||
GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
|
||||
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
|
||||
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
|
||||
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
|
||||
ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error)
|
||||
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
|
||||
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
|
||||
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
|
||||
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
|
||||
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
|
||||
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
|
||||
ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
|
||||
ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
|
||||
SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
|
||||
Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
|
||||
PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
|
||||
SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
|
||||
ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
|
||||
Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
|
||||
AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
|
||||
ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
|
||||
AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
|
||||
BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
|
||||
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
|
||||
PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
|
||||
DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
|
||||
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
|
||||
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
|
||||
TonBalance(ctx context.Context, userID int64) (int64, error)
|
||||
TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
|
||||
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
|
||||
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
|
||||
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
|
||||
}
|
||||
|
||||
// StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。
|
||||
// 借记原子且永不为负;余额不足返回 domain.ErrStarsInsufficient(rpc 经 starsErr
|
||||
// 映射为 BALANCE_TOO_LOW)。getStarsStatus 首读时惰性授予起始余额。
|
||||
type StarsService interface {
|
||||
GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error)
|
||||
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
||||
Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
||||
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
|
||||
}
|
||||
|
||||
// SecretChatService 抽象私聊端对端加密(Secret Chat)握手状态机(app/secretchat)。
|
||||
// 服务端是盲中继;错误集合见 domain.ErrSecretChat* 与 app/secretchat.ErrGAInvalid
|
||||
// (rpc 层经 secretChatErr 映射为 ENCRYPTION_* / CHAT_ID_INVALID / DH_G_A_INVALID)。
|
||||
|
|
|
|||
|
|
@ -300,11 +300,6 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
|
|||
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
|
||||
}
|
||||
updates = append(updates, newMsg)
|
||||
if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{
|
||||
Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance},
|
||||
})
|
||||
}
|
||||
date := int(r.clock.Now().Unix())
|
||||
if res.Duplicate && res.ReplayDeleteEvent != nil {
|
||||
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {
|
||||
|
|
|
|||
|
|
@ -296,12 +296,6 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 {
|
||||
t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft)
|
||||
}
|
||||
tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554}
|
||||
tooLow.SetAllowPaidStars(9)
|
||||
if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") {
|
||||
t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err)
|
||||
}
|
||||
|
||||
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
|
||||
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
|
||||
subReq.ClearDraft = true
|
||||
|
|
@ -319,7 +313,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd)
|
||||
}
|
||||
var subMessageID int
|
||||
var subPaidStars, subBalance int64
|
||||
var subPaidStars int64
|
||||
for _, update := range subUpdates.Updates {
|
||||
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
|
||||
if message, ok := newMessage.Message.(*tg.Message); ok {
|
||||
|
|
@ -336,36 +330,21 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
|
||||
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
|
||||
subBalance = amount.Amount
|
||||
}
|
||||
}
|
||||
}
|
||||
if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 {
|
||||
t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates)
|
||||
// telesrv has no Stars economy: DM sends are always free, so no
|
||||
// UpdateStarsBalance is ever emitted regardless of allow_paid_stars.
|
||||
if subMessageID == 0 || subPaidStars != 0 {
|
||||
t.Fatalf("subscriber send updates id/paid = %d/%d, want id>0/0: %#v", subMessageID, subPaidStars, subUpdates.Updates)
|
||||
}
|
||||
if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found {
|
||||
t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err)
|
||||
t.Fatalf("clear_draft after send found/err = %v/%v, want false/nil", found, err)
|
||||
}
|
||||
duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber paid replay: %v", err)
|
||||
t.Fatalf("subscriber replay: %v", err)
|
||||
}
|
||||
duplicateUpdates, ok := duplicateUpd.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd)
|
||||
}
|
||||
var duplicateBalance int64
|
||||
for _, update := range duplicateUpdates.Updates {
|
||||
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
|
||||
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
|
||||
duplicateBalance = amount.Amount
|
||||
}
|
||||
}
|
||||
}
|
||||
if duplicateBalance != 990 {
|
||||
t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance)
|
||||
if _, ok := duplicateUpd.(*tg.Updates); !ok {
|
||||
t.Fatalf("subscriber replay = %T, want *tg.Updates", duplicateUpd)
|
||||
}
|
||||
|
||||
// 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id
|
||||
|
|
@ -435,8 +414,10 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
|
|||
if _, ok := top.Media.(*tg.MessageMediaContact); !ok {
|
||||
t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media)
|
||||
}
|
||||
if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 {
|
||||
t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok)
|
||||
// telesrv has no Stars economy: sends are always free regardless of the
|
||||
// channel's configured price, so paid_message_stars is never set.
|
||||
if paid, ok := top.GetPaidMessageStars(); ok || paid != 0 {
|
||||
t.Fatalf("media paid_message_stars = %d/%v, want 0/false", paid, ok)
|
||||
}
|
||||
topSuggested, ok := top.GetSuggestedPost()
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// channelPaidReactionService 是 r.deps.Channels 的可选扩展(app/channels.Service 实现),
|
||||
// 用类型断言接入,避免在 ChannelsService 接口上为付费 reaction 单加方法。
|
||||
type channelPaidReactionService interface {
|
||||
SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error)
|
||||
}
|
||||
|
||||
// channelPaidReactionUpdates 为某 viewer 构造一条 updateMessageReactions:把消息已有的普通
|
||||
// reaction 与付费 ReactionPaid(总星数 + top reactors)合并。非请求者走 min 语义(置 Min,
|
||||
// 客户端忽略 chosen 保留本地态),避免把请求者视角串给他人。
|
||||
func (r *Router) channelPaidReactionUpdates(ctx context.Context, requestUserID, viewerUserID int64, res domain.ChannelMessagePaidReactionResult, ids []int) *tg.Updates {
|
||||
isRequester := viewerUserID == requestUserID
|
||||
base := domain.ChannelMessageReactions{}
|
||||
if res.Message.Reactions != nil {
|
||||
base = *res.Message.Reactions
|
||||
}
|
||||
paid := clonePaidForViewer(res.Paid, isRequester)
|
||||
base.Paid = &paid
|
||||
mr := tgMessageReactions(viewerUserID, &base)
|
||||
if mr == nil {
|
||||
mr = &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
}
|
||||
if !isRequester {
|
||||
mr.Min = true
|
||||
}
|
||||
msgID := res.Message.ID
|
||||
if msgID == 0 && len(ids) > 0 {
|
||||
msgID = ids[0]
|
||||
}
|
||||
update := &tg.UpdateMessageReactions{
|
||||
Peer: &tg.PeerChannel{ChannelID: res.Channel.ID},
|
||||
MsgID: msgID,
|
||||
Reactions: *mr,
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update},
|
||||
Users: r.tgUsersForIDs(ctx, viewerUserID, paidReactorUserIDs(res.Paid)),
|
||||
Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// clonePaidForViewer 返回付费聚合的副本;非请求者抹除 My/MyStars(min 语义防串视角)。
|
||||
func clonePaidForViewer(paid domain.ChannelMessagePaidReactions, isRequester bool) domain.ChannelMessagePaidReactions {
|
||||
out := paid
|
||||
out.TopReactors = make([]domain.PaidReactor, len(paid.TopReactors))
|
||||
copy(out.TopReactors, paid.TopReactors)
|
||||
if !isRequester {
|
||||
out.MyStars = 0
|
||||
out.MyAnonymous = false
|
||||
for i := range out.TopReactors {
|
||||
out.TopReactors[i].My = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectPaidReaction 把付费 reaction 注入 MessageReactions:ReactionPaid 计数置首位、填充
|
||||
// top reactors 排行。My/chosen 完全由 paid 的视角数据驱动(调用方对他人视角已抹除 My/MyStars)。
|
||||
func injectPaidReaction(mr *tg.MessageReactions, paid domain.ChannelMessagePaidReactions) {
|
||||
if paid.TotalStars <= 0 {
|
||||
return
|
||||
}
|
||||
paidCount := tg.ReactionCount{Reaction: &tg.ReactionPaid{}, Count: int(paid.TotalStars)}
|
||||
if paid.MyStars > 0 {
|
||||
// chosen_order 仅标记「本人已投」,具体序值不关键,置正。
|
||||
paidCount.SetChosenOrder(int(paid.MyStars))
|
||||
}
|
||||
mr.Results = append([]tg.ReactionCount{paidCount}, mr.Results...)
|
||||
|
||||
reactors := make([]tg.MessageReactor, 0, len(paid.TopReactors))
|
||||
for _, rr := range paid.TopReactors {
|
||||
item := tg.MessageReactor{Count: int(rr.Stars)}
|
||||
if rr.My {
|
||||
item.My = true
|
||||
} else {
|
||||
item.Top = true
|
||||
}
|
||||
if rr.Anonymous {
|
||||
item.Anonymous = true
|
||||
}
|
||||
// PeerID:非匿名给出;匿名仅本人给出(TL 语义:匿名本人 anonymous+peer 都置)。
|
||||
if rr.UserID != 0 && (!rr.Anonymous || rr.My) {
|
||||
item.SetPeerID(&tg.PeerUser{UserID: rr.UserID})
|
||||
}
|
||||
reactors = append(reactors, item)
|
||||
}
|
||||
if len(reactors) > 0 {
|
||||
mr.SetTopReactors(reactors)
|
||||
}
|
||||
}
|
||||
|
||||
// paidReactorUserIDs 收集非匿名 reactor 的用户 id,供 Updates.Users 富化头像。
|
||||
func paidReactorUserIDs(paid domain.ChannelMessagePaidReactions) []int64 {
|
||||
ids := make([]int64, 0, len(paid.TopReactors))
|
||||
for _, rr := range paid.TopReactors {
|
||||
if rr.UserID != 0 && !rr.Anonymous {
|
||||
ids = append(ids, rr.UserID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// injectPaidReaction 请求者视角:ReactionPaid 置首、计数=总星、chosen 置位;
|
||||
// TopReactors 含本人带 My + PeerID。
|
||||
func TestInjectPaidReactionRequesterView(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 150,
|
||||
MyStars: 50,
|
||||
TopReactors: []domain.PaidReactor{
|
||||
{UserID: 2, Stars: 100, My: false},
|
||||
{UserID: 1, Stars: 50, My: true},
|
||||
},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, true))
|
||||
|
||||
if len(mr.Results) != 1 {
|
||||
t.Fatalf("results = %d, want 1 paid", len(mr.Results))
|
||||
}
|
||||
if _, ok := mr.Results[0].Reaction.(*tg.ReactionPaid); !ok {
|
||||
t.Fatalf("results[0] reaction = %T, want *tg.ReactionPaid", mr.Results[0].Reaction)
|
||||
}
|
||||
if mr.Results[0].Count != 150 {
|
||||
t.Fatalf("paid count = %d, want 150", mr.Results[0].Count)
|
||||
}
|
||||
if order, ok := mr.Results[0].GetChosenOrder(); !ok || order <= 0 {
|
||||
t.Fatalf("requester chosen = %d ok %v, want set+positive (MyStars>0)", order, ok)
|
||||
}
|
||||
reactors, ok := mr.GetTopReactors()
|
||||
if !ok || len(reactors) != 2 {
|
||||
t.Fatalf("top reactors = %d ok %v, want 2", len(reactors), ok)
|
||||
}
|
||||
// 第二条是本人。
|
||||
me := reactors[1]
|
||||
if !me.My || me.Count != 50 {
|
||||
t.Fatalf("my reactor = %+v, want My count 50", me)
|
||||
}
|
||||
if peer, ok := me.GetPeerID(); !ok {
|
||||
t.Fatalf("my reactor peer = %v, want set", peer)
|
||||
}
|
||||
// 第一条是 top(非本人)。
|
||||
if !reactors[0].Top || reactors[0].My {
|
||||
t.Fatalf("top reactor = %+v, want Top not My", reactors[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 非请求者视角:无 chosen、My 一律抹去(min 语义防串视角)。
|
||||
func TestInjectPaidReactionOtherViewerScrubsMy(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 150,
|
||||
MyStars: 50,
|
||||
TopReactors: []domain.PaidReactor{
|
||||
{UserID: 2, Stars: 100, My: false},
|
||||
{UserID: 1, Stars: 50, My: true},
|
||||
},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, false))
|
||||
|
||||
if _, ok := mr.Results[0].GetChosenOrder(); ok {
|
||||
t.Fatalf("non-requester chosen must be unset")
|
||||
}
|
||||
reactors, _ := mr.GetTopReactors()
|
||||
for i, rr := range reactors {
|
||||
if rr.My {
|
||||
t.Fatalf("reactor[%d] My must be scrubbed for non-requester: %+v", i, rr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 匿名 reactor(非本人):Anonymous 置位、不暴露 PeerID。
|
||||
func TestInjectPaidReactionAnonymousHidesPeer(t *testing.T) {
|
||||
paid := domain.ChannelMessagePaidReactions{
|
||||
TotalStars: 100,
|
||||
TopReactors: []domain.PaidReactor{{UserID: 9, Stars: 100, Anonymous: true, My: false}},
|
||||
}
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, clonePaidForViewer(paid, true))
|
||||
reactors, ok := mr.GetTopReactors()
|
||||
if !ok || len(reactors) != 1 {
|
||||
t.Fatalf("reactors = %d ok %v, want 1", len(reactors), ok)
|
||||
}
|
||||
if !reactors[0].Anonymous {
|
||||
t.Fatalf("reactor must be Anonymous: %+v", reactors[0])
|
||||
}
|
||||
if peer, ok := reactors[0].GetPeerID(); ok {
|
||||
t.Fatalf("anonymous non-self reactor must not expose peer, got %v", peer)
|
||||
}
|
||||
}
|
||||
|
||||
// 空付费态:不注入任何 ReactionPaid。
|
||||
func TestInjectPaidReactionEmpty(t *testing.T) {
|
||||
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
|
||||
injectPaidReaction(mr, domain.ChannelMessagePaidReactions{})
|
||||
if len(mr.Results) != 0 {
|
||||
t.Fatalf("empty paid must inject nothing, got %d results", len(mr.Results))
|
||||
}
|
||||
if _, ok := mr.GetTopReactors(); ok {
|
||||
t.Fatalf("empty paid must not set top reactors")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -60,179 +59,3 @@ func (r *Router) validateDefaultReaction(ctx context.Context, reaction domain.Me
|
|||
}
|
||||
return reactionInvalidErr()
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
settings := domain.DefaultAccountReactionSettings()
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
next, err := svc.GetReactionSettings(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
settings = next
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{
|
||||
Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy),
|
||||
}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) {
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return false, messageIDInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
return false, err
|
||||
}
|
||||
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
privacy = next.PaidPrivacy
|
||||
}
|
||||
r.pushUserUpdates(ctx, userID, &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// onMessagesSendPaidReaction 为一条广播频道消息发送付费 reaction:从 Stars 账本 Debit
|
||||
// req.Count 星,累计到消息上,返回带 updateMessageReactions(含 ReactionPaid 总星数 +
|
||||
// top reactors)与 updateStarsBalance 的 Updates。崩溃约束:必须返回合法 Updates——
|
||||
// DrKLO StarsController 对响应无 instanceof 强转 (TLRPC.Updates)。
|
||||
func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) {
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if req.Count <= 0 || req.Count > domain.MaxPaidReactionStarsPerRequest {
|
||||
return nil, starsAmountInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 付费 reaction 仅用于(广播)频道帖子。
|
||||
if peer.Type != domain.PeerTypeChannel {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
anonymous, err := r.resolvePaidReactionAnonymous(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return nil, balanceTooLowErr()
|
||||
}
|
||||
paidSvc, ok := r.deps.Channels.(channelPaidReactionService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: peer.ID}
|
||||
// 1. 先从账本 Debit(余额不足→真实 BALANCE_TOO_LOW)。
|
||||
balance, err := r.deps.Stars.Debit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction", "")
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
// 2. 累计付费 reaction;失败则补偿退款。
|
||||
res, err := paidSvc.SendPaidReaction(ctx, userID, domain.SendChannelPaidReactionRequest{
|
||||
UserID: userID,
|
||||
ChannelID: peer.ID,
|
||||
MessageID: req.MsgID,
|
||||
Stars: int64(req.Count),
|
||||
Anonymous: anonymous,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
if _, refundErr := r.deps.Stars.Credit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction refund", ""); refundErr != nil {
|
||||
r.log.Error("paid reaction refund failed after record error",
|
||||
zap.Int64("user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("msg_id", req.MsgID), zap.Error(refundErr))
|
||||
}
|
||||
return nil, channelReactionErr(err)
|
||||
}
|
||||
// 3. 构建并扇出 updateMessageReactions;请求者额外带 updateStarsBalance。
|
||||
ids := []int{res.Message.ID}
|
||||
build := func(viewerUserID int64) *tg.Updates {
|
||||
updates := r.channelPaidReactionUpdates(ctx, userID, viewerUserID, res, ids)
|
||||
if updates != nil && viewerUserID == userID {
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
recipients := append([]int64{res.Message.SenderUserID}, res.Recipients...)
|
||||
r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, recipients, build)
|
||||
return build(userID), nil
|
||||
}
|
||||
|
||||
// resolvePaidReactionAnonymous 计算本次付费 reaction 是否匿名:显式 private 优先,
|
||||
// 缺省回退用户保存的默认付费 reaction 隐私。
|
||||
func (r *Router) resolvePaidReactionAnonymous(ctx context.Context, userID int64, req *tg.MessagesSendPaidReactionRequest) (bool, error) {
|
||||
if private, ok := req.GetPrivate(); ok {
|
||||
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, private)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return privacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
|
||||
}
|
||||
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
|
||||
if settings, err := svc.GetReactionSettings(ctx, userID); err == nil {
|
||||
return settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) {
|
||||
switch typed := in.(type) {
|
||||
case nil, *tg.PaidReactionPrivacyDefault:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil
|
||||
case *tg.PaidReactionPrivacyAnonymous:
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil
|
||||
case *tg.PaidReactionPrivacyPeer:
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer)
|
||||
if err != nil {
|
||||
return domain.PaidReactionPrivacy{}, err
|
||||
}
|
||||
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil
|
||||
default:
|
||||
return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass {
|
||||
switch in.Kind {
|
||||
case domain.PaidReactionPrivacyAnonymous:
|
||||
return &tg.PaidReactionPrivacyAnonymous{}
|
||||
case domain.PaidReactionPrivacyPeer:
|
||||
if in.Peer == nil {
|
||||
return &tg.PaidReactionPrivacyDefault{}
|
||||
}
|
||||
if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil {
|
||||
return &tg.PaidReactionPrivacyPeer{Peer: peer}
|
||||
}
|
||||
}
|
||||
return &tg.PaidReactionPrivacyDefault{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -903,15 +903,6 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
return r.onMessagesSetDefaultReaction(ctx, layerRequest.
|
||||
Reaction)
|
||||
})
|
||||
registerRPC[*tg.MessagesGetPaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesGetPaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesGetPaidReactionPrivacyRequest) (any, error) {
|
||||
return r.onMessagesGetPaidReactionPrivacy(ctx)
|
||||
})
|
||||
registerRPC[*tg.MessagesTogglePaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesTogglePaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesTogglePaidReactionPrivacyRequest) (any, error) {
|
||||
return r.onMessagesTogglePaidReactionPrivacy(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesSendPaidReactionRequest](d, tlprofile.SemanticMethodMessagesSendPaidReaction, func(ctx context.Context, layerRequest *tg.MessagesSendPaidReactionRequest) (any, error) {
|
||||
return r.onMessagesSendPaidReaction(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesDeleteParticipantReactionsRequest](d, tlprofile.SemanticMethodMessagesDeleteParticipantReactions, func(ctx context.Context, layerRequest *tg.MessagesDeleteParticipantReactionsRequest) (any, error) {
|
||||
return r.onMessagesDeleteParticipantReactions(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -258,12 +258,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
}
|
||||
|
||||
func messageSendErr(err error) error {
|
||||
var paymentRequired *domain.StarsPaymentRequiredError
|
||||
switch {
|
||||
case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0:
|
||||
return allowPaymentRequiredErr(paymentRequired.Stars)
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return balanceTooLowErr()
|
||||
case errors.Is(err, domain.ErrUserFrozen):
|
||||
return frozenMethodInvalidErr()
|
||||
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
|
||||
|
|
@ -507,9 +502,6 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
|
|||
Pts: event.Pts,
|
||||
PtsCount: event.PtsCount,
|
||||
})
|
||||
if balance := tgGiftStarsBalanceUpdate(msg); balance != nil {
|
||||
updates = append(updates, balance)
|
||||
}
|
||||
date := event.Date
|
||||
if date == 0 {
|
||||
date = msg.Date
|
||||
|
|
@ -523,18 +515,6 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
|
|||
}
|
||||
}
|
||||
|
||||
func tgGiftStarsBalanceUpdate(msg domain.Message) tg.UpdateClass {
|
||||
if msg.Out || msg.Media == nil || msg.Media.ServiceAction == nil ||
|
||||
msg.Media.ServiceAction.Kind != domain.MessageServiceActionGiftStars {
|
||||
return nil
|
||||
}
|
||||
action := msg.Media.ServiceAction.GiftStars
|
||||
if action == nil || action.BalanceAfter < 0 {
|
||||
return nil
|
||||
}
|
||||
return &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: action.BalanceAfter}}
|
||||
}
|
||||
|
||||
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
|
||||
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
|
||||
// transition its local pending message to SENT. Visible edited messages use the
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID
|
|||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
|
||||
}
|
||||
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
|
||||
if result.Parent.ID != 0 {
|
||||
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
|
||||
|
|
|
|||
|
|
@ -196,35 +196,6 @@ func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPaymentsGetStarGiftCollectionsNoServiceFallbackAndValidatesPeer(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
var okReq bin.Buffer
|
||||
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}).Encode(&okReq); err != nil {
|
||||
t.Fatalf("encode request: %v", err)
|
||||
}
|
||||
got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
collections, ok := got.(*tg.PaymentsStarGiftCollections)
|
||||
if !ok {
|
||||
t.Fatalf("response type = %T, want *tg.PaymentsStarGiftCollections", got)
|
||||
}
|
||||
if len(collections.Collections) != 0 {
|
||||
t.Fatalf("collections = %+v, want empty list", collections.Collections)
|
||||
}
|
||||
|
||||
var badReq bin.Buffer
|
||||
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil {
|
||||
t.Fatalf("encode bad request: %v", err)
|
||||
}
|
||||
if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") {
|
||||
t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t *testing.T) {
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
|
||||
// gift/auction/revenue 第一阶段兼容桩。
|
||||
// registerPayments 注册 payments.* RPC。telesrv 不实现 Stars/Star Gift 经济:
|
||||
// 大部分曾经的 Stars/gift 动作 RPC 已不再注册;仍注册的几个只读状态 RPC
|
||||
// (getStarsStatus/Subscriptions/Transactions/RevenueStats)返回固定空值,
|
||||
// 因为部分客户端界面会无条件加载它们——错误返回会导致界面卡死或崩溃,
|
||||
// 空值则让界面正常渲染成"没有 Stars"。
|
||||
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) {
|
||||
return r.onPaymentsCanPurchaseStore(ctx, req)
|
||||
|
|
@ -22,24 +22,6 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsAssignPlayMarketTransactionRequest](d, tlprofile.SemanticMethodPaymentsAssignPlayMarketTransaction, func(ctx context.Context, req *tg.PaymentsAssignPlayMarketTransactionRequest) (any, error) {
|
||||
return r.onPaymentsAssignPlayMarketTransaction(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiftOptions(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiveawayOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiveawayOptions, func(ctx context.Context, _ *tg.PaymentsGetStarsGiveawayOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiveawayOptions(ctx)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetGiveawayInfoRequest](d, tlprofile.SemanticMethodPaymentsGetGiveawayInfo, func(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (any, error) {
|
||||
return r.onPaymentsGetGiveawayInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
|
||||
|
||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
||||
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
|
||||
// premiumGiftsReady=true,否则整框不弹出——此前返 NOT_IMPLEMENTED 导致点生日礼物无反应。
|
||||
// 空列表即解门,星礼物正常发送;premium 区段另由 userFull.disallow_premium_gifts=true 隐藏。
|
||||
error) {
|
||||
return devStarsTopupOptions(), nil
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetPremiumGiftCodeOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetPremiumGiftCodeOptions, func(ctx context.Context, req *tg.PaymentsGetPremiumGiftCodeOptionsRequest) (any, error) {
|
||||
return []tg.PremiumGiftCodeOption{}, nil
|
||||
})
|
||||
|
|
@ -52,110 +34,6 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) {
|
||||
return r.onPaymentsCheckCanSendGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGifts(ctx, layerRequest.
|
||||
Hash)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftUpgradePreviewRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradePreview, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradePreviewRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
|
||||
GiftID)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
|
||||
Slug)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) {
|
||||
return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetResaleStarGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
|
||||
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsValidateRequestedInfoRequest](d, tlprofile.SemanticMethodPaymentsValidateRequestedInfo, func(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (any, error) {
|
||||
return r.onPaymentsValidateRequestedInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
|
||||
return r.onPaymentsSendStarsForm(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsSendPaymentForm, func(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (any, error) {
|
||||
return r.onPaymentsSendPaymentForm(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetSavedStarGifts(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetSavedStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsGetSavedStarGift(ctx, layerRequest.
|
||||
Stargift)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSaveStarGiftRequest](d, tlprofile.SemanticMethodPaymentsSaveStarGift, func(ctx context.Context, layerRequest *tg.PaymentsSaveStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsSaveStarGift(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsConvertStarGiftRequest](d, tlprofile.SemanticMethodPaymentsConvertStarGift, func(ctx context.Context, layerRequest *tg.PaymentsConvertStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsConvertStarGift(ctx, layerRequest.
|
||||
Stargift)
|
||||
})
|
||||
registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsUpgradeStarGift(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) {
|
||||
return r.onPaymentsUpdateStarGiftPrice(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsTransferStarGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) {
|
||||
return r.onPaymentsSendStarGiftOffer(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) {
|
||||
return r.onPaymentsResolveStarGiftOffer(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetCraftStarGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) {
|
||||
return r.onPaymentsCraftStarGift(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftAuctionState(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) {
|
||||
return r.onPaymentsToggleChatStarGiftNotifications(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsCreateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsCreateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsCreateStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsCreateStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsUpdateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsUpdateStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsUpdateStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsDeleteStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsDeleteStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsDeleteStarGiftCollectionRequest) (any, error) {
|
||||
return r.onPaymentsDeleteStarGiftCollection(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsReorderStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsReorderStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsReorderStarGiftCollectionsRequest) (any, error) {
|
||||
return r.onPaymentsReorderStarGiftCollections(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsToggleStarGiftsPinnedToTopRequest](d, tlprofile.SemanticMethodPaymentsToggleStarGiftsPinnedToTop, func(ctx context.Context, layerRequest *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (any, error) {
|
||||
return r.onPaymentsToggleStarGiftsPinnedToTop(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsRevenueAdsAccountURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueAdsAccountURL, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsRevenueAdsAccountURLRequest) (any, error) {
|
||||
peer := layerRequest.
|
||||
Peer
|
||||
|
|
@ -194,10 +72,9 @@ func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *t
|
|||
return nil, tgerr.New(400, "STORE_PAYMENT_UNAVAILABLE")
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
|
||||
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
|
||||
// and bot revenue remain the bounded compatibility response because their
|
||||
// revenue bucket is distinct from the general Stars balance and is not modeled.
|
||||
// onPaymentsGetStarsRevenueStats: telesrv has no Stars/gift economy, so every
|
||||
// peer gets the same zero-balance compatibility response (no channel ledger
|
||||
// branch left to read from).
|
||||
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -206,319 +83,43 @@ func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.Pay
|
|||
if req == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ton := req.GetTon()
|
||||
if owner.Type != domain.PeerTypeChannel {
|
||||
return tdesktop.StarsRevenueStats(ton), nil
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
return tdesktop.StarsRevenueStats(ton), nil
|
||||
}
|
||||
var balance int64
|
||||
if ton {
|
||||
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
|
||||
} else {
|
||||
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return tdesktop.StarsRevenueStats(req.GetTon()), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsStatus 无 Stars 账本,恒返回零余额(响应仍是合法的
|
||||
// payments.starsStatus——两端客户端无条件读取 balance 字段)。
|
||||
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
stats := tdesktop.StarsRevenueStats(ton)
|
||||
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
|
||||
if ton {
|
||||
amount = &tg.StarsTonAmount{Amount: balance}
|
||||
if req != nil && req.GetTon() {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
// Channel ledgers currently only receive collectible conversion/marketplace
|
||||
// proceeds and have no withdrawal/debit path, so balance equals lifetime
|
||||
// revenue. Withdrawal stays disabled because no external payout exists.
|
||||
stats.Status.CurrentBalance = amount
|
||||
stats.Status.AvailableBalance = amount
|
||||
stats.Status.OverallRevenue = amount
|
||||
return stats, nil
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
type channelGiftLedgerReader interface {
|
||||
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
|
||||
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
|
||||
ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本
|
||||
// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。
|
||||
// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
|
||||
// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。
|
||||
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
userID, owner, err := r.starGiftLedgerOwner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ton := req != nil && req.GetTon()
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
if ton {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
var balance int64
|
||||
if ton {
|
||||
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
|
||||
} else {
|
||||
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
|
||||
if ton {
|
||||
amount = &tg.StarsTonAmount{Amount: balance}
|
||||
}
|
||||
out := emptyStarsStatus(amount)
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID})
|
||||
return out, nil
|
||||
}
|
||||
if ton {
|
||||
if r.deps.Gifts == nil {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
balance, err := r.deps.Gifts.TonBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
bal, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsSubscriptions returns the authoritative current balance
|
||||
// with an empty subscription page. telesrv does not create recurring Stars
|
||||
// subscriptions yet; returning a well-shaped terminal page lets both official
|
||||
// clients finish loading the Stars screen without inventing subscription state.
|
||||
// onPaymentsGetStarsSubscriptions returns a zero balance and no subscriptions
|
||||
// — telesrv never creates recurring Stars subscriptions.
|
||||
func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
return nil, inputRequestInvalidErr()
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
userID, owner, err := r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner.Type != domain.PeerTypeUser || owner.ID != userID {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
balance, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{Amount: balance.Balance}), nil
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
|
||||
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
|
||||
// onPaymentsGetStarsTransactions 无 Stars 账本,恒返回零余额、空流水(不设置
|
||||
// next_offset,避免客户端无限翻页)。
|
||||
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
query, err := starsTransactionQuery(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if req != nil && req.GetTon() {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
ton := req != nil && req.GetTon()
|
||||
if owner.Type == domain.PeerTypeChannel {
|
||||
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
|
||||
if !ok {
|
||||
if ton {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
if ton {
|
||||
page, err := ledger.ChannelTonTransactions(ctx, owner.ID, query)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
|
||||
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
|
||||
return out, nil
|
||||
}
|
||||
page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, query)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
|
||||
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
|
||||
return out, nil
|
||||
}
|
||||
if ton {
|
||||
if r.deps.Gifts == nil {
|
||||
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
|
||||
}
|
||||
page, err := r.deps.Gifts.TonTransactions(ctx, userID, query)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
|
||||
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
ids := make([]int64, 0)
|
||||
for _, txn := range page.Transactions {
|
||||
if txn.Peer.Type == domain.PeerTypeUser {
|
||||
ids = append(ids, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids)))
|
||||
return out, nil
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
page, err := r.deps.Stars.ListTransactions(ctx, userID, query)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
|
||||
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
|
||||
out.SetHistory(txns)
|
||||
}
|
||||
if page.NextOffset != "" {
|
||||
out.SetNextOffset(page.NextOffset)
|
||||
}
|
||||
// 富化流水中提到的用户对手方(频道对手方进 Chats 留待 paid reaction 阶段)。
|
||||
if ids := starsTransactionUserIDs(page.Transactions); len(ids) > 0 {
|
||||
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, ids))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func starsTransactionQuery(req *tg.PaymentsGetStarsTransactionsRequest) (domain.StarsTransactionQuery, error) {
|
||||
if req == nil {
|
||||
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
|
||||
}
|
||||
inbound, outbound := req.GetInbound(), req.GetOutbound()
|
||||
if inbound && outbound {
|
||||
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
|
||||
}
|
||||
if _, ok := req.GetSubscriptionID(); ok {
|
||||
// Stars subscriptions are not part of the current business model. Do not
|
||||
// silently return the unfiltered ledger for a requested subscription.
|
||||
return domain.StarsTransactionQuery{}, subscriptionIDInvalidErr()
|
||||
}
|
||||
direction := domain.StarsTransactionDirectionAll
|
||||
if inbound {
|
||||
direction = domain.StarsTransactionDirectionIncoming
|
||||
} else if outbound {
|
||||
direction = domain.StarsTransactionDirectionOutgoing
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
return domain.StarsTransactionQuery{
|
||||
Offset: req.Offset,
|
||||
Limit: limit,
|
||||
Direction: direction,
|
||||
Ascending: req.GetAscending(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) {
|
||||
if req == nil {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
}
|
||||
|
||||
func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) {
|
||||
if req == nil {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
}
|
||||
|
||||
func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return 0, domain.Peer{}, internalErr()
|
||||
}
|
||||
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
if err != nil {
|
||||
return 0, domain.Peer{}, err
|
||||
}
|
||||
if owner.Type == domain.PeerTypeUser {
|
||||
if owner.ID != userID {
|
||||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
return userID, owner, nil
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
|
||||
return 0, domain.Peer{}, err
|
||||
}
|
||||
return userID, owner, nil
|
||||
}
|
||||
|
||||
func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) {
|
||||
userIDs := make([]int64, 0, len(txns))
|
||||
channelIDs := []int64{ownerChannelID}
|
||||
for _, txn := range txns {
|
||||
switch txn.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userIDs = append(userIDs, txn.Peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
|
||||
}
|
||||
|
||||
func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) {
|
||||
userIDs := make([]int64, 0, len(txns))
|
||||
channelIDs := []int64{ownerChannelID}
|
||||
for _, txn := range txns {
|
||||
switch txn.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userIDs = append(userIDs, txn.Peer.ID)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, txn.Peer.ID)
|
||||
}
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
|
||||
// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。
|
||||
|
|
@ -529,119 +130,3 @@ func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
|
|||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
// tgStarsTransactions 把账本流水投影为 tg.StarsTransaction(amount 带符号:借记为负)。
|
||||
func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
|
||||
out := make([]tg.StarsTransaction, 0, len(in))
|
||||
for _, t := range in {
|
||||
item := tg.StarsTransaction{
|
||||
ID: strconv.FormatInt(t.ID, 10),
|
||||
Amount: &tg.StarsAmount{Amount: t.Amount},
|
||||
Date: t.Date,
|
||||
Peer: tgStarsTransactionPeer(t),
|
||||
}
|
||||
if t.Title != "" {
|
||||
item.SetTitle(t.Title)
|
||||
}
|
||||
if t.Description != "" {
|
||||
item.SetDescription(t.Description)
|
||||
}
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonReaction:
|
||||
item.Reaction = true
|
||||
case domain.StarsReasonPaidMessage:
|
||||
item.SetPaidMessages(1)
|
||||
case domain.StarsReasonGift:
|
||||
item.Gift = true
|
||||
case domain.StarsReasonGiftUpgrade:
|
||||
item.StargiftUpgrade = true
|
||||
case domain.StarsReasonGiftResale:
|
||||
item.StargiftResale = true
|
||||
case domain.StarsReasonGiftPrepaid:
|
||||
item.StargiftPrepaidUpgrade = true
|
||||
case domain.StarsReasonGiftDrop:
|
||||
item.StargiftDropOriginalDetails = true
|
||||
case domain.StarsReasonGiftAuction:
|
||||
item.StargiftAuctionBid = true
|
||||
case domain.StarsReasonGiftOffer:
|
||||
item.Offer = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction {
|
||||
out := make([]tg.StarsTransaction, 0, len(in))
|
||||
for _, t := range in {
|
||||
item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount},
|
||||
Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})}
|
||||
if t.Amount > 0 {
|
||||
item.Refund = true
|
||||
}
|
||||
if t.Title != "" {
|
||||
item.SetTitle(t.Title)
|
||||
}
|
||||
if t.Description != "" {
|
||||
item.SetDescription(t.Description)
|
||||
}
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonGiftResale:
|
||||
item.StargiftResale = true
|
||||
case domain.StarsReasonGiftOffer:
|
||||
item.Offer = true
|
||||
case domain.StarsReasonGiftAuction:
|
||||
item.StargiftAuctionBid = true
|
||||
case domain.StarsReasonGiftPrepaid:
|
||||
item.StargiftPrepaidUpgrade = true
|
||||
case domain.StarsReasonGiftDrop:
|
||||
item.StargiftDropOriginalDetails = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tgStarsTransactionPeer 选择对手方构造器:grant/topup 走 Fragment(站外充值轨),
|
||||
// 真实 peer 走 starsTransactionPeer,其余兜底 Unsupported(Peer 字段必填,不可为 nil)。
|
||||
func tgStarsTransactionPeer(t domain.StarsTransaction) tg.StarsTransactionPeerClass {
|
||||
switch t.Reason {
|
||||
case domain.StarsReasonGrant, domain.StarsReasonTopup:
|
||||
return &tg.StarsTransactionPeerFragment{}
|
||||
}
|
||||
if t.Peer.Type != "" && t.Peer.ID != 0 {
|
||||
if p := tgPeer(t.Peer); p != nil {
|
||||
return &tg.StarsTransactionPeer{Peer: p}
|
||||
}
|
||||
}
|
||||
return &tg.StarsTransactionPeerUnsupported{}
|
||||
}
|
||||
|
||||
// starsTransactionUserIDs 收集流水中去重的用户类对手方 id。
|
||||
func starsTransactionUserIDs(in []domain.StarsTransaction) []int64 {
|
||||
seen := make(map[int64]struct{}, len(in))
|
||||
ids := make([]int64, 0, len(in))
|
||||
for _, t := range in {
|
||||
if t.Peer.Type != domain.PeerTypeUser || t.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[t.Peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[t.Peer.ID] = struct{}{}
|
||||
ids = append(ids, t.Peer.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// starsErr 把 Stars 账本领域错误映射为客户端可识别的 tgerr(仿 premiumBoostErr)。
|
||||
func starsErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return balanceTooLowErr()
|
||||
case errors.Is(err, domain.ErrStarsInvalidAmount):
|
||||
return starsAmountInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) {
|
||||
base := domain.StarGift{
|
||||
ID: 8001,
|
||||
RevisionID: 9001,
|
||||
Stars: 100,
|
||||
ConvertStars: 85,
|
||||
Title: "Fresh Socks",
|
||||
FirstSaleDate: 100,
|
||||
LastSaleDate: 200,
|
||||
Sticker: domain.Document{
|
||||
ID: 700,
|
||||
AccessHash: 7,
|
||||
DCID: 2,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
gift domain.StarGift
|
||||
wantSoldOut bool
|
||||
wantSaleDate bool
|
||||
}{
|
||||
{name: "unlimited live gift with operational sale history", gift: base},
|
||||
{name: "limited live gift", gift: func() domain.StarGift {
|
||||
gift := base
|
||||
gift.Limited = true
|
||||
gift.AvailabilityRemains = 9
|
||||
gift.AvailabilityTotal = 10
|
||||
return gift
|
||||
}()},
|
||||
{name: "sold out gift", gift: func() domain.StarGift {
|
||||
gift := base
|
||||
gift.Limited = true
|
||||
gift.SoldOut = true
|
||||
gift.AvailabilityTotal = 10
|
||||
return gift
|
||||
}(), wantSoldOut: true, wantSaleDate: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for _, profile := range []tlprofile.Profile{
|
||||
tlprofile.Profile225,
|
||||
tlprofile.Profile226,
|
||||
tlprofile.Profile227,
|
||||
tlprofile.Profile228,
|
||||
} {
|
||||
response := &tg.PaymentsStarGifts{
|
||||
Hash: 1,
|
||||
Gifts: []tg.StarGiftClass{tgStarGift(test.gift)},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, response, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d catalog: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d catalog: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.PaymentsStarGifts)
|
||||
if !ok || len(decoded.Gifts) != 1 {
|
||||
t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject)
|
||||
}
|
||||
gift, ok := decoded.Gifts[0].(*tg.StarGift)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0])
|
||||
}
|
||||
if gift.SoldOut != test.wantSoldOut {
|
||||
t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut)
|
||||
}
|
||||
first, firstSet := gift.GetFirstSaleDate()
|
||||
last, lastSet := gift.GetLastSaleDate()
|
||||
if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate {
|
||||
t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate)
|
||||
}
|
||||
if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) {
|
||||
t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftCollections(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return &tg.PaymentsStarGiftCollections{Collections: []tg.StarGiftCollection{}}, nil
|
||||
}
|
||||
collections, err := r.deps.Gifts.ListCollections(ctx, owner)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
if req.Hash != 0 && req.Hash == domain.StarGiftCollectionsHash(collections) {
|
||||
return &tg.PaymentsStarGiftCollectionsNotModified{}, nil
|
||||
}
|
||||
return &tg.PaymentsStarGiftCollections{Collections: tgStarGiftCollections(collections)}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsCreateStarGiftCollection(ctx context.Context, req *tg.PaymentsCreateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collection, err := r.deps.Gifts.CreateCollection(ctx, owner, strings.TrimSpace(req.Title), ids)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
out := tgStarGiftCollection(collection)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsUpdateStarGiftCollection(ctx context.Context, req *tg.PaymentsUpdateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
|
||||
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := domain.StarGiftCollectionPatch{}
|
||||
if title, ok := req.GetTitle(); ok {
|
||||
title = strings.TrimSpace(title)
|
||||
patch.Title = &title
|
||||
}
|
||||
if refs, ok := req.GetDeleteStargift(); ok {
|
||||
patch.DeleteIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if refs, ok := req.GetAddStargift(); ok {
|
||||
patch.AddIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if refs, ok := req.GetOrder(); ok {
|
||||
patch.Order, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
collection, err := r.deps.Gifts.UpdateCollection(ctx, owner, req.CollectionID, patch)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
out := tgStarGiftCollection(collection)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsDeleteStarGiftCollection(ctx context.Context, req *tg.PaymentsDeleteStarGiftCollectionRequest) (bool, error) {
|
||||
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
deleted, err := r.deps.Gifts.DeleteCollection(ctx, owner, req.CollectionID)
|
||||
if err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
if !deleted {
|
||||
return false, starGiftCollectionErr(domain.ErrStarGiftCollectionNotFound)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsReorderStarGiftCollections(ctx context.Context, req *tg.PaymentsReorderStarGiftCollectionsRequest) (bool, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.deps.Gifts.ReorderCollections(ctx, owner, req.Order); err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsToggleStarGiftsPinnedToTop(ctx context.Context, req *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (bool, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return false, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
|
||||
return false, err
|
||||
}
|
||||
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.deps.Gifts.SetPinned(ctx, owner, ids); err != nil {
|
||||
return false, starGiftCollectionErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(owner)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) resolveStarGiftCollectionRefs(ctx context.Context, userID int64, owner domain.Peer, refs []tg.InputSavedStarGiftClass) ([]int64, error) {
|
||||
if len(refs) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
domainRefs := make([]domain.SavedStarGiftRef, 0, len(refs))
|
||||
for _, input := range refs {
|
||||
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || ref.Owner != owner {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
domainRefs = append(domainRefs, ref)
|
||||
}
|
||||
ids, err := r.deps.Gifts.ResolveSavedIDs(ctx, owner, domainRefs)
|
||||
if err != nil {
|
||||
return nil, starGiftCollectionErr(err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func tgStarGiftCollections(in []domain.StarGiftCollection) []tg.StarGiftCollection {
|
||||
out := make([]tg.StarGiftCollection, 0, len(in))
|
||||
for _, collection := range in {
|
||||
out = append(out, tgStarGiftCollection(collection))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftCollection(in domain.StarGiftCollection) tg.StarGiftCollection {
|
||||
return tg.StarGiftCollection{
|
||||
CollectionID: in.CollectionID,
|
||||
Title: in.Title,
|
||||
GiftsCount: len(in.GiftIDs),
|
||||
Hash: in.Hash,
|
||||
}
|
||||
}
|
||||
|
||||
func starGiftCollectionErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftCollectionNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftCollectionsFull):
|
||||
return inputRequestInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,454 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentFormClass, error) {
|
||||
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tg.PaymentsPaymentFormStarGift{
|
||||
FormID: starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails),
|
||||
Invoice: tg.Invoice{
|
||||
Currency: "XTR",
|
||||
Prices: []tg.LabeledPrice{{Label: "Star gift upgrade", Amount: preview.UpgradeStars}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
|
||||
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
chargeStars := int64(0)
|
||||
if replay {
|
||||
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid ||
|
||||
receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
chargeStars = receipt.ChargeStars
|
||||
} else {
|
||||
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
|
||||
if formID == 0 || formID != wantFormID {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
chargeStars = preview.UpgradeStars
|
||||
}
|
||||
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
|
||||
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
|
||||
FormID: formID, CommandKey: commandKey,
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starGiftUpgradeErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(saved.Owner)
|
||||
updates := r.tgStarGiftUpgradeUpdates(ctx, userID, result, true)
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.PaymentsUpgradeStarGiftRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil || r.deps.Gifts == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
|
||||
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if replay {
|
||||
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid ||
|
||||
receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
} else {
|
||||
if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if saved.PrepaidUpgradeStars <= 0 {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
}
|
||||
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
|
||||
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
|
||||
CommandKey: commandKey,
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starGiftUpgradeErr(err)
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(saved.Owner)
|
||||
return r.tgStarGiftUpgradeUpdates(ctx, userID, result, false), nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
|
||||
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
|
||||
}
|
||||
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
|
||||
return saved, preview, err
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) {
|
||||
if r.deps.Gifts == nil {
|
||||
return domain.SavedStarGift{}, notImplementedErr()
|
||||
}
|
||||
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.SavedStarGift{}, starGiftInvalidErr()
|
||||
}
|
||||
if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil {
|
||||
return domain.SavedStarGift{}, err
|
||||
}
|
||||
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
|
||||
if err != nil {
|
||||
return domain.SavedStarGift{}, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return domain.SavedStarGift{}, starGiftInvalidErr()
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) {
|
||||
if saved.Converted || saved.UniqueGiftID != 0 {
|
||||
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
|
||||
if err != nil {
|
||||
return domain.StarGiftUpgradePreview{}, internalErr()
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
|
||||
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef {
|
||||
ref := domain.SavedStarGiftRef{Owner: saved.Owner}
|
||||
if saved.Owner.Type == domain.PeerTypeChannel {
|
||||
ref.SavedID = saved.SavedID
|
||||
} else {
|
||||
ref.MsgID = saved.MsgID
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
|
||||
message, event := result.Send.RecipientMessage, result.Send.RecipientEvent
|
||||
if result.Send.SenderMessage.OwnerUserID == ownerUserID {
|
||||
message, event = result.Send.SenderMessage, result.Send.SenderEvent
|
||||
}
|
||||
updates := tgPrivateMessageUpdates(event, message, 0, false,
|
||||
r.usersForMessageUpdate(ctx, ownerUserID, message),
|
||||
r.chatsForMessageUpdate(ctx, ownerUserID, message))
|
||||
for _, edit := range result.SourceEdits {
|
||||
if edit.UserID != ownerUserID {
|
||||
continue
|
||||
}
|
||||
if update := tgOtherUpdateFromEvent(edit.Event); update != nil {
|
||||
updates.Updates = append(updates.Updates, update)
|
||||
if edit.Event.Date > updates.Date {
|
||||
updates.Date = edit.Event.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeBalance {
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
func starGiftUpgradeFormID(userID, savedGiftID, stars int64, keepOriginal bool) int64 {
|
||||
id := userID*0x9e3779b1 ^ savedGiftID<<11 ^ stars<<19 ^ 0x55504752414445
|
||||
if keepOriginal {
|
||||
id ^= 0x4b454550
|
||||
}
|
||||
if id < 0 {
|
||||
id = ^id
|
||||
}
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func starGiftUpgradeErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarsInsufficient):
|
||||
return starsErr(err)
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleSoldOut),
|
||||
errors.Is(err, domain.ErrStarGiftCollectibleInvalid):
|
||||
return starGiftInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func sessionIDOrZero(ctx context.Context) int64 {
|
||||
sessionID, _ := SessionIDFrom(ctx)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradePreview, error) {
|
||||
if giftID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || preview.Issued >= preview.SupplyTotal {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
return &tg.PaymentsStarGiftUpgradePreview{
|
||||
SampleAttributes: tgStarGiftPreviewAttributes(preview),
|
||||
Prices: []tg.StarGiftUpgradePrice{},
|
||||
NextPrices: []tg.StarGiftUpgradePrice{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) {
|
||||
if giftID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
|
||||
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
viewerUserID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
out := &tg.PaymentsUniqueStarGift{
|
||||
Gift: tgUniqueStarGift(unique),
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
switch unique.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
ids := []int64{unique.Owner.ID}
|
||||
if unique.KeepOriginalDetails && !unique.OriginalNameHidden && unique.OriginalFromUserID != 0 && unique.OriginalFromUserID != unique.Owner.ID {
|
||||
ids = append(ids, unique.OriginalFromUserID)
|
||||
}
|
||||
out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids))
|
||||
case domain.PeerTypeChannel:
|
||||
out.Chats = r.tgChatsForChannelIDs(ctx, viewerUserID, []int64{unique.Owner.ID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
|
||||
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
|
||||
for _, attribute := range preview.Models {
|
||||
if attribute.Crafted {
|
||||
continue
|
||||
}
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
for _, attribute := range preview.Patterns {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
for _, attribute := range preview.Backdrops {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
|
||||
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
|
||||
for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} {
|
||||
for _, attribute := range attributes {
|
||||
out = append(out, tgStarGiftAttribute(attribute))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
|
||||
rarity := tgStarGiftAttributeRarity(attribute)
|
||||
switch attribute.Kind {
|
||||
case domain.StarGiftCollectibleModel:
|
||||
document := tg.DocumentClass(&tg.DocumentEmpty{})
|
||||
if attribute.Document != nil {
|
||||
document = tgDocument(*attribute.Document)
|
||||
}
|
||||
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted}
|
||||
case domain.StarGiftCollectiblePattern:
|
||||
document := tg.DocumentClass(&tg.DocumentEmpty{})
|
||||
if attribute.Document != nil {
|
||||
document = tgDocument(*attribute.Document)
|
||||
}
|
||||
return &tg.StarGiftAttributePattern{Name: attribute.Name, Document: document, Rarity: rarity}
|
||||
case domain.StarGiftCollectibleBackdrop:
|
||||
return &tg.StarGiftAttributeBackdrop{
|
||||
Name: attribute.Name, BackdropID: attribute.BackdropID,
|
||||
CenterColor: attribute.CenterColor, EdgeColor: attribute.EdgeColor,
|
||||
PatternColor: attribute.PatternColor, TextColor: attribute.TextColor, Rarity: rarity,
|
||||
}
|
||||
default:
|
||||
return &tg.StarGiftAttributeBackdrop{Name: attribute.Name, Rarity: rarity}
|
||||
}
|
||||
}
|
||||
|
||||
func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass {
|
||||
switch attribute.RarityKind {
|
||||
case domain.StarGiftRarityUncommon:
|
||||
return &tg.StarGiftAttributeRarityUncommon{}
|
||||
case domain.StarGiftRarityRare:
|
||||
return &tg.StarGiftAttributeRarityRare{}
|
||||
case domain.StarGiftRarityEpic:
|
||||
return &tg.StarGiftAttributeRarityEpic{}
|
||||
case domain.StarGiftRarityLegendary:
|
||||
return &tg.StarGiftAttributeRarityLegendary{}
|
||||
default:
|
||||
return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
|
||||
}
|
||||
}
|
||||
|
||||
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
|
||||
attributes := []tg.StarGiftAttributeClass{
|
||||
tgStarGiftAttribute(unique.Model),
|
||||
tgStarGiftAttribute(unique.Pattern),
|
||||
tgStarGiftAttribute(unique.Backdrop),
|
||||
}
|
||||
if unique.KeepOriginalDetails && unique.OriginalOwner.ID != 0 {
|
||||
original := &tg.StarGiftAttributeOriginalDetails{
|
||||
RecipientID: tgPeer(unique.OriginalOwner),
|
||||
Date: unique.OriginalDate,
|
||||
}
|
||||
if unique.OriginalFromUserID != 0 && !unique.OriginalNameHidden {
|
||||
original.SetSenderID(&tg.PeerUser{UserID: unique.OriginalFromUserID})
|
||||
}
|
||||
if unique.OriginalMessage != "" {
|
||||
original.SetMessage(tg.TextWithEntities{Text: unique.OriginalMessage})
|
||||
}
|
||||
attributes = append(attributes, original)
|
||||
}
|
||||
out := &tg.StarGiftUnique{
|
||||
RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly,
|
||||
ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted,
|
||||
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
|
||||
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
|
||||
}
|
||||
if unique.OwnerAddress != "" {
|
||||
out.SetOwnerAddress(unique.OwnerAddress)
|
||||
} else if owner := tgPeer(unique.Owner); owner != nil {
|
||||
out.SetOwnerID(owner)
|
||||
} else if unique.OwnerName != "" {
|
||||
out.SetOwnerName(unique.OwnerName)
|
||||
}
|
||||
if unique.GiftAddress != "" {
|
||||
out.SetGiftAddress(unique.GiftAddress)
|
||||
}
|
||||
if unique.ResellAmount != nil {
|
||||
out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)})
|
||||
}
|
||||
if peer := tgPeer(unique.ReleasedBy); peer != nil {
|
||||
out.SetReleasedBy(peer)
|
||||
}
|
||||
if unique.ValueAmount > 0 {
|
||||
out.SetValueAmount(unique.ValueAmount)
|
||||
}
|
||||
if unique.ValueCurrency != "" {
|
||||
out.SetValueCurrency(unique.ValueCurrency)
|
||||
}
|
||||
if unique.ValueUSD > 0 {
|
||||
out.SetValueUsdAmount(unique.ValueUSD)
|
||||
}
|
||||
if peer := tgPeer(unique.ThemePeer); peer != nil {
|
||||
out.SetThemePeer(peer)
|
||||
}
|
||||
if peer := tgPeer(unique.Host); peer != nil {
|
||||
out.SetHostID(peer)
|
||||
}
|
||||
if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser {
|
||||
out.SetOfferMinStars(unique.OfferMinStars)
|
||||
}
|
||||
if unique.CraftChancePermille > 0 {
|
||||
out.SetCraftChancePermille(unique.CraftChancePermille)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass {
|
||||
if amount.Currency == domain.StarGiftCurrencyTON {
|
||||
return &tg.StarsTonAmount{Amount: amount.Amount}
|
||||
}
|
||||
return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos}
|
||||
}
|
||||
|
||||
func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) {
|
||||
switch value := amount.(type) {
|
||||
case *tg.StarsAmount:
|
||||
if value == nil {
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos}
|
||||
return out, out.Valid()
|
||||
case *tg.StarsTonAmount:
|
||||
if value == nil {
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount}
|
||||
return out, out.Valid()
|
||||
default:
|
||||
return domain.StarGiftAmount{}, false
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,102 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type adminUniqueStarGiftGranter interface {
|
||||
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
|
||||
}
|
||||
|
||||
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
|
||||
// grant.SenderID without charging any Stars. It powers the admin console "Give
|
||||
// gift" action: the gift is loaded from the catalog and delivered through the
|
||||
// exact same path a paid send uses (messageActionStarGift service message for
|
||||
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
|
||||
//
|
||||
// SenderID must be zero or the official system account (777000). When Upgrade
|
||||
// is true, the store assigns a genuine collectible directly in the same
|
||||
// transaction as its service message and durable updates. The optional
|
||||
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
|
||||
// collectible facts (0 => random; number is always sequential). Upgraded
|
||||
// delivery is supported for user recipients only.
|
||||
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
|
||||
senderID := grant.SenderID
|
||||
if senderID <= 0 {
|
||||
senderID = domain.OfficialSystemUserID
|
||||
}
|
||||
if senderID != domain.OfficialSystemUserID {
|
||||
return fmt.Errorf("gift sender must be the official system account")
|
||||
}
|
||||
if grant.GiftID <= 0 {
|
||||
return fmt.Errorf("gift_id is required")
|
||||
}
|
||||
if grant.Recipient.ID <= 0 {
|
||||
return fmt.Errorf("recipient is required")
|
||||
}
|
||||
if r.deps.Gifts == nil {
|
||||
return fmt.Errorf("gifts dependency is not configured")
|
||||
}
|
||||
gift, ok, err := r.deps.Gifts.GiftByID(ctx, grant.GiftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("gift %d not found", grant.GiftID)
|
||||
}
|
||||
if grant.Upgrade {
|
||||
return r.adminGrantUpgradedStarGift(ctx, senderID, gift, grant)
|
||||
}
|
||||
switch grant.Recipient.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, _, err = r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, _, err = r.sendStarGiftToChannel(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported recipient peer type %q", grant.Recipient.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// adminGrantUpgradedStarGift assigns a collectible through the atomic store
|
||||
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
|
||||
// outbox event behind.
|
||||
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
|
||||
if grant.Recipient.Type != domain.PeerTypeUser {
|
||||
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, gift.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || preview.UpgradeStars <= 0 {
|
||||
return fmt.Errorf("gift %d has no published collectible upgrade", gift.ID)
|
||||
}
|
||||
if preview.Issued >= preview.SupplyTotal {
|
||||
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
|
||||
}
|
||||
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
|
||||
if !ok {
|
||||
return fmt.Errorf("atomic collectible grant is not configured")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grant.SenderID = senderID
|
||||
grant.Date = int(r.clock.Now().Unix())
|
||||
grant.RecipientBlocked = recipientBlocked
|
||||
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
||||
return err
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(grant.Recipient)
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,262 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appstars "telesrv/internal/app/stars"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type starsFriendGiftRPCStore struct {
|
||||
*memory.StarsStore
|
||||
issued domain.StarsPurchaseForm
|
||||
purchased domain.StarsPurchaseRequest
|
||||
purchases int
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
form.FormID = 70001
|
||||
s.issued = form
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) PurchaseStars(_ context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
s.purchased = req
|
||||
s.purchases++
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
Currency: req.Currency, Amount: req.Amount, Stars: req.Stars,
|
||||
TransactionID: "stars-gift-test", BalanceAfter: 4321,
|
||||
}}
|
||||
sender := domain.Message{ID: 11, OwnerUserID: req.BuyerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, Out: true, Date: req.Date,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
recipient := sender
|
||||
recipient.ID, recipient.OwnerUserID, recipient.Peer, recipient.Out = 12, req.RecipientUserID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, false
|
||||
return domain.StarsPurchaseResult{
|
||||
Balance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
|
||||
TransactionID: "stars-gift-test",
|
||||
Send: domain.SendPrivateTextResult{
|
||||
SenderMessage: sender, RecipientMessage: recipient,
|
||||
SenderEvent: domain.UpdateEvent{UserID: req.BuyerUserID, Type: domain.UpdateEventNewMessage, Pts: 5, PtsCount: 1, Date: req.Date, Message: sender},
|
||||
RecipientEvent: domain.UpdateEvent{UserID: req.RecipientUserID, Type: domain.UpdateEventNewMessage, Pts: 9, PtsCount: 1, Date: req.Date, Message: recipient},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
buyer, err := users.Create(ctx, domain.User{AccessHash: 8101, Phone: "+15558101", FirstName: "Buyer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipient, err := users.Create(ctx, domain.User{AccessHash: 8102, Phone: "+15558102", FirstName: "Recipient"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()}
|
||||
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, st, buyer, recipient
|
||||
}
|
||||
|
||||
func TestStarsFriendGiftOptionsFormAndFiatSettlement(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
generic, err := r.onPaymentsGetStarsGiftOptions(ctx, &tg.PaymentsGetStarsGiftOptionsRequest{})
|
||||
if err != nil || len(generic) != 3 || generic[0].Stars != 1000 || generic[0].Currency != "USD" || generic[0].Amount != 99 {
|
||||
t.Fatalf("generic gift options = %+v err=%v", generic, err)
|
||||
}
|
||||
input := &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}
|
||||
personalReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
|
||||
personalReq.SetUserID(input)
|
||||
personal, err := r.onPaymentsGetStarsGiftOptions(ctx, personalReq)
|
||||
if err != nil || len(personal) != len(generic) {
|
||||
t.Fatalf("personal gift options = %+v err=%v", personal, err)
|
||||
}
|
||||
|
||||
purpose := &tg.InputStorePaymentStarsGift{UserID: input, Stars: 2500, Currency: "USD", Amount: 199}
|
||||
invoice := &tg.InputInvoiceStars{Purpose: purpose}
|
||||
formClass, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
|
||||
if err != nil {
|
||||
t.Fatalf("get gift payment form: %v", err)
|
||||
}
|
||||
form, ok := formClass.(*tg.PaymentsPaymentForm)
|
||||
if !ok || form.FormID != 70001 || !form.Invoice.Test || form.Invoice.Currency != "USD" ||
|
||||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 199 ||
|
||||
form.ProviderID != domain.OfficialSystemUserID || form.URL != "https://links.example.test/payments/dev-stars?form_id=70001" {
|
||||
t.Fatalf("gift payment form = %T %+v", formClass, formClass)
|
||||
}
|
||||
if st.issued.Kind != domain.StarsPurchaseGift || st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
|
||||
t.Fatalf("issued form = %+v", st.issued)
|
||||
}
|
||||
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("sendStarsForm fiat gift err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
|
||||
}
|
||||
resultClass, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sendPaymentForm gift: %v", err)
|
||||
}
|
||||
result, ok := resultClass.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
t.Fatalf("sendStarsForm result = %T", resultClass)
|
||||
}
|
||||
updates, ok := result.Updates.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 1 {
|
||||
t.Fatalf("sender updates = %T %+v", result.Updates, result.Updates)
|
||||
}
|
||||
newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage)
|
||||
if !ok || newMessage.Pts != 5 || newMessage.PtsCount != 1 {
|
||||
t.Fatalf("sender new message = %T %+v", updates.Updates[0], updates.Updates[0])
|
||||
}
|
||||
serviceMessage, ok := newMessage.Message.(*tg.MessageService)
|
||||
if !ok {
|
||||
t.Fatalf("gift message = %T", newMessage.Message)
|
||||
}
|
||||
action, ok := serviceMessage.Action.(*tg.MessageActionGiftStars)
|
||||
if !ok || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 || action.TransactionID != "" {
|
||||
t.Fatalf("sender gift action = %T %+v", serviceMessage.Action, serviceMessage.Action)
|
||||
}
|
||||
if st.purchased.FormID != form.FormID || st.purchased.RecipientUserID != recipient.ID || st.purchases != 1 {
|
||||
t.Fatalf("purchase request = %+v count=%d", st.purchased, st.purchases)
|
||||
}
|
||||
|
||||
if st.purchases != 1 {
|
||||
t.Fatalf("settlement count = %d, want one fiat submit", st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsDirectPurchaseValidateRequestedInfoIsReadOnly(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
topup := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}}
|
||||
gift := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 2500, Currency: "USD", Amount: 199,
|
||||
}}
|
||||
for name, invoice := range map[string]tg.InputInvoiceClass{"topup": topup, "gift": gift} {
|
||||
result, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Save: true, Invoice: invoice,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s validateRequestedInfo: %v", name, err)
|
||||
}
|
||||
if result == nil || !result.Zero() {
|
||||
t.Fatalf("%s validated info = %+v, want flags=0", name, result)
|
||||
}
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
|
||||
withInfo := &tg.PaymentsValidateRequestedInfoRequest{Invoice: topup}
|
||||
withInfo.Info.SetName("unexpected")
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_INVALID") {
|
||||
t.Fatalf("non-empty info err=%v, want REQUESTED_INFO_INVALID", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceSlug{Slug: "unsupported"},
|
||||
}); !tgerr.Is(err, "NOT_IMPLEMENTED") {
|
||||
t.Fatalf("non-Stars invoice err=%v, want NOT_IMPLEMENTED", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 100}},
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package err=%v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("invalid validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
badRecipientReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
|
||||
badRecipientReq.SetUserID(&tg.InputUser{UserID: recipient.ID + 99999})
|
||||
if _, err := r.onPaymentsGetStarsGiftOptions(ctx, badRecipientReq); !tgerr.Is(err, "USER_ID_INVALID") {
|
||||
t.Fatalf("bad recipient err = %v", err)
|
||||
}
|
||||
bad := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 2500, Currency: "USD", Amount: 200,
|
||||
}}
|
||||
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package form err = %v", err)
|
||||
}
|
||||
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: 70001, Invoice: bad, Credentials: devStarsCredentials(70001),
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package settle err = %v", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("invalid request reached store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidStorePurchaseFailsClosedInFavorOfInvoiceCheckout(t *testing.T) {
|
||||
r, _, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
purpose := &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}
|
||||
allowed, err := r.onPaymentsCanPurchaseStore(ctx, &tg.PaymentsCanPurchaseStoreRequest{Purpose: purpose})
|
||||
if err != nil {
|
||||
t.Fatalf("canPurchaseStore: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("canPurchaseStore = true, want false")
|
||||
}
|
||||
if _, err := r.onPaymentsAssignPlayMarketTransaction(ctx, &tg.PaymentsAssignPlayMarketTransactionRequest{
|
||||
Receipt: tg.DataJSON{Data: `{"orderId":"unverified"}`}, Purpose: purpose,
|
||||
}); !tgerr.Is(err, "STORE_PAYMENT_UNAVAILABLE") {
|
||||
t.Fatalf("assignPlayMarketTransaction err = %v, want STORE_PAYMENT_UNAVAILABLE", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftStarsRecipientProjectionCarriesBalanceOnlineAndDifference(t *testing.T) {
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
Currency: "USD", Amount: 99, Stars: 1000, TransactionID: "txn-1", BalanceAfter: 3100,
|
||||
}}
|
||||
msg := domain.Message{ID: 4, OwnerUserID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1700000000,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
|
||||
event := domain.UpdateEvent{UserID: 2, Type: domain.UpdateEventNewMessage, Pts: 8, PtsCount: 1, Date: msg.Date, Message: msg}
|
||||
online := tgPrivateMessageUpdates(event, msg, 0, false, nil, nil)
|
||||
if len(online.Updates) != 2 {
|
||||
t.Fatalf("online updates = %+v", online.Updates)
|
||||
}
|
||||
balance, ok := online.Updates[1].(*tg.UpdateStarsBalance)
|
||||
if !ok || balance.Balance.(*tg.StarsAmount).Amount != 3100 {
|
||||
t.Fatalf("online balance = %T %+v", online.Updates[1], online.Updates[1])
|
||||
}
|
||||
diff := tgUpdatesDifference(2, domain.UpdateDifference{Events: []domain.UpdateEvent{event}, State: domain.UpdateState{Pts: 8}})
|
||||
full, ok := diff.(*tg.UpdatesDifference)
|
||||
if !ok || len(full.NewMessages) != 1 || len(full.OtherUpdates) != 1 {
|
||||
t.Fatalf("difference = %T %+v", diff, diff)
|
||||
}
|
||||
if _, ok := full.OtherUpdates[0].(*tg.UpdateStarsBalance); !ok {
|
||||
t.Fatalf("difference balance = %T", full.OtherUpdates[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appstars "telesrv/internal/app/stars"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func starsRouter(t *testing.T, grant int64) *Router {
|
||||
t.Helper()
|
||||
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(grant))
|
||||
return New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
|
||||
}
|
||||
|
||||
// getStarsStatus 首读惰性授予后返回真实余额;响应必须是合法 starsStatus
|
||||
// (balance 必填 + chats/users 非 nil vector)。
|
||||
func TestOnPaymentsGetStarsStatusGranted(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsStatus: %v", err)
|
||||
}
|
||||
amount, ok := status.Balance.(*tg.StarsAmount)
|
||||
if !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
|
||||
}
|
||||
if status.Chats == nil || status.Users == nil {
|
||||
t.Fatalf("chats/users must be non-nil vectors, got chats=%v users=%v", status.Chats, status.Users)
|
||||
}
|
||||
// 余额是 flag 外必填字段,不能省略。
|
||||
if _, hasHistory := status.GetHistory(); hasHistory {
|
||||
t.Fatalf("status (not transactions) should carry no history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsSubscriptionsReturnsTerminalEmptyPage(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
status, err := r.onPaymentsGetStarsSubscriptions(ctx, &tg.PaymentsGetStarsSubscriptionsRequest{
|
||||
Peer: &tg.InputPeerSelf{}, Offset: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsSubscriptions: %v", err)
|
||||
}
|
||||
amount, ok := status.Balance.(*tg.StarsAmount)
|
||||
if !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
|
||||
}
|
||||
if subscriptions, ok := status.GetSubscriptions(); ok || len(subscriptions) != 0 {
|
||||
t.Fatalf("subscriptions = %+v ok=%v, want absent terminal page", subscriptions, ok)
|
||||
}
|
||||
if _, ok := status.GetSubscriptionsNextOffset(); ok {
|
||||
t.Fatal("empty subscription page unexpectedly has next offset")
|
||||
}
|
||||
}
|
||||
|
||||
// TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。
|
||||
func TestOnPaymentsGetStarsStatusTon(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
// SetTon 同时置 flag 位+字段(gotd true-flag:GetTon 读 flag 位,手工 struct 字面量不置位)。
|
||||
req := &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}
|
||||
req.SetTon(true)
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsStatus ton: %v", err)
|
||||
}
|
||||
if _, ok := status.Balance.(*tg.StarsTonAmount); !ok {
|
||||
t.Fatalf("ton balance = %#v, want StarsTonAmount", status.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
// getStarsTransactions 返回授予流水;keyset 分页末页省略 next_offset(防 DrKLO 死循环)。
|
||||
func TestOnPaymentsGetStarsTransactions(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
status, err := r.onPaymentsGetStarsTransactions(ctx, &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsTransactions: %v", err)
|
||||
}
|
||||
history, ok := status.GetHistory()
|
||||
if !ok || len(history) != 1 {
|
||||
t.Fatalf("history = %d ok=%v, want 1 grant txn", len(history), ok)
|
||||
}
|
||||
txn := history[0]
|
||||
if amount, ok := txn.Amount.(*tg.StarsAmount); !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("grant txn amount = %#v, want +1000", txn.Amount)
|
||||
}
|
||||
// grant 走 Fragment 对手方(Peer 必填,不可 nil)。
|
||||
if _, ok := txn.Peer.(*tg.StarsTransactionPeerFragment); !ok {
|
||||
t.Fatalf("grant peer = %#v, want StarsTransactionPeerFragment", txn.Peer)
|
||||
}
|
||||
// 单页装得下 → 无 next_offset。
|
||||
if off, ok := status.GetNextOffset(); ok {
|
||||
t.Fatalf("single-page next_offset = %q, want absent (no infinite paging)", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsTransactionsDirections(t *testing.T) {
|
||||
const userID int64 = 1000000001
|
||||
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(0))
|
||||
ctx := WithUserID(context.Background(), userID)
|
||||
if _, err := svc.Credit(ctx, userID, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 100: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, userID, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 40: %v", err)
|
||||
}
|
||||
if _, err := svc.Credit(ctx, userID, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("credit 20: %v", err)
|
||||
}
|
||||
if _, err := svc.Debit(ctx, userID, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
|
||||
t.Fatalf("debit 10: %v", err)
|
||||
}
|
||||
r := New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
all := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
assertRPCStarsAmounts(t, r, ctx, all, []int64{-10, 20, -40, 100})
|
||||
|
||||
incoming := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
incoming.SetInbound(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, incoming, []int64{20, 100})
|
||||
|
||||
outgoing := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
outgoing.SetOutbound(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, outgoing, []int64{-10, -40})
|
||||
|
||||
ascending := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
|
||||
ascending.SetInbound(true)
|
||||
ascending.SetAscending(true)
|
||||
assertRPCStarsAmounts(t, r, ctx, ascending, []int64{100, 20})
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsTransactionsRejectsInvalidFilters(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
both := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
|
||||
both.SetInbound(true)
|
||||
both.SetOutbound(true)
|
||||
if _, err := r.onPaymentsGetStarsTransactions(ctx, both); err == nil {
|
||||
t.Fatal("mutually exclusive inbound/outbound unexpectedly succeeded")
|
||||
}
|
||||
|
||||
subscription := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
|
||||
subscription.SetSubscriptionID("subscription-1")
|
||||
if _, err := r.onPaymentsGetStarsTransactions(ctx, subscription); err == nil {
|
||||
t.Fatal("unsupported subscription filter unexpectedly returned the unfiltered ledger")
|
||||
}
|
||||
}
|
||||
|
||||
func assertRPCStarsAmounts(t *testing.T, r *Router, ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest, want []int64) {
|
||||
t.Helper()
|
||||
status, err := r.onPaymentsGetStarsTransactions(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsTransactions: %v", err)
|
||||
}
|
||||
history, _ := status.GetHistory()
|
||||
if len(history) != len(want) {
|
||||
t.Fatalf("history count = %d, want %d: %+v", len(history), len(want), history)
|
||||
}
|
||||
for i, amount := range want {
|
||||
stars, ok := history[i].Amount.(*tg.StarsAmount)
|
||||
if !ok || stars.Amount != amount {
|
||||
t.Fatalf("history[%d].amount = %#v, want %d", i, history[i].Amount, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTGStarsTransactionsPaidMessage(t *testing.T) {
|
||||
out := tgStarsTransactions([]domain.StarsTransaction{{
|
||||
ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50},
|
||||
Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message",
|
||||
}})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("paid-message transactions = %d, want 1", len(out))
|
||||
}
|
||||
if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 {
|
||||
t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok)
|
||||
}
|
||||
if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 {
|
||||
t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount)
|
||||
}
|
||||
}
|
||||
|
||||
// deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。
|
||||
func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil {
|
||||
t.Fatalf("nil-deps getStarsStatus: %v", err)
|
||||
}
|
||||
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 0 {
|
||||
t.Fatalf("nil-deps balance = %#v, want StarsAmount 0", status.Balance)
|
||||
}
|
||||
_ = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
|
||||
type channelLedgerGifts struct {
|
||||
GiftsService
|
||||
starsBalance int64
|
||||
tonBalance int64
|
||||
starsPage domain.StarsTransactionPage
|
||||
tonPage domain.TonTransactionPage
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) {
|
||||
return s.starsBalance, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
|
||||
return s.starsPage, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) {
|
||||
return s.tonBalance, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
|
||||
return s.tonPage, nil
|
||||
}
|
||||
|
||||
type channelLedgerChannels struct {
|
||||
ChannelsService
|
||||
view domain.ChannelView
|
||||
}
|
||||
|
||||
func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
|
||||
return s.view, nil
|
||||
}
|
||||
|
||||
func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) {
|
||||
return []domain.ChannelView{s.view}, nil
|
||||
}
|
||||
|
||||
func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) {
|
||||
const viewerID, channelID int64 = 1000000001, 2000000001
|
||||
view := domain.ChannelView{
|
||||
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID},
|
||||
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive},
|
||||
}
|
||||
gifts := &channelLedgerGifts{
|
||||
starsBalance: 20,
|
||||
tonBalance: 900,
|
||||
starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{
|
||||
ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift,
|
||||
}}},
|
||||
tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{
|
||||
ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale,
|
||||
}}},
|
||||
}
|
||||
r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), viewerID)
|
||||
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}
|
||||
|
||||
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel stars status: %v", err)
|
||||
}
|
||||
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 {
|
||||
t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats))
|
||||
}
|
||||
revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel stars revenue: %v", err)
|
||||
}
|
||||
if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 {
|
||||
t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance)
|
||||
}
|
||||
if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled {
|
||||
t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled)
|
||||
}
|
||||
|
||||
txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20}
|
||||
txnReq.SetTon(true)
|
||||
transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel ton transactions: %v", err)
|
||||
}
|
||||
history, ok := transactions.GetHistory()
|
||||
if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale {
|
||||
t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history)
|
||||
}
|
||||
revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}
|
||||
revenueReq.SetTon(true)
|
||||
tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel ton revenue: %v", err)
|
||||
}
|
||||
if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 {
|
||||
t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) {
|
||||
const viewerID, channelID int64 = 1000000001, 2000000001
|
||||
view := domain.ChannelView{
|
||||
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true},
|
||||
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
|
||||
}
|
||||
r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), viewerID)
|
||||
_, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}})
|
||||
if err == nil {
|
||||
t.Fatal("non-admin channel ledger read unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
|
||||
// ownership-write boundary. The rule does not reject the gift: it decides
|
||||
// whether an incoming user gift is displayed immediately (unsaved=false) or
|
||||
// waits for the recipient's approval (unsaved=true).
|
||||
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
|
||||
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
|
||||
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
|
||||
return false, nil
|
||||
}
|
||||
allowed, err := r.deps.Privacy.CanSee(
|
||||
ctx,
|
||||
recipient.ID,
|
||||
senderUserID,
|
||||
domain.PrivacyKeyStarGiftsAutoSave,
|
||||
)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return !allowed, nil
|
||||
}
|
||||
|
|
@ -1288,9 +1288,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
|
||||
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
|
||||
{name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}},
|
||||
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
|
||||
{name: "payments.getStarsGiftOptions", req: &tg.PaymentsGetStarsGiftOptionsRequest{}},
|
||||
{name: "payments.getStarsGiveawayOptions", req: &tg.PaymentsGetStarsGiveawayOptionsRequest{}},
|
||||
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
||||
|
|
@ -1350,11 +1347,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "stories.getPeerMaxIDs", req: &tg.StoriesGetPeerMaxIDsRequest{ID: []tg.InputPeerClass{&tg.InputPeerSelf{}}}},
|
||||
{name: "stories.getStoriesViews", req: &tg.StoriesGetStoriesViewsRequest{Peer: &tg.InputPeerSelf{}, ID: []int{1}}},
|
||||
{name: "stories.getChatsToSend", req: &tg.StoriesGetChatsToSendRequest{}},
|
||||
{name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}},
|
||||
{name: "payments.getStarGifts", req: &tg.PaymentsGetStarGiftsRequest{}},
|
||||
{name: "payments.getStarGiftCollections", req: &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}},
|
||||
{name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}},
|
||||
{name: "payments.getStarsRevenueAdsAccountUrl", req: &tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsRevenueStats", req: &tg.PaymentsGetStarsRevenueStatsRequest{Ton: true, Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "bots.getBotRecommendations", req: &tg.BotsGetBotRecommendationsRequest{Bot: &tg.InputUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
||||
|
|
|
|||
|
|
@ -324,18 +324,6 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
|
|||
userIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if msg.Action.StarGift != nil {
|
||||
if id := msg.Action.StarGift.FromUserID; id != 0 && !msg.Action.StarGift.NameHidden {
|
||||
userIDs[id] = struct{}{}
|
||||
}
|
||||
if id := msg.Action.StarGift.PeerUserID; id != 0 {
|
||||
userIDs[id] = struct{}{}
|
||||
}
|
||||
if id := msg.Action.StarGift.PeerChannelID; id != 0 && id != currentChannelID {
|
||||
channelIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
collectStarGiftUniquePeerRefs(msg.Action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
if msg.Reactions != nil {
|
||||
for _, reaction := range msg.Reactions.Recent {
|
||||
|
|
@ -356,37 +344,6 @@ func collectServiceActionPeerRefs(media *domain.MessageMedia, currentChannelID i
|
|||
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
if gift := action.StarGift; gift != nil {
|
||||
if gift.FromUserID != 0 && !gift.NameHidden {
|
||||
userIDs[gift.FromUserID] = struct{}{}
|
||||
}
|
||||
if gift.PeerUserID != 0 {
|
||||
userIDs[gift.PeerUserID] = struct{}{}
|
||||
}
|
||||
if gift.PeerChannelID != 0 && gift.PeerChannelID != currentChannelID {
|
||||
channelIDs[gift.PeerChannelID] = struct{}{}
|
||||
}
|
||||
addDomainPeerRef(gift.To, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
collectStarGiftUniquePeerRefs(action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
|
||||
func collectStarGiftUniquePeerRefs(action *domain.MessageStarGiftUniqueAction, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
|
||||
if action == nil {
|
||||
return
|
||||
}
|
||||
if action.FromUserID != 0 {
|
||||
userIDs[action.FromUserID] = struct{}{}
|
||||
}
|
||||
addDomainPeerRef(action.Peer, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.Owner, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.OriginalOwner, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.ReleasedBy, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.ThemePeer, currentChannelID, userIDs, channelIDs)
|
||||
addDomainPeerRef(action.Gift.Host, currentChannelID, userIDs, channelIDs)
|
||||
if action.Gift.OriginalFromUserID != 0 && !action.Gift.OriginalNameHidden {
|
||||
userIDs[action.Gift.OriginalFromUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
|
||||
|
|
|
|||
|
|
@ -28,96 +28,3 @@ func TestRemoveKnownChannelRefs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsIncludesStarGiftServiceActions(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
FromUserID: 1001, PeerChannelID: 55,
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1001]; !ok {
|
||||
t.Fatalf("ordinary star-gift user refs=%v, missing sender", users)
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("ordinary star-gift channel refs=%v", channels)
|
||||
}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{PeerUserID: 1002},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1002]; !ok {
|
||||
t.Fatalf("ordinary star-gift recipient refs=%v", users)
|
||||
}
|
||||
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
FromUserID: 2001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
|
||||
Gift: domain.UniqueStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
|
||||
OriginalFromUserID: 2002,
|
||||
OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
|
||||
ReleasedBy: domain.Peer{Type: domain.PeerTypeUser, ID: 2004},
|
||||
ThemePeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 57},
|
||||
Host: domain.Peer{Type: domain.PeerTypeUser, ID: 2005},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
for _, id := range []int64{2001, 2002, 2003, 2004, 2005} {
|
||||
if _, ok := users[id]; !ok {
|
||||
t.Fatalf("unique star-gift user refs=%v, missing %d", users, id)
|
||||
}
|
||||
}
|
||||
for _, id := range []int64{56, 57} {
|
||||
if _, ok := channels[id]; !ok {
|
||||
t.Fatalf("unique star-gift channel refs=%v, missing %d", channels, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsHidesStarGiftSenderDetails(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
FromUserID: 1001, NameHidden: true, PeerChannelID: 55,
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: domain.UniqueStarGift{
|
||||
OriginalFromUserID: 2001,
|
||||
OriginalNameHidden: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
for _, id := range []int64{1001, 2001} {
|
||||
if _, ok := users[id]; ok {
|
||||
t.Fatalf("hidden star-gift sender %d leaked into refs=%v", id, users)
|
||||
}
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("hidden ordinary gift lost recipient channel ref=%v", channels)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,14 +362,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
|
|||
}
|
||||
full.CommonChatsCount = common.Count
|
||||
}
|
||||
// star gift 数量:客户端把资料页 Gifts 区段/标签页门控在 stargifts_count>0
|
||||
//(DrKLO ProfileActivity:10497 / TDesktop data_user.cpp:924),不下发则收到的礼物
|
||||
// 不在资料页展示。计展示在资料的礼物数(非转换、非隐藏)。
|
||||
if r.deps.Gifts != nil {
|
||||
if n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}); err == nil && n > 0 {
|
||||
full.SetStargiftsCount(n)
|
||||
}
|
||||
}
|
||||
// 屏蔽 premium 礼物赠送:telesrv 未实现 payments.getPremiumGiftCodeOptions,
|
||||
// DrKLO GiftSheet 对个人送礼时总会渲染一个「Gift Premium」区段(fillItems:918),
|
||||
// premiumTiers 永远空 → 卡死成三个 flicker 占位骨架。设 disallow_premium_gifts=true
|
||||
|
|
@ -389,7 +381,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
|
|||
full.SetBirthday(tgBirthday(u.Birthday))
|
||||
}
|
||||
}
|
||||
r.applyAccountRatingToUserFull(ctx, currentUserID, u, &full)
|
||||
// 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的
|
||||
// overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及
|
||||
// build/chats 两次解析同一频道。
|
||||
|
|
@ -441,50 +432,6 @@ func (r *Router) userFullPrivacyVisibility(ctx context.Context, viewerUserID, ow
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// applyAccountRatingToUserFull projects gramsrv's stored composite rating through
|
||||
// the rating fields official clients already render. This is a gramsrv policy
|
||||
// score, not a promise that its inputs or thresholds match Telegram's service.
|
||||
//
|
||||
// The projection is built inside the existing per-(viewer,target) UserFull
|
||||
// cache. Therefore a cache miss adds at most one primary-key read and a cache hit
|
||||
// adds none. Recompute and writes remain exclusively in the bounded background
|
||||
// worker/admin paths.
|
||||
func (r *Router) applyAccountRatingToUserFull(ctx context.Context, viewerUserID int64, target domain.User, full *tg.UserFull) {
|
||||
targetUserID := target.ID
|
||||
if r.deps.AccountRatings == nil || full == nil || !domain.RatableAccount(targetUserID, target.Bot) {
|
||||
return
|
||||
}
|
||||
rating, err := r.deps.AccountRatings.Rating(ctx, targetUserID)
|
||||
if err != nil {
|
||||
// Missing, disabled and temporarily unavailable projections all preserve
|
||||
// the legacy wire shape instead of failing the surrounding profile read.
|
||||
return
|
||||
}
|
||||
full.SetStarsRating(tgAccountRatingLevel(rating.LevelSnapshot()))
|
||||
if viewerUserID == 0 || viewerUserID != targetUserID {
|
||||
return
|
||||
}
|
||||
pending, ok := rating.PendingLevel()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
full.SetStarsMyPendingRating(tgAccountRatingLevel(pending))
|
||||
full.SetStarsMyPendingRatingDate(int(rating.PendingDate.Unix()))
|
||||
}
|
||||
|
||||
// tgAccountRatingLevel maps the local level snapshot onto starsRating#1b0e4f07.
|
||||
// next_level_stars remains absent at the configured maximum local level.
|
||||
func tgAccountRatingLevel(in domain.AccountRatingLevel) tg.StarsRating {
|
||||
out := tg.StarsRating{
|
||||
Level: in.Level,
|
||||
CurrentLevelStars: in.CurrentLevelStars,
|
||||
Stars: in.Stars,
|
||||
}
|
||||
if in.HasNextLevelStars {
|
||||
out.SetNextLevelStars(in.NextLevelStars)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tgBirthday 把 domain 生日转 tg.Birthday(Year 可选,0 表示不含年份)。
|
||||
func tgBirthday(b domain.Birthday) tg.Birthday {
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
package giftdemo
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// This file hand-builds small, fully original 512x512 Lottie animations from
|
||||
// geometric primitives (polystars, ellipses, rings). They are deliberately
|
||||
// simple placeholders — the point is a legally-clean demo asset set the admin
|
||||
// can later replace with real artwork, not studio-grade graphics. Everything
|
||||
// here is emitted as plain shape/transform JSON, with no expressions and no
|
||||
// external assets, so it passes the Star Gift animation validator unchanged.
|
||||
|
||||
const (
|
||||
canvasSize = 512
|
||||
center = canvasSize / 2
|
||||
frameRate = 60
|
||||
// 120 frames @ 60fps = 2s loop, well under the 30s ceiling.
|
||||
outPoint = 120
|
||||
)
|
||||
|
||||
// rgb is a 0..1 normalized colour triplet, the form Lottie fills expect.
|
||||
type rgb [3]float64
|
||||
|
||||
func fromHex(v int) rgb {
|
||||
return rgb{
|
||||
float64((v>>16)&0xff) / 255,
|
||||
float64((v>>8)&0xff) / 255,
|
||||
float64(v&0xff) / 255,
|
||||
}
|
||||
}
|
||||
|
||||
type motion int
|
||||
|
||||
const (
|
||||
motionSpin motion = iota
|
||||
motionPulse
|
||||
motionSpinPulse
|
||||
)
|
||||
|
||||
// shapeKind picks which primitive the layer draws.
|
||||
type shapeKind int
|
||||
|
||||
const (
|
||||
shapeStar shapeKind = iota // pointed star (polystar type 1)
|
||||
shapePolygon
|
||||
shapeRing // ellipse outline + inner disc, used for "coin"
|
||||
shapeBurst
|
||||
)
|
||||
|
||||
type lottieSpec struct {
|
||||
kind shapeKind
|
||||
points int // star / polygon point count
|
||||
fill rgb
|
||||
stroke rgb
|
||||
strokeW float64 // 0 = no stroke
|
||||
radius float64
|
||||
motion motion
|
||||
}
|
||||
|
||||
// prop builds a static Lottie animated-value wrapper {a:0,k:value}.
|
||||
func prop(value any) map[string]any { return map[string]any{"a": 0, "k": value} }
|
||||
|
||||
// easing handles are arrays (never strings), so the validator's expression
|
||||
// check — which only rejects string-valued "x" keys — never trips on them.
|
||||
func keyframe(t float64, value []float64) map[string]any {
|
||||
return map[string]any{
|
||||
"t": t,
|
||||
"s": value,
|
||||
"i": map[string]any{"x": []float64{0.6}, "y": []float64{1}},
|
||||
"o": map[string]any{"x": []float64{0.4}, "y": []float64{0}},
|
||||
}
|
||||
}
|
||||
|
||||
func spinRotation() map[string]any {
|
||||
return map[string]any{"a": 1, "k": []any{
|
||||
keyframe(0, []float64{0}),
|
||||
keyframe(outPoint, []float64{360}),
|
||||
}}
|
||||
}
|
||||
|
||||
func pulseScale() map[string]any {
|
||||
return map[string]any{"a": 1, "k": []any{
|
||||
keyframe(0, []float64{100, 100, 100}),
|
||||
keyframe(outPoint/2, []float64{114, 114, 114}),
|
||||
keyframe(outPoint, []float64{100, 100, 100}),
|
||||
}}
|
||||
}
|
||||
|
||||
func transform(m motion) map[string]any {
|
||||
rotation := prop(0.0)
|
||||
scale := prop([]float64{100, 100, 100})
|
||||
switch m {
|
||||
case motionSpin:
|
||||
rotation = spinRotation()
|
||||
case motionPulse:
|
||||
scale = pulseScale()
|
||||
case motionSpinPulse:
|
||||
rotation = spinRotation()
|
||||
scale = pulseScale()
|
||||
}
|
||||
return map[string]any{
|
||||
"o": prop(100.0),
|
||||
"r": rotation,
|
||||
"p": prop([]float64{center, center, 0}),
|
||||
"a": prop([]float64{0, 0, 0}),
|
||||
"s": scale,
|
||||
"sk": prop(0.0),
|
||||
"sa": prop(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
func fill(c rgb) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "fl", "nm": "Fill", "r": 1,
|
||||
"o": prop(100.0),
|
||||
"c": prop([]float64{c[0], c[1], c[2]}),
|
||||
}
|
||||
}
|
||||
|
||||
func stroke(c rgb, width float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "st", "nm": "Stroke", "lc": 2, "lj": 2, "ml": 4,
|
||||
"o": prop(100.0),
|
||||
"w": prop(width),
|
||||
"c": prop([]float64{c[0], c[1], c[2]}),
|
||||
}
|
||||
}
|
||||
|
||||
func polystar(points int, starType int, outer, innerRatio float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "sr", "nm": "Polystar", "sy": starType,
|
||||
"d": 1,
|
||||
"pt": prop(float64(points)),
|
||||
"p": prop([]float64{0, 0}),
|
||||
"r": prop(0.0),
|
||||
"ir": prop(outer * innerRatio),
|
||||
"is": prop(0.0),
|
||||
"or": prop(outer),
|
||||
"os": prop(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
func ellipse(radius float64) map[string]any {
|
||||
return map[string]any{
|
||||
"ty": "el", "nm": "Ellipse", "d": 1,
|
||||
"p": prop([]float64{0, 0}),
|
||||
"s": prop([]float64{radius * 2, radius * 2}),
|
||||
}
|
||||
}
|
||||
|
||||
// renderLottie serializes one spec to Lottie JSON bytes.
|
||||
func renderLottie(spec lottieSpec) ([]byte, error) {
|
||||
var shapes []any
|
||||
switch spec.kind {
|
||||
case shapeStar:
|
||||
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.5))
|
||||
case shapePolygon:
|
||||
shapes = append(shapes, polystar(spec.points, 2, spec.radius, 0.5))
|
||||
case shapeBurst:
|
||||
shapes = append(shapes, polystar(spec.points, 1, spec.radius, 0.32))
|
||||
case shapeRing:
|
||||
shapes = append(shapes, ellipse(spec.radius))
|
||||
}
|
||||
shapes = append(shapes, fill(spec.fill))
|
||||
if spec.strokeW > 0 {
|
||||
shapes = append(shapes, stroke(spec.stroke, spec.strokeW))
|
||||
}
|
||||
// A coin gets a smaller contrasting inner disc for a bit of depth.
|
||||
if spec.kind == shapeRing {
|
||||
shapes = append(shapes, ellipse(spec.radius*0.55), fill(spec.stroke))
|
||||
}
|
||||
|
||||
layer := map[string]any{
|
||||
"ddd": 0, "ind": 1, "ty": 4, "nm": "gift", "sr": 1,
|
||||
"ks": transform(spec.motion),
|
||||
"ao": 0,
|
||||
"shapes": shapes,
|
||||
"ip": 0, "op": outPoint, "st": 0, "bm": 0,
|
||||
}
|
||||
root := map[string]any{
|
||||
"v": "5.7.4", "fr": frameRate, "ip": 0, "op": outPoint,
|
||||
"w": canvasSize, "h": canvasSize, "nm": "owpengram-demo-gift",
|
||||
"ddd": 0, "assets": []any{}, "layers": []any{layer},
|
||||
}
|
||||
return json.Marshal(root)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue