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

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

View file

@ -981,21 +981,6 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
return s.channels.SetChannelMessageReactions(ctx, req)
}
// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本
// Debit 完成,本方法只负责累计与聚合。
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
if req.UserID == 0 {
req.UserID = userID
}
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
}
return s.channels.AddChannelMessagePaidReaction(ctx, req)
}
// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {

View file

@ -24,15 +24,3 @@ func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, sende
}
return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action)
}
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息,
// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。
func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
if s == nil || s.channels == nil {
return domain.ErrChannelInvalid
}
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
return err
}
return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action)
}

View file

@ -1,454 +0,0 @@
// Package rating implements the composite account rating use cases: reading the
// stored projection, recomputing it from the raw contribution signals, and
// applying operator adjustments through the contribution ledger.
//
// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
// private algorithm. The service gathers signals, applies the configured
// weights and pending-delay policy, and persists the result under optimistic
// concurrency for both admin and read-only client projection.
package rating
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
// defaultPendingDelay parks a rating increase for a day, matching the
// shipped TELESRV_RATING_PENDING_DELAY default.
defaultPendingDelay = 24 * time.Hour
// defaultStaleAfter is the recompute horizon used when none is configured.
defaultStaleAfter = 6 * time.Hour
// defaultListLimit / maxListLimit bound one leaderboard page.
defaultListLimit = 50
maxListLimit = 200
// defaultEventLimit / maxEventLimit bound one ledger page.
defaultEventLimit = 50
maxEventLimit = 200
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
defaultRecomputeBatch = 500
maxRecomputeBatch = 10000
)
// ErrDisabled reports that the local composite rating feature is switched off.
// Reads degrade to an empty admin projection; writes are refused so an operator
// never believes an adjustment was recorded when it was not.
var ErrDisabled = errors.New("account rating is disabled")
// Service is the composite account rating use-case layer.
type Service struct {
store store.AccountRatingStore
weights domain.AccountRatingWeights
pendingDelay time.Duration
staleAfter time.Duration
enabled bool
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithStore injects the rating read model and ledger store.
func WithStore(st store.AccountRatingStore) Option {
return func(s *Service) { s.store = st }
}
// WithWeights installs the composite formula. An invalid set is rejected in
// favour of the shipped defaults, so a misconfigured deployment produces a
// conservative rating instead of an inconsistent one.
func WithWeights(weights domain.AccountRatingWeights) Option {
return func(s *Service) {
if err := weights.Validate(); err != nil {
return
}
s.weights = weights
}
}
// WithPendingDelay configures how long a rating increase stays parked as
// pending. Zero applies every change immediately.
func WithPendingDelay(delay time.Duration) Option {
return func(s *Service) {
if delay >= 0 {
s.pendingDelay = delay
}
}
}
// WithStaleAfter configures the projection age after which the background
// worker recomputes a user.
func WithStaleAfter(staleAfter time.Duration) Option {
return func(s *Service) {
if staleAfter > 0 {
s.staleAfter = staleAfter
}
}
}
// WithEnabled toggles the feature.
func WithEnabled(enabled bool) Option {
return func(s *Service) { s.enabled = enabled }
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the rating service. It is enabled by default so that the
// only switch is the configuration flag, and it stays safe without a store:
// reads answer empty and writes report a configuration error.
func NewService(opts ...Option) *Service {
s := &Service{
weights: domain.DefaultAccountRatingWeights(),
pendingDelay: defaultPendingDelay,
staleAfter: defaultStaleAfter,
enabled: true,
now: time.Now,
log: zap.NewNop(),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
if s.pendingDelay < 0 {
s.pendingDelay = 0
}
if s.staleAfter <= 0 {
s.staleAfter = defaultStaleAfter
}
if err := s.weights.Validate(); err != nil {
s.weights = domain.DefaultAccountRatingWeights()
}
return s
}
// Enabled reports whether the feature is switched on.
func (s *Service) Enabled() bool { return s != nil && s.enabled }
// Ready reports whether the feature is on and backed by a store.
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
// Weights returns the configured composite formula, so the admin panel can
// explain a level with the same numbers that produced it.
func (s *Service) Weights() domain.AccountRatingWeights {
if s == nil {
return domain.DefaultAccountRatingWeights()
}
return s.weights
}
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
return s.store, nil
}
// Rating returns the stored projection.
//
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
// value so the admin API can distinguish "not computed" from a computed zero.
// A missing store reports a configuration error an operator can diagnose.
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
return st.AccountRating(ctx, userID)
}
// RatingBatch resolves several users in one round trip. Users without a stored
// projection are absent from the map, so a disabled feature and an unconfigured
// store both read as "nobody has a rating" -- the batch shape already encodes
// absence and needs no error to express it.
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || !s.enabled || s.store == nil {
return map[int64]domain.AccountRating{}, nil
}
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return map[int64]domain.AccountRating{}, nil
}
batch, err := s.store.AccountRatingBatch(ctx, unique)
if err != nil {
return nil, err
}
if batch == nil {
return map[int64]domain.AccountRating{}, nil
}
return batch, nil
}
// Recompute gathers the contribution signals, applies the configured weights
// and the pending-delay policy relative to the stored value, and persists the
// result.
//
// The save is guarded by the stored version. A concurrent writer (another
// recompute, an adjustment, the worker) only invalidates the base the pending
// policy was resolved against, so exactly one retry against the freshly
// returned row is both sufficient and terminating.
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
// The service accounts are infrastructure, not participants. Refusing here as
// well as in the seeding query means an operator cannot create a rating for one
// by hand either -- the platform account is not flagged is_bot, so nothing else
// would stop it.
if !domain.RatableAccount(userID, false) {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
signals, err := st.AccountRatingSignals(ctx, userID)
if err != nil {
return domain.AccountRating{}, err
}
signals.UserID = userID
prev, err := s.previous(ctx, st, userID)
if err != nil {
return domain.AccountRating{}, err
}
now := s.now().UTC()
computed := domain.ComputeAccountRating(signals, s.weights, now)
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if changed {
return stored, nil
}
// One retry: `stored` is the row that won the race, so resolving the pending
// policy against it produces the correct next version.
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if !changed {
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
}
return stored, nil
}
// Adjust records an operator adjustment in the contribution ledger and
// immediately recomputes the projection, so the manual component is visible
// without waiting for the background worker. Replaying the same CommandKey
// records nothing and reports applied=false; the current rating is still
// returned so a retried admin command stays idempotent.
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, false, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, false, err
}
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
_, applied, err := st.AdjustAccountRating(ctx, req)
if err != nil {
return domain.AccountRating{}, false, err
}
rating, err := s.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
// List is the admin leaderboard query with a bounded page size.
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if filter.MinLevel < 0 {
filter.MinLevel = 0
}
if filter.MinLevel > domain.MaxAccountRatingLevel {
filter.MinLevel = domain.MaxAccountRatingLevel
}
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return st.ListAccountRatings(ctx, filter)
}
// Events returns one user's contribution ledger, newest first.
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if userID <= 0 {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
}
// RunRecomputeCycle advances the read model by one bounded batch and returns how
// many users it wrote. A single user's failure is logged and skipped: one poisoned
// row must not stall the whole cycle.
//
// The cycle does two things, and the order matters. It first refreshes projections
// that have gone stale, because those are rows somebody is already looking at.
// Whatever batch budget is left it spends seeding accounts that have no projection
// at all -- without that pass the read model can never populate itself, since
// StaleAccountRatings walks account_rating and cannot return a user who is not in
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
// all, which is what makes the admin leaderboard populate without an operator
// opening every account first.
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
if s == nil || !s.enabled {
return 0, nil
}
st, err := s.ratingStore()
if err != nil {
return 0, err
}
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
if err != nil {
return 0, err
}
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
if err != nil {
return processed, err
}
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
// can never turn one cycle into an unbounded amount of work.
remaining := limit - len(userIDs)
if remaining <= 0 {
return processed, nil
}
unrated, err := st.UnratedAccounts(ctx, remaining)
if err != nil {
// Seeding extends the cycle rather than being its purpose: a store that
// cannot enumerate accounts must not turn a successful stale pass into a
// failed cycle.
s.log.Warn("list unrated accounts failed", zap.Error(err))
return processed, nil
}
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
return processed + seeded, err
}
// recomputeEach recomputes a list of users, skipping the ones that fail, and
// giving up early only when the context is done.
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
processed := 0
for _, userID := range userIDs {
if err := ctx.Err(); err != nil {
return processed, err
}
if userID <= 0 {
continue
}
if _, err := s.Recompute(ctx, userID); err != nil {
s.log.Warn(failureMessage,
zap.Int64("user_id", userID),
zap.Error(err))
continue
}
processed++
}
return processed, nil
}
// EnsureRating returns the stored local-admin projection, computing and storing
// it first when an administrative caller needs an immediate value.
//
// The background cycle reaches every account eventually; callers that require a
// local rating immediately use this bounded materialization path instead.
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := s.Rating(ctx, userID)
if err == nil {
return rating, nil
}
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, err
}
return s.Recompute(ctx, userID)
}
// previous reads the stored projection the pending policy is resolved against.
// A never-computed user yields the zero value, which domain.ResolveAccountRating
// Pending treats as "apply immediately" -- a first rating is never parked.
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
prev, err := st.AccountRating(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, nil
}
return domain.AccountRating{}, err
}
return prev, nil
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}

View file

@ -1,719 +0,0 @@
package rating
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
// concurrency contract as PostgreSQL: a save whose version does not follow the
// stored one reports changed=false and returns the row that won.
type fakeRatingStore struct {
signals map[int64]domain.AccountRatingSignals
ratings map[int64]domain.AccountRating
manual map[int64]int64
events map[int64][]domain.AccountRatingEvent
keys map[string]domain.AccountRatingEvent
stale []int64
staleOlderThan int64
staleLimit int
unrated []int64
unratedLimit int
unratedCalls int
unratedErr error
saves []domain.AccountRating
forceConflicts int
signalsErr error
}
func newFakeRatingStore() *fakeRatingStore {
return &fakeRatingStore{
signals: map[int64]domain.AccountRatingSignals{},
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
events: map[int64][]domain.AccountRatingEvent{},
keys: map[string]domain.AccountRatingEvent{},
}
}
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
for _, userID := range userIDs {
if rating, ok := f.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
f.saves = append(f.saves, rating)
current := f.ratings[rating.UserID]
if f.forceConflicts > 0 {
f.forceConflicts--
return current, false, nil
}
if rating.Version != current.Version+1 {
return current, false, nil
}
f.ratings[rating.UserID] = rating
return rating, true, nil
}
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if f.signalsErr != nil {
return domain.AccountRatingSignals{}, f.signalsErr
}
signals := f.signals[userID]
signals.UserID = userID
signals.Manual = f.manual[userID]
return signals, nil
}
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if req.CommandKey != "" {
if event, ok := f.keys[req.CommandKey]; ok {
return event, false, nil
}
}
event := domain.AccountRatingEvent{
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
}
f.events[req.UserID] = append(f.events[req.UserID], event)
f.manual[req.UserID] += req.Amount
if req.CommandKey != "" {
f.keys[req.CommandKey] = event
}
return event, true, nil
}
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
out := make([]domain.AccountRating, 0, len(f.ratings))
for _, rating := range f.ratings {
if rating.Level >= filter.MinLevel {
out = append(out, rating)
}
if len(out) >= filter.Limit {
break
}
}
return out, nil
}
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
events := f.events[userID]
if len(events) > limit {
events = events[:limit]
}
return append([]domain.AccountRatingEvent(nil), events...), nil
}
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
f.staleOlderThan = olderThanUnix
f.staleLimit = limit
if len(f.stale) > limit {
return append([]int64(nil), f.stale[:limit]...), nil
}
return append([]int64(nil), f.stale...), nil
}
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
f.unratedCalls++
f.unratedLimit = limit
if f.unratedErr != nil {
return nil, f.unratedErr
}
out := make([]int64, 0, len(f.unrated))
for _, userID := range f.unrated {
if _, rated := f.ratings[userID]; rated {
continue
}
out = append(out, userID)
if len(out) == limit {
break
}
}
return out, nil
}
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
return NewService(append(base, opts...)...)
}
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}
service := newTestService(st)
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
weights := domain.DefaultAccountRatingWeights()
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}, weights, testNow)
if rating.Stars != want.Stars || rating.Level != want.Level ||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
rating.PenaltyComponent != want.PenaltyComponent {
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
}
if rating.Version != 1 {
t.Fatalf("first stored version = %d, want 1", rating.Version)
}
if !rating.ComputedAt.Equal(testNow) {
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
}
}
func TestRecomputePendingPolicy(t *testing.T) {
t.Run("increase is parked", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 100 {
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
}
if rating.PendingStars != 400 {
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
}
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
}
if rating.Version != 5 {
t.Fatalf("version = %d, want 5", rating.Version)
}
})
t.Run("decrease applies immediately", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 0 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
}
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
}
})
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{
UserID: 7, Stars: 100, Level: 1, Version: 2,
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
}
})
t.Run("zero delay never parks", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
}
})
}
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 1
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
}
if rating.Version != 4 || rating.Stars != 200 {
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
}
}
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 2
service := newTestService(st)
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute reported success while every save lost the version race")
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
}
}
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
})
if err != nil || !applied {
t.Fatalf("Adjust = %v, %v", applied, err)
}
if rating.ManualComponent != 300 || rating.Stars != 400 {
t.Fatalf("rating = %#v, want the manual component folded in", rating)
}
if len(st.events[7]) != 1 {
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
}
}
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
first, applied, err := service.Adjust(context.Background(), req)
if err != nil || !applied {
t.Fatalf("first Adjust = %v, %v", applied, err)
}
second, applied, err := service.Adjust(context.Background(), req)
if err != nil {
t.Fatalf("replayed Adjust: %v", err)
}
if applied {
t.Fatal("replayed Adjust reported applied=true")
}
if len(st.events[7]) != 1 || st.manual[7] != 300 {
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
}
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
}
}
func TestAdjustValidatesRequest(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st)
tests := []domain.AdjustAccountRatingRequest{
{UserID: 0, Amount: 10},
{UserID: 7, Amount: 0},
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
}
for _, req := range tests {
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
}
}
if len(st.events) != 0 || len(st.saves) != 0 {
t.Fatal("store was touched by an invalid adjustment")
}
}
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2, 3}
for _, userID := range st.stale {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st, WithStaleAfter(6*time.Hour))
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want 3", processed)
}
if st.staleLimit != 10 {
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
}
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
}
for _, userID := range st.stale {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("user %d was not recomputed", userID)
}
}
}
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 0, 2}
st.forceConflicts = 2 // both saves of the first user lose the race
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 0)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the surviving user only", processed)
}
if st.staleLimit != defaultRecomputeBatch {
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
}
}
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
service := newTestService(st, WithEnabled(false))
if service.Enabled() || service.Ready() {
t.Fatal("disabled service reported enabled/ready")
}
// The userFull projection omits both TL flags on this error, which is exactly
// the pre-rating wire shape.
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
}
batch, err := service.RatingBatch(context.Background(), []int64{7})
if err != nil || len(batch) != 0 {
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
}
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil || processed != 0 {
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
}
}
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
service := NewService()
if service.Ready() {
t.Fatal("Ready = true without a store")
}
if _, err := service.Rating(context.Background(), 7); err == nil {
t.Fatal("Rating accepted a missing store")
}
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute accepted a missing store")
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
t.Fatal("Adjust accepted a missing store")
}
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
t.Fatal("RunRecomputeCycle accepted a missing store")
}
}
func TestNilServiceIsSafe(t *testing.T) {
var service *Service
if service.Enabled() || service.Ready() {
t.Fatal("nil service reported enabled/ready")
}
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("nil service weights = %#v, want the defaults", got)
}
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
}
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
}
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
}
}
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
}
}
func TestListAndEventsBoundThePage(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
for i := range maxEventLimit + 10 {
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
}
service := newTestService(st)
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List = %d rows, want 1", len(list))
}
events, err := service.Events(context.Background(), 7, 100000)
if err != nil {
t.Fatalf("Events: %v", err)
}
if len(events) != maxEventLimit {
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
}
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Events accepted a zero user id")
}
}
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
}()
// The first cycle runs before the ticker, so cancelling immediately still
// leaves exactly one recompute behind.
<-time.After(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("worker did not stop on context cancellation")
}
if _, ok := st.ratings[1]; !ok {
t.Fatal("worker did not recompute the stale user")
}
}
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
done := make(chan struct{})
go func() {
defer close(done)
worker.Run(context.Background())
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("disabled worker kept running")
}
if worker.batch != defaultRecomputeBatch {
t.Fatalf("batch = %d, want the default fallback", worker.batch)
}
}
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
// so it can only ever refresh rows that already exist; without a seeding pass the
// very first row for a user has to come from an operator recomputing that user by
// hand, and the read model stays permanently empty.
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
st := newFakeRatingStore()
st.unrated = []int64{11, 12, 13}
for _, userID := range st.unrated {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the three seeded accounts", processed)
}
for _, userID := range st.unrated {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("account %d was not seeded", userID)
}
}
// A second cycle has nothing left to seed, so seeding converges instead of
// rewriting the same rows every interval.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
}
}
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
// must not let one cycle do an unbounded amount of work.
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2}
st.unrated = []int64{11, 12, 13, 14}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 3)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the batch bound of 3", processed)
}
if st.unratedLimit != 1 {
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
}
// A cycle whose stale pass already fills the batch does not query for seeds at
// all: refreshing rows somebody is looking at comes first.
full := newFakeRatingStore()
full.stale = []int64{1, 2, 3}
full.unrated = []int64{11}
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if full.unratedCalls != 0 {
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
}
}
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
// accounts must not turn a successful stale pass into a failed cycle.
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.unratedErr = errors.New("no users table")
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the one stale row", processed)
}
}
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
// when the worker has not reached an account yet, the first read materializes the
// local projection and the second read must not write again.
func TestEnsureRatingMaterializesOnce(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st)
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
}
rating, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("EnsureRating: %v", err)
}
if rating.UserID != 7 || rating.Stars == 0 {
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
}
writes := len(st.saves)
again, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("second EnsureRating: %v", err)
}
if again.Version != rating.Version {
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
}
if len(st.saves) != writes {
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
}
}
// A disabled feature materialises nothing. Telegram wire fields remain unset
// independently of this local feature flag.
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st, WithEnabled(false))
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
}
if len(st.saves) != 0 {
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
}
}
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
// rating in the first place -- and an operator must not be able to create one by
// hand either.
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
for _, userID := range domain.SystemUserIDs() {
st := newFakeRatingStore()
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
st.unrated = []int64{userID}
service := newTestService(st)
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
}
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
}
if len(st.ratings) != 0 {
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
}
// A seeding pass that is somehow handed one skips it rather than failing the
// whole cycle.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
}
}
// An ordinary account is unaffected.
st := newFakeRatingStore()
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
t.Fatalf("Recompute of an ordinary account: %v", err)
}
}

View file

@ -1,91 +0,0 @@
package rating
import (
"context"
"time"
"go.uber.org/zap"
)
const (
// defaultRecomputeInterval matches the shipped
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
defaultRecomputeInterval = 15 * time.Minute
)
// RecomputeWorker keeps the rating read model fresh.
//
// The projection is derived from signals that change outside the rating write
// path (Stars flow, message activity, moderation decisions, account age), so no
// single writer can keep it current. This worker walks the stale projections in
// bounded batches; it never recomputes the whole table in one pass, and a
// cancelled context stops it between users rather than mid-write.
type RecomputeWorker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
// interval/batch fall back to the shipped defaults, matching the retention
// worker's contract.
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultRecomputeInterval
}
if batch <= 0 {
batch = defaultRecomputeBatch
}
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run recomputes one batch immediately and then on every tick until ctx is
// done. A disabled or store-less service exits immediately with one explicit
// log line instead of ticking forever over a no-op.
func (w *RecomputeWorker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("account rating recompute worker disabled",
zap.Bool("enabled", w.service.Enabled()))
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *RecomputeWorker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("account rating recompute cycle failed",
zap.Int("processed", processed),
zap.Int("batch", w.batch),
zap.Error(err))
return
}
if processed > 0 {
w.logger.Info("account rating recompute cycle completed",
zap.Int("processed", processed),
zap.Int("batch", w.batch))
}
}

View file

@ -1,201 +0,0 @@
package stargifts
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"math"
"path/filepath"
"strings"
"time"
"telesrv/internal/domain"
)
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
// single canonical pair used by both the Telegram download path and admin preview.
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimation(fileName, data)
}
// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official
// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary
// operator uploads continue through PrepareAnimation and reject expressions.
func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimationWithPolicy(fileName, data, true)
}
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
return prepareAnimationWithPolicy(fileName, data, false)
}
func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) {
fileName = strings.TrimSpace(filepath.Base(fileName))
ext := strings.ToLower(filepath.Ext(fileName))
format := domain.StarGiftAnimationLottie
var rawJSON []byte
if ext == ".tgs" || isGzip(data) {
format = domain.StarGiftAnimationTGS
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
var err error
rawJSON, err = decompressSingleTGS(data)
if err != nil {
return domain.StarGiftAnimation{}, err
}
} else {
if ext != ".json" && ext != ".lottie" {
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
}
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
rawJSON = data
}
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
if err != nil {
return domain.StarGiftAnimation{}, err
}
tgs, err := gzipLottie(normalized)
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
}
sum := sha256.Sum256(tgs)
return domain.StarGiftAnimation{
SourceName: fileName,
SourceFormat: format,
JSON: normalized,
TGS: tgs,
SHA256: append([]byte(nil), sum[:]...),
Width: meta.W,
Height: meta.H,
FrameRate: meta.FrameRate,
InPoint: meta.InPoint,
OutPoint: meta.OutPoint,
}, nil
}
type lottieMetadata struct {
Version string `json:"v"`
W int `json:"w"`
H int `json:"h"`
FrameRate float64 `json:"fr"`
InPoint float64 `json:"ip"`
OutPoint float64 `json:"op"`
Layers []json.RawMessage `json:"layers"`
Assets []json.RawMessage `json:"assets"`
}
func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) {
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var root any
if err := dec.Decode(&root); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if _, ok := root.(map[string]any); !ok {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
if !allowExpressions && containsLottieExpression(root) {
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
}
var meta lottieMetadata
if err := json.Unmarshal(data, &meta); err != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
frameSpan := meta.OutPoint - meta.InPoint
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
// pre-composition assets with only an id/layers payload remain valid.
for _, raw := range meta.Assets {
var asset map[string]json.RawMessage
if json.Unmarshal(raw, &asset) != nil {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
for _, key := range []string{"p", "u"} {
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
}
}
}
var compact bytes.Buffer
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
}
return compact.Bytes(), meta, nil
}
func containsLottieExpression(value any) bool {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
if key == "x" {
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
return true
}
}
if containsLottieExpression(child) {
return true
}
}
case []any:
for _, child := range node {
if containsLottieExpression(child) {
return true
}
}
}
return false
}
func isGzip(data []byte) bool {
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
}
func decompressSingleTGS(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
gz, err := gzip.NewReader(reader)
if err != nil {
return nil, domain.ErrStarGiftFileInvalid
}
gz.Multistream(false)
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
closeErr := gz.Close()
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
return nil, domain.ErrStarGiftFileInvalid
}
return raw, nil
}
func gzipLottie(data []byte) ([]byte, error) {
var out bytes.Buffer
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
if err != nil {
return nil, err
}
gz.Header.ModTime = time.Unix(0, 0)
gz.Header.OS = 255
if _, err := gz.Write(data); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}

