removed all "paid" features - no more stars, gifts, or grams
This commit is contained in:
parent
d4451d753c
commit
21d8e91756
165 changed files with 318 additions and 40948 deletions
|
|
@ -1,397 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Composite account rating.
|
||||
//
|
||||
// This is gramsrv's server-local account score. It deliberately uses its own
|
||||
// inputs and thresholds (Stars, activity and moderation), rather than claiming
|
||||
// to reproduce Telegram's private rating algorithm. The RPC edge exposes the
|
||||
// stored level through userFull's existing rating fields so official clients can
|
||||
// render it without a client patch.
|
||||
const (
|
||||
// MaxAccountRatingLevel bounds the local gramsrv level.
|
||||
MaxAccountRatingLevel = 50
|
||||
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
|
||||
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
|
||||
accountRatingLevelUnit = 100
|
||||
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
|
||||
MaxAccountRatingReasonLength = 512
|
||||
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
|
||||
MaxAccountRatingActorLength = 128
|
||||
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
|
||||
MaxAccountRatingCommandKeyLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccountRatingNotFound reports a user with no rating row yet.
|
||||
ErrAccountRatingNotFound = errors.New("account rating not found")
|
||||
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
|
||||
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
|
||||
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
|
||||
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
|
||||
)
|
||||
|
||||
// AccountRatingEventKind is the contribution source of a ledger row. Only
|
||||
// 'manual' rows survive a full recompute; the rest are audit trail.
|
||||
type AccountRatingEventKind string
|
||||
|
||||
const (
|
||||
AccountRatingEventStars AccountRatingEventKind = "stars"
|
||||
AccountRatingEventActivity AccountRatingEventKind = "activity"
|
||||
AccountRatingEventModeration AccountRatingEventKind = "moderation"
|
||||
AccountRatingEventManual AccountRatingEventKind = "manual"
|
||||
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
|
||||
)
|
||||
|
||||
// Valid reports whether the kind is modelled.
|
||||
func (k AccountRatingEventKind) Valid() bool {
|
||||
switch k {
|
||||
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
|
||||
AccountRatingEventManual, AccountRatingEventRecompute:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRating is the stored read model for one user.
|
||||
type AccountRating struct {
|
||||
UserID int64
|
||||
Level int
|
||||
Stars int64
|
||||
CurrentLevelStars int64
|
||||
// NextLevelStars is meaningful only when HasNextLevel is true.
|
||||
NextLevelStars int64
|
||||
HasNextLevel bool
|
||||
// Components are the explainable breakdown. PenaltyComponent is a
|
||||
// non-negative magnitude that is subtracted.
|
||||
StarsComponent int64
|
||||
ActivityComponent int64
|
||||
PenaltyComponent int64
|
||||
ManualComponent int64
|
||||
// PendingStars is a score delta not yet applied to the visible level, with
|
||||
// PendingDate reporting when it becomes effective.
|
||||
PendingStars int64
|
||||
PendingDate time.Time
|
||||
ComputedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Version int64
|
||||
}
|
||||
|
||||
// AccountRatingLevel is the local client/admin-facing level snapshot.
|
||||
type AccountRatingLevel struct {
|
||||
Level int
|
||||
CurrentLevelStars int64
|
||||
Stars int64
|
||||
NextLevelStars int64
|
||||
HasNextLevelStars bool
|
||||
}
|
||||
|
||||
// RatableAccount reports whether an account may carry a composite rating.
|
||||
//
|
||||
// The rating measures what an account did with Stars -- gifts bought, paid
|
||||
// messages sent, activity, moderation history. Two kinds of account have no
|
||||
// meaningful answer there and are excluded everywhere the rating is computed,
|
||||
// seeded or projected:
|
||||
//
|
||||
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
|
||||
// its score would only ever be the flat account-age term.
|
||||
// - The built-in service accounts (the platform account, BotFather, @Stickers,
|
||||
// @ChatBot, the verification bots). They are infrastructure rather than
|
||||
// participants: a leaderboard entry for the platform account is noise, and a
|
||||
// level badge on it would claim something about transaction volume that means
|
||||
// nothing.
|
||||
//
|
||||
// Note that the platform account is not flagged is_bot, so the bot check alone
|
||||
// does not cover it -- which is exactly how it ended up in the seeding pass.
|
||||
func RatableAccount(userID int64, bot bool) bool {
|
||||
return userID > 0 && !bot && !IsSystemUserID(userID)
|
||||
}
|
||||
|
||||
// LevelSnapshot returns the current visible local level.
|
||||
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
|
||||
return AccountRatingLevel{
|
||||
Level: r.Level,
|
||||
CurrentLevelStars: r.CurrentLevelStars,
|
||||
Stars: r.Stars,
|
||||
NextLevelStars: r.NextLevelStars,
|
||||
HasNextLevelStars: r.HasNextLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// PendingLevel returns the local level after the pending score is applied and
|
||||
// reports whether a pending record exists at all.
|
||||
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
|
||||
if r.PendingStars == 0 || r.PendingDate.IsZero() {
|
||||
return AccountRatingLevel{}, false
|
||||
}
|
||||
total := r.Stars + r.PendingStars
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
return AccountRatingLevel{
|
||||
Level: level,
|
||||
CurrentLevelStars: current,
|
||||
Stars: total,
|
||||
NextLevelStars: next,
|
||||
HasNextLevelStars: hasNext,
|
||||
}, true
|
||||
}
|
||||
|
||||
// AccountRatingWeights is the composite formula. All weights are integers so the
|
||||
// score is exactly reproducible across a recompute and across store backends.
|
||||
type AccountRatingWeights struct {
|
||||
// StarsReceivedPermille weighs Stars credited to the account (gifts,
|
||||
// reactions, paid messages received), in permille of the raw amount.
|
||||
StarsReceivedPermille int64
|
||||
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
|
||||
// signal than receiving, so the default is lower.
|
||||
StarsSpentPermille int64
|
||||
// PerMessageSent rewards sustained use.
|
||||
PerMessageSent int64
|
||||
// PerAccountAgeDay rewards account longevity.
|
||||
PerAccountAgeDay int64
|
||||
// PerGiftReceived rewards collectible gifts held.
|
||||
PerGiftReceived int64
|
||||
// PerModerationCase is the penalty for each upheld moderation case.
|
||||
PerModerationCase int64
|
||||
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
|
||||
ScamPenalty int64
|
||||
FakePenalty int64
|
||||
// ActivityCap bounds the activity component so activity alone cannot
|
||||
// outweigh everything else. Zero means uncapped.
|
||||
ActivityCap int64
|
||||
}
|
||||
|
||||
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
|
||||
// activity contributes a bounded floor, and moderation subtracts.
|
||||
func DefaultAccountRatingWeights() AccountRatingWeights {
|
||||
return AccountRatingWeights{
|
||||
StarsReceivedPermille: 1000,
|
||||
StarsSpentPermille: 250,
|
||||
PerMessageSent: 1,
|
||||
PerAccountAgeDay: 2,
|
||||
PerGiftReceived: 25,
|
||||
PerModerationCase: 150,
|
||||
ScamPenalty: 5000,
|
||||
FakePenalty: 5000,
|
||||
ActivityCap: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects negative weights and an impossible cap.
|
||||
func (w AccountRatingWeights) Validate() error {
|
||||
values := []int64{
|
||||
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
|
||||
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
|
||||
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
|
||||
}
|
||||
for _, v := range values {
|
||||
if v < 0 {
|
||||
return ErrAccountRatingWeightsInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals is the raw snapshot gathered from the contributing
|
||||
// sources for one user. It is deliberately a plain value: the same snapshot must
|
||||
// produce the same score in a unit test and in production.
|
||||
type AccountRatingSignals struct {
|
||||
UserID int64
|
||||
StarsReceived int64
|
||||
StarsSpent int64
|
||||
MessagesSent int64
|
||||
AccountAgeDays int64
|
||||
GiftsReceived int64
|
||||
ModerationCases int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
// Manual is the sum of admin adjustments, carried across recomputes.
|
||||
Manual int64
|
||||
}
|
||||
|
||||
// ComputeAccountRating turns a signal snapshot into the read model. The score is
|
||||
// clamped at zero: penalties can erase this local score but never invert it.
|
||||
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
|
||||
if err := weights.Validate(); err != nil {
|
||||
weights = DefaultAccountRatingWeights()
|
||||
}
|
||||
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
|
||||
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
|
||||
|
||||
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
|
||||
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
|
||||
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
|
||||
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
|
||||
activityComponent = weights.ActivityCap
|
||||
}
|
||||
|
||||
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
|
||||
if signals.Scam {
|
||||
penalty += weights.ScamPenalty
|
||||
}
|
||||
if signals.Fake {
|
||||
penalty += weights.FakePenalty
|
||||
}
|
||||
|
||||
total := starsComponent + activityComponent + signals.Manual - penalty
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
level, current, next, hasNext := AccountRatingLevelForStars(total)
|
||||
|
||||
return AccountRating{
|
||||
UserID: signals.UserID,
|
||||
Level: level,
|
||||
Stars: total,
|
||||
CurrentLevelStars: current,
|
||||
NextLevelStars: next,
|
||||
HasNextLevel: hasNext,
|
||||
StarsComponent: starsComponent,
|
||||
ActivityComponent: activityComponent,
|
||||
PenaltyComponent: penalty,
|
||||
ManualComponent: signals.Manual,
|
||||
ComputedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountRatingLevelThreshold returns the score needed to reach the given level.
|
||||
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
|
||||
// later ones stay meaningful.
|
||||
func AccountRatingLevelThreshold(level int) int64 {
|
||||
if level <= 0 {
|
||||
return 0
|
||||
}
|
||||
if level > MaxAccountRatingLevel {
|
||||
level = MaxAccountRatingLevel
|
||||
}
|
||||
n := int64(level)
|
||||
return accountRatingLevelUnit * n * n
|
||||
}
|
||||
|
||||
// AccountRatingLevelForStars maps a score onto the level and the surrounding
|
||||
// thresholds. hasNext is false at MaxAccountRatingLevel.
|
||||
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
|
||||
if stars < 0 {
|
||||
stars = 0
|
||||
}
|
||||
level = 0
|
||||
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
|
||||
if stars < AccountRatingLevelThreshold(candidate) {
|
||||
break
|
||||
}
|
||||
level = candidate
|
||||
}
|
||||
currentLevelStars = AccountRatingLevelThreshold(level)
|
||||
if level >= MaxAccountRatingLevel {
|
||||
return level, currentLevelStars, 0, false
|
||||
}
|
||||
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
|
||||
}
|
||||
|
||||
// ResolveAccountRatingPending decides whether a freshly computed score becomes
|
||||
// visible immediately or is parked as pending.
|
||||
//
|
||||
// A score that dropped is applied at once -- a penalty must not sit behind a
|
||||
// delay. A score that grew is parked until delay has elapsed; once the parked
|
||||
// window has passed the pending delta is folded into the visible local rating.
|
||||
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
|
||||
out := computed
|
||||
out.Version = prev.Version + 1
|
||||
if out.Version <= 0 {
|
||||
out.Version = 1
|
||||
}
|
||||
if delay <= 0 || prev.UserID == 0 {
|
||||
return out
|
||||
}
|
||||
if computed.Stars <= prev.Stars {
|
||||
return out
|
||||
}
|
||||
// A previously parked delta whose date has arrived is applied now.
|
||||
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
|
||||
return out
|
||||
}
|
||||
pendingSince := prev.PendingDate
|
||||
if prev.PendingStars == 0 || pendingSince.IsZero() {
|
||||
pendingSince = now.Add(delay)
|
||||
}
|
||||
visible := prev
|
||||
visible.StarsComponent = computed.StarsComponent
|
||||
visible.ActivityComponent = computed.ActivityComponent
|
||||
visible.PenaltyComponent = computed.PenaltyComponent
|
||||
visible.ManualComponent = computed.ManualComponent
|
||||
visible.PendingStars = computed.Stars - prev.Stars
|
||||
visible.PendingDate = pendingSince
|
||||
visible.ComputedAt = now
|
||||
visible.UpdatedAt = now
|
||||
visible.Version = out.Version
|
||||
return visible
|
||||
}
|
||||
|
||||
// AccountRatingEvent is one contribution ledger row.
|
||||
type AccountRatingEvent struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Kind AccountRatingEventKind
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
|
||||
type AdjustAccountRatingRequest struct {
|
||||
UserID int64
|
||||
Amount int64
|
||||
Reason string
|
||||
Actor string
|
||||
CommandKey string
|
||||
}
|
||||
|
||||
// Validate rejects a no-op or oversized adjustment.
|
||||
func (r AdjustAccountRatingRequest) Validate() error {
|
||||
if r.UserID <= 0 || r.Amount == 0 {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Reason) > MaxAccountRatingReasonLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.Actor) > MaxAccountRatingActorLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
|
||||
return ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountRatingFilter bounds an admin listing query.
|
||||
type AccountRatingFilter struct {
|
||||
MinLevel int
|
||||
UserID int64
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func permille(value, weight int64) int64 {
|
||||
if value <= 0 || weight <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value * weight / 1000
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -611,11 +611,6 @@ const (
|
|||
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice:
|
||||
// 广播频道 Direct Messages 开关/价格变更的服务消息。
|
||||
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
|
||||
// ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。
|
||||
ChannelActionStarGift ChannelMessageActionType = "star_gift"
|
||||
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUnique:collectible
|
||||
// 升级、转赠等所有权变更只进入 Recent Actions,不伪造频道历史/pts。
|
||||
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
|
||||
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。
|
||||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
|
||||
|
|
@ -655,9 +650,6 @@ type ChannelMessageAction struct {
|
|||
Completed []int
|
||||
Incompleted []int
|
||||
TodoItems []MessageTodoItem
|
||||
// StarGift 仅 star_gift 服务消息使用。
|
||||
StarGift *MessageStarGiftAction
|
||||
StarGiftUnique *MessageStarGiftUniqueAction
|
||||
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
|
||||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
|
|
@ -852,9 +844,6 @@ type ChannelMessageReactions struct {
|
|||
AsTags bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions
|
||||
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
|
||||
Paid *ChannelMessagePaidReactions
|
||||
}
|
||||
|
||||
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
|
||||
|
|
@ -1685,19 +1674,17 @@ func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
|
|||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
// optional Published result is the broadcast post.
|
||||
type ToggleSuggestedPostApprovalResult struct {
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
PayerStarsBalance *StarsBalance
|
||||
PayerTONBalance *int64
|
||||
Duplicate bool
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
|
||||
|
|
@ -1824,8 +1811,6 @@ type SendChannelMessageResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。
|
||||
SenderStarsBalance *StarsBalance
|
||||
// ReplayDeleteEvent is the existing durable channel delete event paired
|
||||
// with a deleted exact-random_id replay. It must be returned only to the
|
||||
// caller echo and must never be fanned out as a fresh event.
|
||||
|
|
|
|||
|
|
@ -591,18 +591,6 @@ const (
|
|||
// 状态切换与关闭请求。会话级保护不能写入普通消息的 NoForwards 字段。
|
||||
MessageServiceActionNoForwardsToggle MessageServiceActionKind = "no_forwards_toggle"
|
||||
MessageServiceActionNoForwardsRequest MessageServiceActionKind = "no_forwards_request"
|
||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
||||
// MessageServiceActionGiftStars maps messageActionGiftStars: fiat-purchased
|
||||
// Stars credited directly to a friend, distinct from collectible Star Gifts.
|
||||
MessageServiceActionGiftStars MessageServiceActionKind = "gift_stars"
|
||||
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
|
||||
// immutable collectible snapshot is carried by the service message so an
|
||||
// exact replay/difference never depends on mutable catalog state.
|
||||
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
|
||||
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
|
||||
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
|
||||
)
|
||||
|
||||
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
|
||||
|
|
@ -685,92 +673,6 @@ type MessageServiceAction struct {
|
|||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
GiftStars *MessageGiftStarsAction `json:"gift_stars,omitempty"`
|
||||
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
|
||||
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
|
||||
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,omitempty"`
|
||||
}
|
||||
|
||||
// MessageGiftStarsAction is the immutable service-message projection. The
|
||||
// recipient-only BalanceAfter field is not encoded in messageActionGiftStars;
|
||||
// it lets online push and offline difference attach the matching non-PTS
|
||||
// updateStarsBalance without querying mutable current state.
|
||||
type MessageGiftStarsAction struct {
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Stars int64 `json:"stars"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
Converted bool `json:"converted,omitempty"`
|
||||
CanUpgrade bool `json:"can_upgrade,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
|
||||
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
|
||||
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
|
||||
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
|
||||
// outer messageActionStarGift and is only the amount already prepaid by the
|
||||
// sender. TDesktop uses these two fields to choose the paid vs free flow.
|
||||
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
|
||||
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
|
||||
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
|
||||
GiftMsgID int `json:"gift_msg_id,omitempty"`
|
||||
GiftNum int `json:"gift_num,omitempty"`
|
||||
AuctionAcquired bool `json:"auction_acquired,omitempty"`
|
||||
To Peer `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftUniqueAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
Peer Peer `json:"peer"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
Upgrade bool `json:"upgrade,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
Transferred bool `json:"transferred,omitempty"`
|
||||
Refunded bool `json:"refunded,omitempty"`
|
||||
Assigned bool `json:"assigned,omitempty"`
|
||||
FromOffer bool `json:"from_offer,omitempty"`
|
||||
Craft bool `json:"craft,omitempty"`
|
||||
CanExportAt int `json:"can_export_at,omitempty"`
|
||||
TransferStars int64 `json:"transfer_stars,omitempty"`
|
||||
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
|
||||
CanTransferAt int `json:"can_transfer_at,omitempty"`
|
||||
CanResellAt int `json:"can_resell_at,omitempty"`
|
||||
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
|
||||
CanCraftAt int `json:"can_craft_at,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
ExpiresAt int `json:"expires_at"`
|
||||
Accepted bool `json:"accepted,omitempty"`
|
||||
Declined bool `json:"declined,omitempty"`
|
||||
}
|
||||
|
||||
type MessageStarGiftOfferDeclinedAction struct {
|
||||
Gift UniqueStarGift `json:"gift"`
|
||||
Price StarGiftAmount `json:"price"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
package domain
|
||||
|
||||
// 频道帖子付费 reaction(messages.sendPaidReaction):用户花 Stars 为一条频道消息「点赞」,
|
||||
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
|
||||
// 与 Stars 账本(stars.go)配合:rpc 先 Debit 再在此累计。
|
||||
|
||||
const (
|
||||
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max)。
|
||||
MaxPaidReactionStarsPerRequest = 10000
|
||||
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
|
||||
MaxPaidReactionTopReactors = 3
|
||||
)
|
||||
|
||||
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
|
||||
type PaidReactor struct {
|
||||
UserID int64
|
||||
Stars int64
|
||||
Anonymous bool
|
||||
My bool // 是否为当前 viewer(投影时按视角置位)
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
|
||||
type ChannelMessagePaidReactions struct {
|
||||
TotalStars int64 // 全体 reactor 投入星数之和
|
||||
MyStars int64 // 当前 viewer 投入的星数(0 = 未投)
|
||||
MyAnonymous bool // 当前 viewer 是否匿名投入
|
||||
TopReactors []PaidReactor // 按 Stars DESC,含当前 viewer
|
||||
}
|
||||
|
||||
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
|
||||
type SendChannelPaidReactionRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Stars int64
|
||||
Anonymous bool // 隐私:是否匿名投入
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
|
||||
type ChannelMessagePaidReactionResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Paid ChannelMessagePaidReactions
|
||||
Recipients []int64
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,194 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
|
||||
user := Peer{Type: PeerTypeUser, ID: 42}
|
||||
channel := Peer{Type: PeerTypeChannel, ID: 84}
|
||||
tests := []struct {
|
||||
name string
|
||||
ref SavedStarGiftRef
|
||||
want bool
|
||||
}{
|
||||
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
|
||||
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
|
||||
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
|
||||
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
|
||||
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
|
||||
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
|
||||
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
|
||||
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.ref.Valid(); got != tt.want {
|
||||
t.Fatalf("Valid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
|
||||
if StarGiftLifecycleStatus("").Live() {
|
||||
t.Fatal("empty lifecycle status must not be treated as active")
|
||||
}
|
||||
if !StarGiftLifecycleActive.Live() {
|
||||
t.Fatal("active lifecycle status must be live")
|
||||
}
|
||||
}
|
||||
|
||||
func validCollectibleDraft() StarGiftCollectibleWrite {
|
||||
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
|
||||
return StarGiftCollectibleWrite{
|
||||
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
|
||||
Models: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
|
||||
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
|
||||
},
|
||||
Patterns: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
|
||||
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
|
||||
},
|
||||
Backdrops: []StarGiftCollectibleAttribute{
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
|
||||
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.OfficialGiftID = 10
|
||||
write.SourceManifestSHA256 = make([]byte, sha256.Size)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
|
||||
t.Fatalf("valid official draft: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]StarGiftCollectibleWrite{}
|
||||
withoutHash := write
|
||||
withoutHash.SourceManifestSHA256 = nil
|
||||
tests["official ID without hash"] = withoutHash
|
||||
withoutID := write
|
||||
withoutID.OfficialGiftID = 0
|
||||
tests["hash without official ID"] = withoutID
|
||||
negativeID := write
|
||||
negativeID.OfficialGiftID = -1
|
||||
tests["negative official ID"] = negativeID
|
||||
for name, invalid := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
write.Models[0].RarityKind = ""
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func storedCollectibleWrite() StarGiftCollectibleWrite {
|
||||
write := validCollectibleDraft()
|
||||
for i := range write.Models {
|
||||
write.Models[i].Document = &Document{
|
||||
ID: int64(100 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
|
||||
}
|
||||
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
|
||||
}
|
||||
for i := range write.Patterns {
|
||||
write.Patterns[i].Document = &Document{
|
||||
ID: int64(200 + i), MimeType: "application/x-tgsticker",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
|
||||
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
|
||||
}
|
||||
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
|
||||
}
|
||||
return write
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"one selectable model": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models = append(write.Models[:1], write.Models[2:]...)
|
||||
},
|
||||
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns = write.Patterns[:1]
|
||||
},
|
||||
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops = write.Backdrops[:1]
|
||||
},
|
||||
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
|
||||
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := validCollectibleDraft()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
|
||||
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
if kind == StarGiftCollectibleModel {
|
||||
write.Models[1].Document = write.Models[0].Document
|
||||
} else {
|
||||
write.Patterns[1].Document = write.Patterns[0].Document
|
||||
}
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
|
||||
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
|
||||
t.Fatalf("valid stored collectible: %v", err)
|
||||
}
|
||||
|
||||
tests := map[string]func(*StarGiftCollectibleWrite){
|
||||
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
|
||||
},
|
||||
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Attributes[0].TextColor = false
|
||||
},
|
||||
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
|
||||
write.Patterns[0].Document.Thumbs = nil
|
||||
},
|
||||
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
|
||||
},
|
||||
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
|
||||
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
|
||||
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
write := storedCollectibleWrite()
|
||||
mutate(&write)
|
||||
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
|
||||
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
|
||||
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
|
||||
got, ok := DecodeSavedStarGiftListCursor(encoded)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
|
||||
}
|
||||
|
||||
unpinned := SavedStarGiftListCursor{ID: 42}
|
||||
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
|
||||
if !ok || got != unpinned {
|
||||
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
|
||||
for _, cursor := range []string{
|
||||
"not-base64!",
|
||||
EncodeStarGiftCursor(42),
|
||||
EncodeSavedStarGiftListCursor(-1, 42),
|
||||
EncodeSavedStarGiftListCursor(1, 0),
|
||||
} {
|
||||
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
|
||||
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
|
||||
// 非真实支付:余额为整数 Stars(线上 Nanos 恒 0),借记原子、永不为负。
|
||||
|
||||
// StarsBalance 是一个账号的当前可用 Stars 余额。
|
||||
type StarsBalance struct {
|
||||
UserID int64
|
||||
Balance int64 // 当前可花费 Stars,恒 >= 0
|
||||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
||||
}
|
||||
|
||||
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
|
||||
// checkout. It is persisted with the form so a client cannot reinterpret a
|
||||
// self top-up as a friend gift (or vice versa) when submitting the form.
|
||||
type StarsPurchaseKind string
|
||||
|
||||
const (
|
||||
StarsPurchaseTopup StarsPurchaseKind = "topup"
|
||||
StarsPurchaseGift StarsPurchaseKind = "gift"
|
||||
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
|
||||
)
|
||||
|
||||
func (k StarsPurchaseKind) Valid() bool {
|
||||
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
|
||||
}
|
||||
|
||||
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
|
||||
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
|
||||
// eventual winner draw is a separate lifecycle transition.
|
||||
type StarsGiveawayPurchase struct {
|
||||
BoostPeer Peer `json:"boost_peer"`
|
||||
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
|
||||
CountriesISO2 []string `json:"countries_iso2,omitempty"`
|
||||
PrizeDescription string `json:"prize_description,omitempty"`
|
||||
RandomID int64 `json:"random_id"`
|
||||
UntilDate int `json:"until_date"`
|
||||
Users int `json:"users"`
|
||||
PerUserStars int64 `json:"per_user_stars"`
|
||||
YearlyBoosts int `json:"yearly_boosts"`
|
||||
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
|
||||
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
|
||||
}
|
||||
|
||||
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
|
||||
// authenticated buyer, purpose and exact server-advertised package. Recipient
|
||||
// is zero for a self top-up and mandatory for a friend gift.
|
||||
type StarsPurchaseForm struct {
|
||||
FormID int64
|
||||
Kind StarsPurchaseKind
|
||||
BuyerUserID int64
|
||||
RecipientUserID int64
|
||||
SpendPurposePeer Peer
|
||||
Giveaway *StarsGiveawayPurchase
|
||||
Stars int64
|
||||
Currency string
|
||||
Amount int64
|
||||
IssuedAt int
|
||||
ExpiresAt int
|
||||
}
|
||||
|
||||
// StarsPurchaseRequest is the immutable settlement command carried by
|
||||
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
|
||||
// command after the ordinary fiat checkout has produced provider credentials.
|
||||
type StarsPurchaseRequest struct {
|
||||
StarsPurchaseForm
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// StarsPurchaseResult is the atomically committed credit and, for a friend
|
||||
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
|
||||
type StarsPurchaseResult struct {
|
||||
Balance StarsBalance
|
||||
Send SendPrivateTextResult
|
||||
ChannelSend SendChannelMessageResult
|
||||
TransactionID string
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
|
||||
// Winner selection/results are intentionally outside the purchase aggregate.
|
||||
type StarsGiveawayInfo struct {
|
||||
StartDate int
|
||||
Participating bool
|
||||
PreparingResults bool
|
||||
JoinedTooEarlyDate int
|
||||
AdminDisallowedChatID int64
|
||||
DisallowedCountry string
|
||||
}
|
||||
|
||||
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
type StarsTransaction struct {
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
Reason StarsTransactionReason
|
||||
Title string // 可选,投影到 tg.StarsTransaction.Title
|
||||
Description string // 可选,投影到 tg.StarsTransaction.Description
|
||||
}
|
||||
|
||||
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
|
||||
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
|
||||
|
||||
// StarsTransactionDirection scopes one payments.getStarsTransactions view.
|
||||
// The zero value intentionally means the combined inbound/outbound history.
|
||||
type StarsTransactionDirection uint8
|
||||
|
||||
const (
|
||||
StarsTransactionDirectionAll StarsTransactionDirection = iota
|
||||
StarsTransactionDirectionIncoming
|
||||
StarsTransactionDirectionOutgoing
|
||||
)
|
||||
|
||||
func (d StarsTransactionDirection) Valid() bool {
|
||||
return d <= StarsTransactionDirectionOutgoing
|
||||
}
|
||||
|
||||
func (d StarsTransactionDirection) IncludesAmount(amount int64) bool {
|
||||
switch d {
|
||||
case StarsTransactionDirectionAll:
|
||||
return true
|
||||
case StarsTransactionDirectionIncoming:
|
||||
return amount > 0
|
||||
case StarsTransactionDirectionOutgoing:
|
||||
return amount < 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StarsTransactionQuery keeps direction, ordering and the opaque keyset cursor
|
||||
// together so filtering is applied before LIMIT in every ledger backend.
|
||||
type StarsTransactionQuery struct {
|
||||
Offset string
|
||||
Limit int
|
||||
Direction StarsTransactionDirection
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
// NormalizeStarsTransactionQuery preserves the existing bounded limit/offset
|
||||
// behavior while rejecting impossible internal direction values.
|
||||
func NormalizeStarsTransactionQuery(query StarsTransactionQuery) (StarsTransactionQuery, error) {
|
||||
if !query.Direction.Valid() {
|
||||
return StarsTransactionQuery{}, ErrStarsTransactionQueryInvalid
|
||||
}
|
||||
if len(query.Offset) > MaxStarsTransactionsOffsetBytes {
|
||||
query.Offset = ""
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > MaxStarsTransactionsLimit {
|
||||
query.Limit = MaxStarsTransactionsLimit
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
|
||||
type StarsTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []StarsTransaction
|
||||
NextOffset string // 空表示无更多页(DrKLO 据此停止翻页,勿在末页给非空值)
|
||||
Users []User // History 中提到的对手方用户,供 tg Users 富化
|
||||
}
|
||||
|
||||
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
|
||||
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
|
||||
// a TON node, or any blockchain service.
|
||||
type TonTransaction struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
GiftID int64
|
||||
Amount int64 // signed nanoton amount
|
||||
Date int
|
||||
Reason StarsTransactionReason
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type TonTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []TonTransaction
|
||||
NextOffset string
|
||||
Users []User
|
||||
}
|
||||
|
||||
// Stars 账本边界常量。
|
||||
const (
|
||||
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
|
||||
DefaultStarsStartingGrant = 1000
|
||||
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
|
||||
MaxStarsTransactionsLimit = 100
|
||||
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
|
||||
MaxStarsTransactionsOffsetBytes = 64
|
||||
)
|
||||
|
||||
// Stars 账本哨兵错误(rpc 层 errors.Is 匹配后映射为 tgerr,仿 ErrPremiumRequired)。
|
||||
var (
|
||||
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW)。
|
||||
ErrStarsInsufficient = errors.New("stars: insufficient balance")
|
||||
// ErrStarsInvalidAmount 表示金额非法(<=0)。
|
||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
|
||||
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
|
||||
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
|
||||
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
|
||||
// ErrStarsPurchaseFormExpired is returned before any settlement write.
|
||||
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
|
||||
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
|
||||
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
|
||||
)
|
||||
|
||||
// StarsPaymentRequiredError reports the minimum paid-message authorization the
|
||||
// sender must include in allow_paid_stars. The authorization is a ceiling; the
|
||||
// ledger debits only the channel's current configured price.
|
||||
type StarsPaymentRequiredError struct {
|
||||
Stars int64
|
||||
}
|
||||
|
||||
func (e *StarsPaymentRequiredError) Error() string {
|
||||
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
|
||||
}
|
||||
|
||||
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
|
||||
func EncodeStarsCursor(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
// DecodeStarsCursor 反解 EncodeStarsCursor;无法解析(含空串)时返回 ok=false,
|
||||
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
|
||||
func DecodeStarsCursor(s string) (int64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrEmojiStatusCollectibleInvalid is returned when a collectible emoji-status
|
||||
// snapshot fails EmojiStatusCollectible.Valid() -- partial/malformed rather
|
||||
// than either fully empty or fully populated.
|
||||
var ErrEmojiStatusCollectibleInvalid = errors.New("emoji status collectible invalid")
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue