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

@ -1,397 +0,0 @@
package domain
import (
"errors"
"time"
)
// Composite account rating.
//
// This is gramsrv's server-local account score. It deliberately uses its own
// inputs and thresholds (Stars, activity and moderation), rather than claiming
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
// stored level through userFull's existing rating fields so official clients can
// render it without a client patch.
const (
// MaxAccountRatingLevel bounds the local gramsrv level.
MaxAccountRatingLevel = 50
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
accountRatingLevelUnit = 100
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
MaxAccountRatingReasonLength = 512
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
MaxAccountRatingActorLength = 128
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
MaxAccountRatingCommandKeyLength = 128
)
var (
// ErrAccountRatingNotFound reports a user with no rating row yet.
ErrAccountRatingNotFound = errors.New("account rating not found")
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
)
// AccountRatingEventKind is the contribution source of a ledger row. Only
// 'manual' rows survive a full recompute; the rest are audit trail.
type AccountRatingEventKind string
const (
AccountRatingEventStars AccountRatingEventKind = "stars"
AccountRatingEventActivity AccountRatingEventKind = "activity"
AccountRatingEventModeration AccountRatingEventKind = "moderation"
AccountRatingEventManual AccountRatingEventKind = "manual"
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
)
// Valid reports whether the kind is modelled.
func (k AccountRatingEventKind) Valid() bool {
switch k {
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
AccountRatingEventManual, AccountRatingEventRecompute:
return true
default:
return false
}
}
// AccountRating is the stored read model for one user.
type AccountRating struct {
UserID int64
Level int
Stars int64
CurrentLevelStars int64
// NextLevelStars is meaningful only when HasNextLevel is true.
NextLevelStars int64
HasNextLevel bool
// Components are the explainable breakdown. PenaltyComponent is a
// non-negative magnitude that is subtracted.
StarsComponent int64
ActivityComponent int64
PenaltyComponent int64
ManualComponent int64
// PendingStars is a score delta not yet applied to the visible level, with
// PendingDate reporting when it becomes effective.
PendingStars int64
PendingDate time.Time
ComputedAt time.Time
UpdatedAt time.Time
Version int64
}
// AccountRatingLevel is the local client/admin-facing level snapshot.
type AccountRatingLevel struct {
Level int
CurrentLevelStars int64
Stars int64
NextLevelStars int64
HasNextLevelStars bool
}
// RatableAccount reports whether an account may carry a composite rating.
//
// The rating measures what an account did with Stars -- gifts bought, paid
// messages sent, activity, moderation history. Two kinds of account have no
// meaningful answer there and are excluded everywhere the rating is computed,
// seeded or projected:
//
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
// its score would only ever be the flat account-age term.
// - The built-in service accounts (the platform account, BotFather, @Stickers,
// @ChatBot, the verification bots). They are infrastructure rather than
// participants: a leaderboard entry for the platform account is noise, and a
// level badge on it would claim something about transaction volume that means
// nothing.
//
// Note that the platform account is not flagged is_bot, so the bot check alone
// does not cover it -- which is exactly how it ended up in the seeding pass.
func RatableAccount(userID int64, bot bool) bool {
return userID > 0 && !bot && !IsSystemUserID(userID)
}
// LevelSnapshot returns the current visible local level.
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
return AccountRatingLevel{
Level: r.Level,
CurrentLevelStars: r.CurrentLevelStars,
Stars: r.Stars,
NextLevelStars: r.NextLevelStars,
HasNextLevelStars: r.HasNextLevel,
}
}
// PendingLevel returns the local level after the pending score is applied and
// reports whether a pending record exists at all.
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
if r.PendingStars == 0 || r.PendingDate.IsZero() {
return AccountRatingLevel{}, false
}
total := r.Stars + r.PendingStars
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRatingLevel{
Level: level,
CurrentLevelStars: current,
Stars: total,
NextLevelStars: next,
HasNextLevelStars: hasNext,
}, true
}
// AccountRatingWeights is the composite formula. All weights are integers so the
// score is exactly reproducible across a recompute and across store backends.
type AccountRatingWeights struct {
// StarsReceivedPermille weighs Stars credited to the account (gifts,
// reactions, paid messages received), in permille of the raw amount.
StarsReceivedPermille int64
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
// signal than receiving, so the default is lower.
StarsSpentPermille int64
// PerMessageSent rewards sustained use.
PerMessageSent int64
// PerAccountAgeDay rewards account longevity.
PerAccountAgeDay int64
// PerGiftReceived rewards collectible gifts held.
PerGiftReceived int64
// PerModerationCase is the penalty for each upheld moderation case.
PerModerationCase int64
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
ScamPenalty int64
FakePenalty int64
// ActivityCap bounds the activity component so activity alone cannot
// outweigh everything else. Zero means uncapped.
ActivityCap int64
}
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
// activity contributes a bounded floor, and moderation subtracts.
func DefaultAccountRatingWeights() AccountRatingWeights {
return AccountRatingWeights{
StarsReceivedPermille: 1000,
StarsSpentPermille: 250,
PerMessageSent: 1,
PerAccountAgeDay: 2,
PerGiftReceived: 25,
PerModerationCase: 150,
ScamPenalty: 5000,
FakePenalty: 5000,
ActivityCap: 5000,
}
}
// Validate rejects negative weights and an impossible cap.
func (w AccountRatingWeights) Validate() error {
values := []int64{
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
}
for _, v := range values {
if v < 0 {
return ErrAccountRatingWeightsInvalid
}
}
return nil
}
// AccountRatingSignals is the raw snapshot gathered from the contributing
// sources for one user. It is deliberately a plain value: the same snapshot must
// produce the same score in a unit test and in production.
type AccountRatingSignals struct {
UserID int64
StarsReceived int64
StarsSpent int64
MessagesSent int64
AccountAgeDays int64
GiftsReceived int64
ModerationCases int64
Scam bool
Fake bool
// Manual is the sum of admin adjustments, carried across recomputes.
Manual int64
}
// ComputeAccountRating turns a signal snapshot into the read model. The score is
// clamped at zero: penalties can erase this local score but never invert it.
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
if err := weights.Validate(); err != nil {
weights = DefaultAccountRatingWeights()
}
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
activityComponent = weights.ActivityCap
}
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
if signals.Scam {
penalty += weights.ScamPenalty
}
if signals.Fake {
penalty += weights.FakePenalty
}
total := starsComponent + activityComponent + signals.Manual - penalty
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRating{
UserID: signals.UserID,
Level: level,
Stars: total,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: starsComponent,
ActivityComponent: activityComponent,
PenaltyComponent: penalty,
ManualComponent: signals.Manual,
ComputedAt: now,
UpdatedAt: now,
Version: 1,
}
}
// AccountRatingLevelThreshold returns the score needed to reach the given level.
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
// later ones stay meaningful.
func AccountRatingLevelThreshold(level int) int64 {
if level <= 0 {
return 0
}
if level > MaxAccountRatingLevel {
level = MaxAccountRatingLevel
}
n := int64(level)
return accountRatingLevelUnit * n * n
}
// AccountRatingLevelForStars maps a score onto the level and the surrounding
// thresholds. hasNext is false at MaxAccountRatingLevel.
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
if stars < 0 {
stars = 0
}
level = 0
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
if stars < AccountRatingLevelThreshold(candidate) {
break
}
level = candidate
}
currentLevelStars = AccountRatingLevelThreshold(level)
if level >= MaxAccountRatingLevel {
return level, currentLevelStars, 0, false
}
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
}
// ResolveAccountRatingPending decides whether a freshly computed score becomes
// visible immediately or is parked as pending.
//
// A score that dropped is applied at once -- a penalty must not sit behind a
// delay. A score that grew is parked until delay has elapsed; once the parked
// window has passed the pending delta is folded into the visible local rating.
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
out := computed
out.Version = prev.Version + 1
if out.Version <= 0 {
out.Version = 1
}
if delay <= 0 || prev.UserID == 0 {
return out
}
if computed.Stars <= prev.Stars {
return out
}
// A previously parked delta whose date has arrived is applied now.
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
return out
}
pendingSince := prev.PendingDate
if prev.PendingStars == 0 || pendingSince.IsZero() {
pendingSince = now.Add(delay)
}
visible := prev
visible.StarsComponent = computed.StarsComponent
visible.ActivityComponent = computed.ActivityComponent
visible.PenaltyComponent = computed.PenaltyComponent
visible.ManualComponent = computed.ManualComponent
visible.PendingStars = computed.Stars - prev.Stars
visible.PendingDate = pendingSince
visible.ComputedAt = now
visible.UpdatedAt = now
visible.Version = out.Version
return visible
}
// AccountRatingEvent is one contribution ledger row.
type AccountRatingEvent struct {
ID int64
UserID int64
Kind AccountRatingEventKind
Amount int64
Reason string
Actor string
CommandKey string
CreatedAt time.Time
}
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
type AdjustAccountRatingRequest struct {
UserID int64
Amount int64
Reason string
Actor string
CommandKey string
}
// Validate rejects a no-op or oversized adjustment.
func (r AdjustAccountRatingRequest) Validate() error {
if r.UserID <= 0 || r.Amount == 0 {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Reason) > MaxAccountRatingReasonLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Actor) > MaxAccountRatingActorLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
return ErrAccountRatingAdjustmentInvalid
}
return nil
}
// AccountRatingFilter bounds an admin listing query.
type AccountRatingFilter struct {
MinLevel int
UserID int64
BeforeID int64
Limit int
}
func permille(value, weight int64) int64 {
if value <= 0 || weight <= 0 {
return 0
}
return value * weight / 1000
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}

View file

@ -611,11 +611,6 @@ const (
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice
// 广播频道 Direct Messages 开关/价格变更的服务消息。
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUniquecollectible
// 升级、转赠等所有权变更只进入 Recent Actions不伪造频道历史/pts。
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
@ -655,9 +650,6 @@ type ChannelMessageAction struct {
Completed []int
Incompleted []int
TodoItems []MessageTodoItem
// StarGift 仅 star_gift 服务消息使用。
StarGift *MessageStarGiftAction
StarGiftUnique *MessageStarGiftUniqueAction
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
Wallpaper *Wallpaper
// Photo 仅 chat_edit_photo 服务消息使用。
@ -852,9 +844,6 @@ type ChannelMessageReactions struct {
AsTags bool
Results []ChannelMessageReactionCount
Recent []ChannelMessagePeerReaction
// Paid 是付费 reactionStars聚合nil = 无);读路径从 channel_message_paid_reactions
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
Paid *ChannelMessagePaidReactions
}
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
@ -1685,19 +1674,17 @@ func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
// monoforum; ServiceEvent is the approval/success/refund service message; an
// optional Published result is the broadcast post.
type ToggleSuggestedPostApprovalResult struct {
Monoforum Channel
Parent Channel
SavedPeer Peer
State SuggestedPostLifecycleState
OriginalMessage ChannelMessage
OriginalEvent ChannelUpdateEvent
ServiceMessage ChannelMessage
ServiceEvent ChannelUpdateEvent
Published *SendChannelMessageResult
Recipients []int64
PayerStarsBalance *StarsBalance
PayerTONBalance *int64
Duplicate bool
Monoforum Channel
Parent Channel
SavedPeer Peer
State SuggestedPostLifecycleState
OriginalMessage ChannelMessage
OriginalEvent ChannelUpdateEvent
ServiceMessage ChannelMessage
ServiceEvent ChannelUpdateEvent
Published *SendChannelMessageResult
Recipients []int64
Duplicate bool
}
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
@ -1824,8 +1811,6 @@ type SendChannelMessageResult struct {
Event ChannelUpdateEvent
Recipients []int64
Duplicate bool
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回RPC 只向发件人投影余额更新。
SenderStarsBalance *StarsBalance
// ReplayDeleteEvent is the existing durable channel delete event paired
// with a deleted exact-random_id replay. It must be returned only to the
// caller echo and must never be fanned out as a fresh event.

View file

@ -591,18 +591,6 @@ const (
// 状态切换与关闭请求。会话级保护不能写入普通消息的 NoForwards 字段。
MessageServiceActionNoForwardsToggle MessageServiceActionKind = "no_forwards_toggle"
MessageServiceActionNoForwardsRequest MessageServiceActionKind = "no_forwards_request"
// MessageServiceActionStarGift 映射 messageActionStarGift收到一份 Star 礼物。
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
// MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased
// Stars credited directly to a friend, distinct from collectible Star Gifts.
MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars"
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
// immutable collectible snapshot is carried by the service message so an
// exact replay/difference never depends on mutable catalog state.
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -685,92 +673,6 @@ type MessageServiceAction struct {
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
}
// MessageGiftStarsAction is the immutable service-message projection. The
// recipient-only BalanceAfter field is not encoded in messageActionGiftStars;
// it lets online push and offline difference attach the matching non-PTS
// updateStarsBalance without querying mutable current state.
type MessageGiftStarsAction struct {
Currency string `json:"currency"`
Amount int64 `json:"amount"`
Stars int64 `json:"stars"`
TransactionID string `json:"transaction_id,omitempty"`
BalanceAfter int64 `json:"balance_after"`
}
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。
type MessageStarGiftAction struct {
GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"`
CanUpgrade bool `json:"can_upgrade,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
// outer messageActionStarGift and is only the amount already prepaid by the
// sender. TDesktop uses these two fields to choose the paid vs free flow.
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
GiftMsgID int `json:"gift_msg_id,omitempty"`
GiftNum int `json:"gift_num,omitempty"`
AuctionAcquired bool `json:"auction_acquired,omitempty"`
To Peer `json:"to,omitempty"`
}
type MessageStarGiftUniqueAction struct {
Gift UniqueStarGift `json:"gift"`
FromUserID int64 `json:"from_user_id,omitempty"`
Peer Peer `json:"peer"`
SavedID int64 `json:"saved_id,omitempty"`
Upgrade bool `json:"upgrade,omitempty"`
Saved bool `json:"saved,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
Transferred bool `json:"transferred,omitempty"`
Refunded bool `json:"refunded,omitempty"`
Assigned bool `json:"assigned,omitempty"`
FromOffer bool `json:"from_offer,omitempty"`
Craft bool `json:"craft,omitempty"`
CanExportAt int `json:"can_export_at,omitempty"`
TransferStars int64 `json:"transfer_stars,omitempty"`
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
CanTransferAt int `json:"can_transfer_at,omitempty"`
CanResellAt int `json:"can_resell_at,omitempty"`
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
CanCraftAt int `json:"can_craft_at,omitempty"`
}
type MessageStarGiftOfferAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
ExpiresAt int `json:"expires_at"`
Accepted bool `json:"accepted,omitempty"`
Declined bool `json:"declined,omitempty"`
}
type MessageStarGiftOfferDeclinedAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
Expired bool `json:"expired,omitempty"`
}
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。

View file

@ -1,46 +0,0 @@
package domain
// 频道帖子付费 reactionmessages.sendPaidReaction用户花 Stars 为一条频道消息「点赞」,
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
// 与 Stars 账本stars.go配合rpc 先 Debit 再在此累计。
const (
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max
MaxPaidReactionStarsPerRequest = 10000
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
MaxPaidReactionTopReactors = 3
)
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
type PaidReactor struct {
UserID int64
Stars int64
Anonymous bool
My bool // 是否为当前 viewer投影时按视角置位
}
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
type ChannelMessagePaidReactions struct {
TotalStars int64 // 全体 reactor 投入星数之和
MyStars int64 // 当前 viewer 投入的星数0 = 未投)
MyAnonymous bool // 当前 viewer 是否匿名投入
TopReactors []PaidReactor // 按 Stars DESC含当前 viewer
}
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
type SendChannelPaidReactionRequest struct {
UserID int64
ChannelID int64
MessageID int
Stars int64
Anonymous bool // 隐私:是否匿名投入
Date int
}
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
type ChannelMessagePaidReactionResult struct {
Channel Channel
Message ChannelMessage
Paid ChannelMessagePaidReactions
Recipients []int64
}

File diff suppressed because it is too large Load diff

View file

@ -1,194 +0,0 @@
package domain
import (
"crypto/sha256"
"errors"
"strings"
"testing"
)
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
user := Peer{Type: PeerTypeUser, ID: 42}
channel := Peer{Type: PeerTypeChannel, ID: 84}
tests := []struct {
name string
ref SavedStarGiftRef
want bool
}{
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.ref.Valid(); got != tt.want {
t.Fatalf("Valid() = %v, want %v", got, tt.want)
}
})
}
}
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
if StarGiftLifecycleStatus("").Live() {
t.Fatal("empty lifecycle status must not be treated as active")
}
if !StarGiftLifecycleActive.Live() {
t.Fatal("active lifecycle status must be live")
}
}
func validCollectibleDraft() StarGiftCollectibleWrite {
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
return StarGiftCollectibleWrite{
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
Models: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
{Kind: StarGiftCollectibleModel, Name: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
},
Patterns: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
},
Backdrops: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
},
}
}
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
write := validCollectibleDraft()
write.OfficialGiftID = 10
write.SourceManifestSHA256 = make([]byte, sha256.Size)
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
t.Fatalf("valid official draft: %v", err)
}
tests := map[string]StarGiftCollectibleWrite{}
withoutHash := write
withoutHash.SourceManifestSHA256 = nil
tests["official ID without hash"] = withoutHash
withoutID := write
withoutID.OfficialGiftID = 0
tests["hash without official ID"] = withoutID
negativeID := write
negativeID.OfficialGiftID = -1
tests["negative official ID"] = negativeID
for name, invalid := range tests {
t.Run(name, func(t *testing.T) {
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
write := validCollectibleDraft()
write.Models[0].RarityKind = ""
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
}
func storedCollectibleWrite() StarGiftCollectibleWrite {
write := validCollectibleDraft()
for i := range write.Models {
write.Models[i].Document = &Document{
ID: int64(100 + i), MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
}
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
}
for i := range write.Patterns {
write.Patterns[i].Document = &Document{
ID: int64(200 + i), MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
}
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
}
return write
}
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
tests := map[string]func(*StarGiftCollectibleWrite){
"one selectable model": func(write *StarGiftCollectibleWrite) {
write.Models = append(write.Models[:1], write.Models[2:]...)
},
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
write.Patterns = write.Patterns[:1]
},
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
write.Backdrops = write.Backdrops[:1]
},
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
write := validCollectibleDraft()
mutate(&write)
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
t.Run(string(kind), func(t *testing.T) {
write := storedCollectibleWrite()
if kind == StarGiftCollectibleModel {
write.Models[1].Document = write.Models[0].Document
} else {
write.Patterns[1].Document = write.Patterns[0].Document
}
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
t.Fatalf("valid stored collectible: %v", err)
}
tests := map[string]func(*StarGiftCollectibleWrite){
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
},
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes[0].TextColor = false
},
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Thumbs = nil
},
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
},
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
write := storedCollectibleWrite()
mutate(&write)
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}

View file

@ -1,31 +0,0 @@
package domain
import "testing"
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
got, ok := DecodeSavedStarGiftListCursor(encoded)
if !ok || got != want {
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
}
unpinned := SavedStarGiftListCursor{ID: 42}
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
if !ok || got != unpinned {
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
}
}
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
for _, cursor := range []string{
"not-base64!",
EncodeStarGiftCursor(42),
EncodeSavedStarGiftListCursor(-1, 42),
EncodeSavedStarGiftListCursor(1, 0),
} {
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
}
}
}

View file

@ -1,274 +0,0 @@
package domain
import (
"encoding/base64"
"errors"
"fmt"
"strconv"
)
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
// 非真实支付:余额为整数 Stars线上 Nanos 恒 0借记原子、永不为负。
// StarsBalance 是一个账号的当前可用 Stars 余额。
type StarsBalance struct {
UserID int64
Balance int64 // 当前可花费 Stars恒 >= 0
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
}
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
// checkout. It is persisted with the form so a client cannot reinterpret a
// self top-up as a friend gift (or vice versa) when submitting the form.
type StarsPurchaseKind string
const (
StarsPurchaseTopup StarsPurchaseKind = "topup"
StarsPurchaseGift StarsPurchaseKind = "gift"
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
)
func (k StarsPurchaseKind) Valid() bool {
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
}
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
// eventual winner draw is a separate lifecycle transition.
type StarsGiveawayPurchase struct {
BoostPeer Peer `json:"boost_peer"`
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
CountriesISO2 []string `json:"countries_iso2,omitempty"`
PrizeDescription string `json:"prize_description,omitempty"`
RandomID int64 `json:"random_id"`
UntilDate int `json:"until_date"`
Users int `json:"users"`
PerUserStars int64 `json:"per_user_stars"`
YearlyBoosts int `json:"yearly_boosts"`
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
}
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
// authenticated buyer, purpose and exact server-advertised package. Recipient
// is zero for a self top-up and mandatory for a friend gift.
type StarsPurchaseForm struct {
FormID int64
Kind StarsPurchaseKind
BuyerUserID int64
RecipientUserID int64
SpendPurposePeer Peer
Giveaway *StarsGiveawayPurchase
Stars int64
Currency string
Amount int64
IssuedAt int
ExpiresAt int
}
// StarsPurchaseRequest is the immutable settlement command carried by
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
// command after the ordinary fiat checkout has produced provider credentials.
type StarsPurchaseRequest struct {
StarsPurchaseForm
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// StarsPurchaseResult is the atomically committed credit and, for a friend
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
type StarsPurchaseResult struct {
Balance StarsBalance
Send SendPrivateTextResult
ChannelSend SendChannelMessageResult
TransactionID string
Duplicate bool
}
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
// Winner selection/results are intentionally outside the purchase aggregate.
type StarsGiveawayInfo struct {
StartDate int
Participating bool
PreparingResults bool
JoinedTooEarlyDate int
AdminDisallowedChatID int64
DisallowedCountry string
}
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
type StarsTransactionReason string
const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
type StarsTransaction struct {
ID int64 // 单调递增账本 idkeyset 游标)
UserID int64 // 账本归属
Peer Peer // 对手方grant/topup 等无对手时为零 Peer
Amount int64 // 带符号金额
Date int // Unix 秒
Reason StarsTransactionReason
Title string // 可选,投影到 tg.StarsTransaction.Title
Description string // 可选,投影到 tg.StarsTransaction.Description
}
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
// StarsTransactionDirection scopes one payments.getStarsTransactions view.
// The zero value intentionally means the combined inbound/outbound history.
type StarsTransactionDirection uint8
const (
StarsTransactionDirectionAll StarsTransactionDirection = iota
StarsTransactionDirectionIncoming
StarsTransactionDirectionOutgoing
)
func (d StarsTransactionDirection) Valid() bool {
return d <= StarsTransactionDirectionOutgoing
}
func (d StarsTransactionDirection) IncludesAmount(amount int64) bool {
switch d {
case StarsTransactionDirectionAll:
return true
case StarsTransactionDirectionIncoming:
return amount > 0
case StarsTransactionDirectionOutgoing:
return amount < 0
default:
return false
}
}
// StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor
// together so filtering is applied before LIMIT in every ledger backend.
type StarsTransactionQuery struct {
Offset string
Limit int
Direction StarsTransactionDirection
Ascending bool
}
// NormalizeStarsTransactionQuery preserves the existing bounded limit/offset
// behavior while rejecting impossible internal direction values.
func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) {
if !query.Direction.Valid() {
return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid
}
if len(query.Offset) > MaxStarsTransactionsOffsetBytes {
query.Offset = ""
}
if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit {
query.Limit = MaxStarsTransactionsLimit
}
return query, nil
}
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
type StarsTransactionPage struct {
Balance int64
Transactions []StarsTransaction
NextOffset string // 空表示无更多页DrKLO 据此停止翻页,勿在末页给非空值)
Users []User // History 中提到的对手方用户,供 tg Users 富化
}
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
// a TON node, or any blockchain service.
type TonTransaction struct {
ID int64
UserID int64
Peer Peer
GiftID int64
Amount int64 // signed nanoton amount
Date int
Reason StarsTransactionReason
Title string
Description string
}
type TonTransactionPage struct {
Balance int64
Transactions []TonTransaction
NextOffset string
Users []User
}
// Stars 账本边界常量。
const (
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
DefaultStarsStartingGrant = 1000
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
MaxStarsTransactionsLimit = 100
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
MaxStarsTransactionsOffsetBytes = 64
)
// Stars 账本哨兵错误rpc 层 errors.Is 匹配后映射为 tgerr仿 ErrPremiumRequired
var (
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW
ErrStarsInsufficient = errors.New("stars: insufficient balance")
// ErrStarsInvalidAmount 表示金额非法(<=0
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
// ErrStarsPurchaseFormExpired is returned before any settlement write.
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
)
// StarsPaymentRequiredError reports the minimum paid-message authorization the
// sender must include in allow_paid_stars. The authorization is a ceiling; the
// ledger debits only the channel's current configured price.
type StarsPaymentRequiredError struct {
Stars int64
}
func (e *StarsPaymentRequiredError) Error() string {
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
}
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id编码为客户端不透明字符串。
func EncodeStarsCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}
// DecodeStarsCursor 反解 EncodeStarsCursor无法解析含空串时返回 ok=false
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
func DecodeStarsCursor(s string) (int64, bool) {
if s == "" {
return 0, false
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, false
}
id, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}

View file

@ -1,6 +1,14 @@
package domain
import "time"
import (
"errors"
"time"
)
// ErrEmojiStatusCollectibleInvalid is returned when a collectible emoji-status
// snapshot fails EmojiStatusCollectible.Valid() -- partial/malformed rather
// than either fully empty or fully populated.
var ErrEmojiStatusCollectibleInvalid = errors.New("emoji status collectible invalid")
// UserIDSequenceBase 是普通用户 ID 的起始值。
//