View file

@ -1,175 +0,0 @@
package stargifts
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
if err != nil {
t.Fatalf("prepare lottie: %v", err)
}
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
t.Fatalf("prepared lottie = %+v", fromJSON)
}
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
if err != nil {
t.Fatalf("prepare tgs: %v", err)
}
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
}
}
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
for name, raw := range map[string]string{
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
}
})
}
}
type testGiftBlob struct{ data map[string][]byte }
func (b *testGiftBlob) Name() string { return "localfs" }
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation,
})
if err != nil {
t.Fatalf("create first: %v", err)
}
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
t.Fatalf("current=%+v found=%v", current, found)
}
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "OwpenGram Pin" {
t.Fatalf("historical=%+v found=%v", historical, found)
}
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
}
}
func TestCreateCatalogBundleRejectsMismatchedOfficialProvenance(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
hash := make([]byte, sha256.Size)
_, err = svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, Title: "Official", Animation: animation,
OfficialGiftID: 10, SourceManifestSHA256: hash, OfficialSourceJSON: []byte(`{"id":10}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
OfficialGiftID: 11, SourceManifestSHA256: hash,
},
})
if !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("mismatched provenance err=%v, want ErrStarGiftCollectibleInvalid", err)
}
}
func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareOfficialAnimation("official.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
manifestSHA := make([]byte, sha256.Size)
result, err := svc.CreateCatalogBundle(ctx, domain.StarGiftCatalogBundleWrite{
Catalog: domain.StarGiftCatalogWrite{
Title: "Official", Stars: 50, ConvertStars: 25, Enabled: true, Animation: animation,
Actor: "test", CommandID: "official-catalog", OfficialGiftID: 10,
SourceManifestSHA256: manifestSHA, OfficialSourceJSON: []byte(`{"id":10}`),
},
Collectible: &domain.StarGiftCollectibleWrite{
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
},
Patterns: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
},
Backdrops: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
},
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
SourceManifestSHA256: manifestSHA,
},
})
if err != nil {
t.Fatalf("create official collectible bundle: %v", err)
}
if result.Collectible == nil || len(result.Collectible.Models) != 2 || len(result.Collectible.Patterns) != 2 {
t.Fatalf("collectible result = %+v", result.Collectible)
}
model := result.Collectible.Models[0].Document
pattern := result.Collectible.Patterns[0].Document
if model == nil || !model.IsSticker() || model.IsCustomEmoji() {
t.Fatalf("model document = %+v, want ordinary sticker", model)
}
if pattern == nil || pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
t.Fatalf("pattern document = %+v, want text-color custom emoji with inline path", pattern)
}
if !pattern.Attributes[1].TextColor {
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
}
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
}
}

View file

@ -1,34 +0,0 @@
package stargifts
import (
"bytes"
"testing"
"telesrv/internal/domain"
)
func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) {
pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern)
if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor {
t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern)
}
model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel)
if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor {
t.Fatalf("model attributes = %+v, want ordinary sticker", model)
}
}
func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) {
pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern)
if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath ||
pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern)
}
pattern[0].Bytes[0] ^= 0xff
if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) {
t.Fatal("collectibleDocumentThumbs returned shared mutable bytes")
}
if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 {
t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model)
}
}

View file

@ -1,50 +0,0 @@
package stargifts
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
)
const localWithdrawalTTL = 15 * time.Minute
// LocalWithdrawalProvider implements the TON/export UX entirely inside
// telesrv. It mints an unguessable, short-lived bearer URL; no external
// blockchain, Fragment endpoint, wallet or network RPC is contacted.
type LocalWithdrawalProvider struct {
publicBaseURL string
}
func NewLocalWithdrawalProvider(publicBaseURL string) (*LocalWithdrawalProvider, error) {
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
parsed, err := url.Parse(publicBaseURL)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, fmt.Errorf("invalid local star gift withdrawal base URL")
}
return &LocalWithdrawalProvider{publicBaseURL: publicBaseURL}, nil
}
func (p *LocalWithdrawalProvider) Name() string { return "telesrv-local" }
func (p *LocalWithdrawalProvider) CreateWithdrawal(_ context.Context, _ StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error) {
if p == nil || p.publicBaseURL == "" {
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("local star gift withdrawal provider is not configured")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return StarGiftWithdrawalProviderResult{}, fmt.Errorf("generate local withdrawal token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
return StarGiftWithdrawalProviderResult{
RequestID: token,
URL: p.publicBaseURL + "/gift-withdrawal/" + url.PathEscape(token),
ExpiresAt: int(time.Now().Add(localWithdrawalTTL).Unix()),
}, nil
}
var _ StarGiftWithdrawalProvider = (*LocalWithdrawalProvider)(nil)

View file

@ -1,34 +0,0 @@
package stargifts
import (
"context"
"strings"
"testing"
"time"
)
func TestLocalWithdrawalProviderIsInternalAndBounded(t *testing.T) {
for _, invalid := range []string{"", "ftp://example.test", "https://user@example.test", "https://example.test/?token=bad", "https://example.test/#bad"} {
if _, err := NewLocalWithdrawalProvider(invalid); err == nil {
t.Fatalf("invalid withdrawal base URL %q accepted", invalid)
}
}
provider, err := NewLocalWithdrawalProvider("https://example.test/base/")
if err != nil {
t.Fatal(err)
}
before := time.Now()
result, err := provider.CreateWithdrawal(context.Background(), StarGiftWithdrawalProviderRequest{})
if err != nil {
t.Fatal(err)
}
if provider.Name() != "telesrv-local" || len(result.RequestID) != 43 ||
result.URL != "https://example.test/base/gift-withdrawal/"+result.RequestID ||
strings.ContainsAny(result.RequestID, "+/=") {
t.Fatalf("local withdrawal result = %+v", result)
}
expires := time.Unix(int64(result.ExpiresAt), 0)
if expires.Before(before.Add(14*time.Minute)) || expires.After(before.Add(16*time.Minute)) {
t.Fatalf("local withdrawal expiry = %v, want about 15 minutes", expires)
}
}

View file

@ -1,56 +0,0 @@
package stargifts_test
import (
"context"
"os"
"testing"
"telesrv/internal/app/stargifts"
"telesrv/internal/officialgifts"
)
// This opt-in test is run by the official import audit. It validates every distinct base,
// model and pattern document with the trusted official animation policy, including the
// small set of Telegram-authored expression animations.
func TestConfiguredOfficialSnapshotAnimations(t *testing.T) {
root := os.Getenv("TELESRV_TEST_OFFICIAL_GIFTS_DIR")
if root == "" {
t.Skip("TELESRV_TEST_OFFICIAL_GIFTS_DIR is not set")
}
catalog := officialgifts.New(root)
items, err := catalog.List(context.Background())
if err != nil {
t.Fatal(err)
}
service := &stargifts.Service{}
seen := map[int64]struct{}{}
validate := func(document officialgifts.Document) {
t.Helper()
if _, ok := seen[document.ID]; ok {
return
}
seen[document.ID] = struct{}{}
if _, err := service.PrepareOfficialAnimation(document.FileName, document.Data); err != nil {
t.Fatalf("document %d (%s): %v", document.ID, document.Path, err)
}
}
for _, item := range items {
bundle, err := catalog.Bundle(context.Background(), item.ID, item.ModelCount+item.PatternCount+item.BackdropCount > 0)
if err != nil {
t.Fatalf("gift %d: %v", item.ID, err)
}
validate(bundle.BaseDocument)
if bundle.Collectible == nil {
continue
}
for _, model := range bundle.Collectible.Models {
validate(model.Document)
}
for _, pattern := range bundle.Collectible.Patterns {
validate(pattern.Document)
}
}
if len(seen) != 8333 {
t.Fatalf("validated %d documents, want 8333", len(seen))
}
}

View file

@ -1,980 +0,0 @@
// Package stargifts implements the durable Star Gift catalog and received-gift state.
package stargifts
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"telesrv/internal/branding"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// BlobBackend is the content-addressed media boundary used by the catalog importer.
type BlobBackend interface {
Name() string
Put(ctx context.Context, data []byte) (string, error)
Get(ctx context.Context, objectKey string) ([]byte, error)
}
type Service struct {
store store.StarGiftStore
upgrades store.StarGiftUpgradeStore
lifecycle store.StarGiftLifecycleStore
withdrawal StarGiftWithdrawalProvider
blobs BlobBackend
dc int
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
formMu sync.Mutex
forms map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm
}
type starGiftPurchaseFormKey struct {
buyerUserID int64
formID int64
}
// AtomicPurchaseConfigured reports whether the production aggregate
// coordinator is installed. It lets the RPC package keep its isolated memory
// test adapter without silently downgrading PostgreSQL deployments.
func (s *Service) AtomicPurchaseConfigured() bool { return s != nil && s.lifecycle != nil }
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func WithLifecycleStore(lifecycle store.StarGiftLifecycleStore) Option {
return func(service *Service) { service.lifecycle = lifecycle }
}
type StarGiftWithdrawalProvider interface {
Name() string
CreateWithdrawal(ctx context.Context, req StarGiftWithdrawalProviderRequest) (StarGiftWithdrawalProviderResult, error)
}
type StarGiftWithdrawalProviderRequest struct {
UserID int64
Gift domain.UniqueStarGift
}
type StarGiftWithdrawalProviderResult struct {
RequestID string
URL string
ExpiresAt int
}
func WithWithdrawalProvider(provider StarGiftWithdrawalProvider) Option {
return func(service *Service) { service.withdrawal = provider }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc, forms: make(map[starGiftPurchaseFormKey]domain.StarGiftPurchaseForm)}
for _, opt := range opts {
opt(service)
}
return service
}
func (s *Service) ensureCatalog(ctx context.Context) error {
s.mu.RLock()
built := s.built
s.mu.RUnlock()
if built {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.built {
return nil
}
if s.store == nil {
return fmt.Errorf("star gift store is not configured")
}
gifts, err := s.store.Catalog(ctx)
if err != nil {
return err
}
s.gifts = gifts
s.byID = make(map[int64]domain.StarGift, len(gifts))
for _, gift := range gifts {
s.byID[gift.ID] = gift
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return append([]domain.StarGift(nil), s.gifts...), nil
}
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash, nil
}
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
if err := s.ensureCatalog(ctx); err != nil {
return domain.StarGift{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
gift, ok := s.byID[id]
return gift, ok, nil
}
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if s == nil || s.store == nil {
return domain.StarGift{}, false, nil
}
return s.store.CatalogRevision(ctx, revisionID)
}
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
func (s *Service) InvalidateStarGiftCatalog() {
if s == nil {
return
}
s.mu.Lock()
s.built = false
s.gifts = nil
s.byID = nil
s.hash = 0
s.mu.Unlock()
}
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
}
write.Title = branding.UserVisibleText(strings.TrimSpace(write.Title), "")
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
if err := s.materializeCatalogWrite(ctx, &write); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) materializeCatalogWrite(ctx context.Context, write *domain.StarGiftCatalogWrite) error {
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
AccessHash: accessHash,
FileReference: fileReference,
Date: int(time.Now().Unix()),
MimeType: "application/x-tgsticker",
Size: int64(len(write.Animation.TGS)),
DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
},
}
write.Blob = domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(write.Animation.TGS)),
SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker",
}
return nil
}
// CreateCatalogBundle materializes every verified asset before publishing both active
// revision pointers in one store transaction. Blob writes are content-addressed and may be
// safely orphaned for later GC if the database transaction fails.
func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured")
}
write.Catalog.Title = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "")
write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "")
if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars ||
write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 ||
len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
var officialSource map[string]any
if write.Catalog.OfficialGiftID < 0 {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID > 0 && (len(write.Catalog.SourceManifestSHA256) != 32 ||
json.Unmarshal(write.Catalog.OfficialSourceJSON, &officialSource) != nil || officialSource == nil) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Catalog.OfficialGiftID == 0 && (len(write.Catalog.SourceManifestSHA256) != 0 || len(write.Catalog.OfficialSourceJSON) != 0) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftInvalid
}
if write.Collectible != nil {
write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix))
brandCollectibleAttributes(write.Collectible.Models)
brandCollectibleAttributes(write.Collectible.Patterns)
brandCollectibleAttributes(write.Collectible.Backdrops)
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
}
validation := *write.Collectible
if validation.GiftID == 0 {
validation.GiftID = write.Catalog.GiftID
if validation.GiftID == 0 {
validation.GiftID = 1
}
}
if err := domain.ValidateStarGiftCollectibleDraft(validation); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
if err := s.materializeCatalogWrite(ctx, &write.Catalog); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if write.Collectible != nil {
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Models); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
if err := s.materializeCollectibleAttributes(ctx, write.Collectible.Patterns); err != nil {
return domain.StarGiftCatalogBundleResult{}, err
}
}
result, err := s.store.CreateCatalogBundle(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
return s.store.AnimationJSON(ctx, giftID)
}
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
}
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return revision, err
}
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
// atomically publishes the complete immutable attribute pool. Callers must pass animations
// produced by PrepareAnimation; partial revisions are never exposed to clients.
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
}
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := s.materializeCollectibleAttributes(ctx, write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := s.materializeCollectibleAttributes(ctx, write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) {
for i := range attributes {
attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "")
}
}
func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: collectibleDocumentAttributes(attributes[i].Kind),
Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder.
// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an
// animated sticker only when document.thumbs is non-empty. The placeholder is
// not used as the rendered collectible pattern: after classification Android
// downloads and decodes the document's full TGS first frame. Keeping the
// placeholder inline avoids introducing a second downloadable blob and matches
// the shape used by official animated-sticker documents.
var collectiblePatternPathThumb = []byte{
0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e,
0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c,
0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3,
0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c,
0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90,
0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86,
0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79,
0x87, 0x03, 0x47, 0x06, 0x87, 0x03,
}
func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize {
if kind != domain.StarGiftCollectiblePattern {
return nil
}
return []domain.PhotoSize{{
Kind: domain.PhotoSizeKindPath,
Type: "j",
Bytes: append([]byte(nil), collectiblePatternPathThumb...),
}}
}
func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute {
renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"}
if kind == domain.StarGiftCollectiblePattern {
// DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the
// pattern is a text-color custom emoji. Without this the gradient is
// visible but the collectible pattern is rendered with its raw fill.
renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true}
}
return []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
renderAttribute,
{Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"},
}
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
return s.collectiblePreview(ctx, giftID, 0)
}
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
// payments.getStarGiftUpgradeAttributes and the admin editor.
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
const attributesPerKind = 3
return s.collectiblePreview(ctx, giftID, attributesPerKind)
}
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
return domain.StarGiftUpgradePreview{
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
SlugPrefix: revision.SlugPrefix,
}, true, nil
}
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
if s == nil || s.store == nil || len(giftIDs) == 0 {
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
}
return s.store.CollectibleAvailability(ctx, giftIDs)
}
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.store == nil {
return nil, false, nil
}
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueBySlug(ctx, slug)
}
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueByID(ctx, uniqueGiftID)
}
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
return map[int64]domain.UniqueStarGift{}, nil
}
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
}
func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || owner.ID <= 0 ||
(owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 {
return []domain.UniqueStarGift{}, nil
}
return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit))
}
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.UpgradeStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeReceipt{}, false, nil
}
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
}
// GrantUnique atomically assigns a freshly minted collectible to a user.
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
if s == nil || s.upgrades == nil {
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
}
result, err := s.lifecycle.PurchaseStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
// IssuePurchaseForm creates one fresh payment intent. PostgreSQL persists the
// intent so server restarts cannot turn a valid checkout into an unbound
// payment. The bounded in-memory branch exists only for isolated RPC tests.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error) {
if !validPurchaseForm(form) {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftFormPurposeInvalid
}
if s != nil && s.lifecycle != nil {
return s.lifecycle.IssueStarGiftPurchaseForm(ctx, form)
}
if s == nil {
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
s.formMu.Lock()
defer s.formMu.Unlock()
for key, existing := range s.forms {
if existing.ExpiresAt < form.IssuedAt {
delete(s.forms, key)
}
}
for attempt := 0; attempt < 8; attempt++ {
formID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftPurchaseForm{}, err
}
key := starGiftPurchaseFormKey{buyerUserID: form.BuyerUserID, formID: formID}
if _, exists := s.forms[key]; exists {
continue
}
form.FormID = formID
s.forms[key] = form
return form, nil
}
return domain.StarGiftPurchaseForm{}, domain.ErrStarGiftUnavailable
}
// ValidatePurchaseForm is a read-only preflight used for precise RPC errors.
// The PostgreSQL purchase transaction repeats this validation while holding a
// row lock; callers must not treat this preflight as the atomicity boundary.
func (s *Service) ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error {
if s != nil && s.lifecycle != nil {
return s.lifecycle.ValidateStarGiftPurchaseForm(ctx, req)
}
if s == nil || req.FormID == 0 {
return domain.ErrStarGiftFormExpired
}
s.formMu.Lock()
defer s.formMu.Unlock()
form, ok := s.forms[starGiftPurchaseFormKey{buyerUserID: req.BuyerUserID, formID: req.FormID}]
if !ok || form.ExpiresAt < req.Date {
return domain.ErrStarGiftFormExpired
}
return validatePurchaseFormIntent(form, req)
}
func validPurchaseForm(form domain.StarGiftPurchaseForm) bool {
return form.FormID == 0 && form.BuyerUserID > 0 && form.To.ID > 0 &&
(form.To.Type == domain.PeerTypeUser || form.To.Type == domain.PeerTypeChannel) &&
form.GiftID > 0 && form.RevisionID > 0 && form.ChargeStars > 0 && form.IssuedAt > 0 &&
form.ExpiresAt == form.IssuedAt+600 && len([]rune(form.Message)) <= 128
}
func validatePurchaseFormIntent(form domain.StarGiftPurchaseForm, req domain.StarGiftPurchaseRequest) error {
if form.BuyerUserID != req.BuyerUserID || form.To != req.To || form.GiftID != req.GiftID ||
form.IncludeUpgrade != req.IncludeUpgrade || form.HideName != req.HideName || form.Message != req.Message {
return domain.ErrStarGiftFormPurposeInvalid
}
if form.RevisionID != req.RevisionID || form.ChargeStars != req.ChargeStars {
return domain.ErrStarGiftFormAmountMismatch
}
return nil
}
func (s *Service) ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftResalePage{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.ListResaleStarGifts(ctx, filter)
}
func (s *Service) ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftValueInfo{}, domain.ErrStarGiftResaleUnavailable
}
return s.lifecycle.UniqueStarGiftValueInfo(ctx, uniqueGiftID)
}
func (s *Service) SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error) {
if s == nil || s.lifecycle == nil {
return domain.UniqueStarGift{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.SetStarGiftListing(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftTransferUnavailable
}
return s.lifecycle.TransferStarGift(ctx, req)
}
func (s *Service) PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftTransferResult{}, domain.ErrStarGiftResaleUnavailable
}
result, err := s.lifecycle.PurchaseResaleStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.SendStarGiftOffer(ctx, req)
}
func (s *Service) ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftOfferResult{}, domain.ErrStarGiftOfferInvalid
}
return s.lifecycle.ResolveStarGiftOffer(ctx, req)
}
func (s *Service) ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGiftPage{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.ListCraftStarGifts(ctx, userID, giftID, offset, limit)
}
func (s *Service) Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
return s.lifecycle.CraftStarGift(ctx, req)
}
func (s *Service) AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionState(ctx, userID, giftID, slug, now)
}
func (s *Service) ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.ActiveStarGiftAuctions(ctx, userID, now)
}
func (s *Service) AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error) {
if s == nil || s.lifecycle == nil {
return nil, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.StarGiftAuctionAcquired(ctx, userID, giftID)
}
func (s *Service) BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftAuction{}, domain.StarsBalance{}, domain.ErrStarGiftAuctionUnavailable
}
return s.lifecycle.BidStarGiftAuction(ctx, req)
}
func (s *Service) PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error) {
if s == nil || s.lifecycle == nil {
return domain.SavedStarGift{}, 0, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepaidUpgradeTarget(ctx, owner, hash)
}
func (s *Service) PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPrepaidUpgradeResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.PrepayStarGiftUpgrade(ctx, req)
}
func (s *Service) DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftDropOriginalDetailsResult{}, domain.ErrStarGiftCollectibleUnavailable
}
return s.lifecycle.DropStarGiftOriginalDetails(ctx, req)
}
func (s *Service) SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error {
if s == nil || s.lifecycle == nil {
return domain.ErrStarGiftUnavailable
}
return s.lifecycle.SetStarGiftNotifications(ctx, userID, channelID, enabled)
}
func (s *Service) NotificationsEnabled(ctx context.Context, userID, channelID int64) (bool, error) {
if s == nil {
return false, domain.ErrStarGiftUnavailable
}
if s.lifecycle == nil {
// Isolated memory/RPC adapters have no settings table; production's
// persisted default is enabled, so preserve that wire behavior.
return true, nil
}
return s.lifecycle.StarGiftNotificationsEnabled(ctx, userID, channelID)
}
func (s *Service) ResolveUserMessageRef(ctx context.Context, viewerUserID int64, msgID int) (domain.SavedStarGiftRef, bool, error) {
if s == nil || s.store == nil {
return domain.SavedStarGiftRef{}, false, nil
}
return s.store.ResolveUserMessageRef(ctx, viewerUserID, msgID)
}
func (s *Service) Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil || s.withdrawal == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
saved, found, err := s.store.GetByRef(ctx, req.Ref)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
saved.UniqueGiftID == 0 || !saved.LifecycleStatus.Live() || saved.CanExportAt > req.Date {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
unique, found, err := s.store.UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found || unique.Burned || unique.Owner != saved.Owner {
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftTransferUnavailable
}
providerResult, err := s.withdrawal.CreateWithdrawal(ctx, StarGiftWithdrawalProviderRequest{UserID: req.UserID, Gift: unique})
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
if strings.TrimSpace(providerResult.RequestID) == "" || strings.TrimSpace(providerResult.URL) == "" || providerResult.ExpiresAt <= req.Date {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
recorded, err := s.lifecycle.RecordStarGiftWithdrawal(ctx, req, s.withdrawal.Name(), providerResult.RequestID, providerResult.URL, providerResult.ExpiresAt)
if err != nil {
return domain.StarGiftWithdrawal{}, err
}
return recorded, nil
}
func (s *Service) ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, false, nil
}
return s.lifecycle.ResolveStarGiftWithdrawal(ctx, providerRequestID)
}
func (s *Service) CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftWithdrawal{}, domain.ErrStarGiftWithdrawalUnavailable
}
return s.lifecycle.CompleteStarGiftWithdrawal(ctx, providerRequestID, date)
}
func (s *Service) TonBalance(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.TonBalance(ctx, userID)
}
func (s *Service) TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
return s.lifecycle.TonTransactions(ctx, userID, query)
}
func (s *Service) ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelStarsBalance(ctx, channelID)
}
func (s *Service) ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.StarsTransactionPage{}, nil
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
return s.lifecycle.ChannelStarsTransactions(ctx, channelID, query)
}
func (s *Service) ChannelTonBalance(ctx context.Context, channelID int64) (int64, error) {
if s == nil || s.lifecycle == nil {
return 0, nil
}
return s.lifecycle.ChannelTonBalance(ctx, channelID)
}
func (s *Service) ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if s == nil || s.lifecycle == nil {
return domain.TonTransactionPage{}, nil
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.TonTransactionPage{}, err
}
return s.lifecycle.ChannelTonTransactions(ctx, channelID, query)
}
func (s *Service) SweepLifecycle(ctx context.Context, now, limit int) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.SweepStarGiftLifecycle(ctx, now, limit)
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
}
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
}
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
return s.store.DeleteCollection(ctx, owner, collectionID)
}
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return s.store.ReorderCollections(ctx, owner, collectionIDs)
}
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return s.store.SetPinned(ctx, owner, savedGiftIDs)
}
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
if err != nil {
return 0, err
}
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
}
gift.PrepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
}
return s.store.Create(ctx, gift)
}
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
offset := filter.Offset
if len(offset) > domain.MaxStarGiftsOffsetBytes {
filter.Offset = ""
}
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
filter.Limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwnerFiltered(ctx, filter)
}
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
return s.store.ResolveSavedIDs(ctx, owner, refs)
}
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
return s.store.CountByOwner(ctx, owner)
}
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert keeps the in-memory/catalog store primitive available to isolated
// tests and non-production adapters. RPC production paths must use
// ConvertAggregate so balance credit and terminal state cannot split.
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func (s *Service) ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftConvertResult{}, domain.ErrStarGiftUnavailable
}
return s.lifecycle.ConvertStarGift(ctx, req)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, fmt.Errorf("generate star gift id: %w", err)
}
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if id == 0 {
id = 1
}
return id, nil
}

View file

@ -1,147 +0,0 @@
package stargifts
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
st := memory.NewStarGiftStore()
st.SeedCatalog(gifts)
return NewService(st, nil, 2), st
}
func TestCatalogCachedAndHash(t *testing.T) {
gifts := []domain.StarGift{
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
}
svc, _ := newTestService(gifts)
ctx := context.Background()
got, err := svc.Catalog(ctx)
if err != nil || len(got) != 2 {
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
}
// 再取一次命中进程内目录缓存。
if _, err := svc.Catalog(ctx); err != nil {
t.Fatalf("catalog#2: %v", err)
}
hash, err := svc.CatalogHash(ctx)
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
}
if g, ok, _ := svc.GiftByID(ctx, 2); !ok || g.Stars != 50 {
t.Fatalf("GiftByID(2) = %+v ok %v, want Cake 50", g, ok)
}
if _, ok, _ := svc.GiftByID(ctx, 999); ok {
t.Fatalf("GiftByID(999) found, want missing")
}
}
func TestSavedGiftLifecycle(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
})
if err != nil || id == 0 {
t.Fatalf("RecordSavedGift = %d err %v", id, err)
}
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
if err != nil || len(collection.GiftIDs) != 1 {
t.Fatalf("CreateCollection = %+v err %v", collection, err)
}
page, err := svc.ListSaved(ctx, owner, false, "", 100)
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
t.Fatalf("list = %d count %d err %v, want 1/1", len(page.Gifts), page.Count, err)
}
if page.NextOffset != "" {
t.Fatalf("single page next_offset = %q, want empty", page.NextOffset)
}
// 隐藏(unsave=true)→ excludeUnsaved 列表为空。
ref := domain.SavedStarGiftRef{Owner: owner, MsgID: 50}
if ok, err := svc.ToggleSaved(ctx, ref, true); err != nil || !ok {
t.Fatalf("ToggleSaved hide = %v err %v", ok, err)
}
hidden, _ := svc.ListSaved(ctx, owner, true, "", 100)
if len(hidden.Gifts) != 0 {
t.Fatalf("excludeUnsaved list = %d, want 0 after hide", len(hidden.Gifts))
}
// 不带 exclude 仍能看到。
all, _ := svc.ListSaved(ctx, owner, false, "", 100)
if len(all.Gifts) != 1 {
t.Fatalf("full list = %d, want 1 (hidden still listed)", len(all.Gifts))
}
// 转换回 Stars → 标记 converted,从列表消失。
saved, err := svc.Convert(ctx, ref)
if err != nil || saved.ConvertStars != 15 {
t.Fatalf("Convert = %+v err %v, want ConvertStars 15", saved, err)
}
after, _ := svc.ListSaved(ctx, owner, false, "", 100)
if len(after.Gifts) != 0 {
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
}
collections, err := svc.ListCollections(ctx, owner)
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
}
// 重复转换被拒。
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
}
}
func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
Date: 1700000000, ConvertStars: 15,
})
if err != nil || savedID == 0 {
t.Fatalf("RecordSavedGift(channel) = %d err %v, want allocated saved_id", savedID, err)
}
gift, found, err := svc.GetSaved(ctx, domain.SavedStarGiftRef{Owner: owner, SavedID: savedID})
if err != nil || !found {
t.Fatalf("GetSaved(channel) found=%v err=%v, want hit", found, err)
}
if gift.MsgID != 0 || gift.SavedID != savedID {
t.Fatalf("channel saved gift ids = msg_id %d saved_id %d, want 0/%d", gift.MsgID, gift.SavedID, savedID)
}
}
func TestSavedGiftPagination(t *testing.T) {
svc, _ := newTestService(nil)
ctx := context.Background()
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
for i := 0; i < 5; i++ {
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
}); err != nil {
t.Fatalf("record#%d: %v", i, err)
}
}
page1, _ := svc.ListSaved(ctx, owner, false, "", 2)
if len(page1.Gifts) != 2 || page1.NextOffset == "" {
t.Fatalf("page1 = %d next=%q, want 2 + next", len(page1.Gifts), page1.NextOffset)
}
page2, _ := svc.ListSaved(ctx, owner, false, page1.NextOffset, 2)
page3, _ := svc.ListSaved(ctx, owner, false, page2.NextOffset, 2)
if len(page3.Gifts) != 1 || page3.NextOffset != "" {
t.Fatalf("page3 = %d next=%q, want 1 + empty (terminal)", len(page3.Gifts), page3.NextOffset)
}
}

View file

@ -1,150 +0,0 @@
// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页,
// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、
// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。
package stars
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 是 Stars 账本应用服务。
type Service struct {
store store.StarsStore
purchaseStore store.StarsPurchaseStore
grantAmount int64
now func() time.Time
}
// Option 配置 Service。
type Option func(*Service)
// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。
func WithStartingGrant(amount int64) Option {
return func(s *Service) { s.grantAmount = amount }
}
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
return func(s *Service) { s.purchaseStore = st }
}
// WithClock 注入时钟(测试用)。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。
func NewService(st store.StarsStore, opts ...Option) *Service {
s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now}
for _, opt := range opts {
opt(s)
}
return s
}
// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。
func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) {
if s.grantAmount > 0 {
bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix()))
return bal, err
}
return s.store.GetBalance(ctx, userID)
}
// GetBalance 返回账号余额,首读时惰性授予起始余额。
func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) {
return s.ensureGranted(ctx, userID)
}
// Credit 为账号入账(amount>0)。
func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。
// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。
func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsBalance{}, err
}
return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc)
}
// ListTransactions 按方向与顺序做 keyset 分页,首读时惰性授予。
func (s *Service) ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
if _, err := s.ensureGranted(ctx, userID); err != nil {
return domain.StarsTransactionPage{}, err
}
return s.store.ListTransactions(ctx, userID, query)
}
// IssuePurchaseForm persists a short-lived, exact checkout intent.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s.purchaseStore == nil || !validPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
}
// Purchase settles one exact persisted form. Package validation remains at
// the RPC boundary as well, while the store revalidates the persisted tuple
// under lock before performing any write.
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.PurchaseStars(ctx, req)
}
// GetGiveawayInfo resolves one launch card from the same aggregate that
// persisted it. date is supplied by the RPC clock for deterministic tests.
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
}
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
}
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
return false
}
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
case domain.StarsPurchaseGift:
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
case domain.StarsPurchaseGiveaway:
g := form.Giveaway
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
default:
return false
}
}

View file

@ -1,209 +0,0 @@
package stars
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newTestService(grant int64) *Service {
return NewService(memory.NewStarsStore(), WithStartingGrant(grant))
}
// 起始授予幂等:多次 GetBalance 只授予一次。
func TestStartingGrantOnce(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
bal, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 1000 || !bal.Granted {
t.Fatalf("first balance = %+v, want 1000 granted", bal)
}
// 再读不应重复授予。
bal2, err := svc.GetBalance(ctx, 7)
if err != nil {
t.Fatalf("GetBalance#2: %v", err)
}
if bal2.Balance != 1000 {
t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance)
}
// 流水里应恰有一条 grant。
page, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 100})
if err != nil {
t.Fatalf("ListTransactions: %v", err)
}
if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 {
t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions)
}
}
// 关闭授予(grant=0)时余额为 0、无 grant 流水。
func TestGrantDisabled(t *testing.T) {
svc := newTestService(0)
bal, err := svc.GetBalance(context.Background(), 9)
if err != nil {
t.Fatalf("GetBalance: %v", err)
}
if bal.Balance != 0 || bal.Granted {
t.Fatalf("balance = %+v, want 0 not granted", bal)
}
}
// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。
func TestDebitAndInsufficient(t *testing.T) {
svc := newTestService(1000)
ctx := context.Background()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555}
bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "")
if err != nil {
t.Fatalf("Debit: %v", err)
}
if bal.Balance != 700 {
t.Fatalf("after debit = %d, want 700", bal.Balance)
}
// 余额不足。
if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err)
}
// 余额未被改动。
after, _ := svc.GetBalance(ctx, 7)
if after.Balance != 700 {
t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance)
}
// 非法金额。
if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) {
t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err)
}
}
// 贷记增加余额并写正流水。
func TestCredit(t *testing.T) {
svc := newTestService(0) // 关闭起始授予,单测贷记
ctx := context.Background()
bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "")
if err != nil {
t.Fatalf("Credit: %v", err)
}
if bal.Balance != 250 {
t.Fatalf("after credit = %d, want 250", bal.Balance)
}
}
// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。
func TestListTransactionsPagination(t *testing.T) {
svc := newTestService(0)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("Credit#%d: %v", i, err)
}
}
page1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 2})
if err != nil {
t.Fatalf("page1: %v", err)
}
if len(page1.Transactions) != 2 || page1.NextOffset == "" {
t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset)
}
// 倒序:最新(id 最大,amount=14)在前。
if page1.Transactions[0].Amount != 14 {
t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount)
}
page2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page1.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2.Transactions) != 2 {
t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions))
}
page3, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Offset: page2.NextOffset, Limit: 2})
if err != nil {
t.Fatalf("page3: %v", err)
}
if len(page3.Transactions) != 1 {
t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions))
}
if page3.NextOffset != "" {
t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset)
}
}
func TestListTransactionsDirectionAndAscending(t *testing.T) {
svc := newTestService(0)
ctx := context.Background()
if _, err := svc.Credit(ctx, 7, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 100: %v", err)
}
if _, err := svc.Debit(ctx, 7, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 40: %v", err)
}
if _, err := svc.Credit(ctx, 7, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 20: %v", err)
}
if _, err := svc.Debit(ctx, 7, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 10: %v", err)
}
all, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Limit: 10})
if err != nil {
t.Fatalf("all transactions: %v", err)
}
assertStarsAmounts(t, all.Transactions, []int64{-10, 20, -40, 100})
if all.Balance != 70 {
t.Fatalf("all balance = %d, want 70", all.Balance)
}
incoming1, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page1: %v", err)
}
assertStarsAmounts(t, incoming1.Transactions, []int64{20})
if incoming1.NextOffset == "" {
t.Fatal("incoming page1 missing next offset")
}
incoming2, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Offset: incoming1.NextOffset, Limit: 1, Direction: domain.StarsTransactionDirectionIncoming,
})
if err != nil {
t.Fatalf("incoming page2: %v", err)
}
assertStarsAmounts(t, incoming2.Transactions, []int64{100})
if incoming2.NextOffset != "" {
t.Fatalf("terminal incoming next offset = %q", incoming2.NextOffset)
}
outgoing, err := svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{
Limit: 10, Direction: domain.StarsTransactionDirectionOutgoing, Ascending: true,
})
if err != nil {
t.Fatalf("ascending outgoing: %v", err)
}
assertStarsAmounts(t, outgoing.Transactions, []int64{-40, -10})
_, err = svc.ListTransactions(ctx, 7, domain.StarsTransactionQuery{Direction: 99})
if !errors.Is(err, domain.ErrStarsTransactionQueryInvalid) {
t.Fatalf("invalid direction error = %v", err)
}
}
func assertStarsAmounts(t *testing.T, transactions []domain.StarsTransaction, want []int64) {
t.Helper()
if len(transactions) != len(want) {
t.Fatalf("transaction count = %d, want %d: %+v", len(transactions), len(want), transactions)
}
for i, amount := range want {
if transactions[i].Amount != amount {
t.Fatalf("transaction[%d].amount = %d, want %d", i, transactions[i].Amount, amount)
}
}
}

View file

@ -575,7 +575,7 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
// to the account's other sessions and offline difference stream.
func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
if !status.Valid() {
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrEmojiStatusCollectibleInvalid
}
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventUserEmojiStatus,

View file

@ -552,7 +552,7 @@ func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, s
return domain.User{}, err
}
if !status.Valid() {
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
return domain.User{}, domain.ErrEmojiStatusCollectibleInvalid
}
if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) {
return domain.User{}, domain.ErrPremiumRequired