484 lines
18 KiB
Go
484 lines
18 KiB
Go
package domain
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"errors"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Star gift(payments.sendStarsForm + inputInvoiceStarGift)领域模型。目录和不可变版本
|
||
// 持久化在 star_gift_catalog(_revisions);peer 收到的礼物实例落 peer_star_gifts。
|
||
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
|
||
|
||
// StarGift 是一个可购买礼物目录项。RevisionID 标识不可变的标题/价格/动画快照。
|
||
type StarGift struct {
|
||
ID int64
|
||
RevisionID int64
|
||
Stars int64 // 购买价(Stars)
|
||
ConvertStars int64 // 收礼人可转换回的 Stars
|
||
UpgradeStars int64 // 升级为唯一礼物所需 Stars;0 表示当前不可升级
|
||
UpgradeTotal int // 当前已发布属性池允许发行的唯一礼物总量
|
||
UpgradeIssued int // 当前已发行数量
|
||
Title string // 可选标题
|
||
Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃)
|
||
}
|
||
|
||
// SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。
|
||
type SavedStarGift struct {
|
||
ID int64
|
||
Owner Peer // 收礼 peer(user/channel)
|
||
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
|
||
GiftID int64 // → StarGift.ID
|
||
RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影
|
||
MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0
|
||
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0
|
||
Date int // 收到时刻 Unix 秒
|
||
NameHidden bool // 送礼人请求隐藏姓名
|
||
Unsaved bool // 未展示在个人资料(saveStarGift 切换)
|
||
Converted bool // 已转换回 Stars(终态,从列表排除)
|
||
ConvertStars int64 // 转换可退回的 Stars
|
||
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
|
||
Message string // 附言(可选)
|
||
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
|
||
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id
|
||
PinnedOrder int // >0 表示资料页置顶顺序
|
||
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
|
||
Unique *UniqueStarGift
|
||
}
|
||
|
||
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
|
||
type StarGiftCollectibleAttributeKind string
|
||
|
||
const (
|
||
StarGiftCollectibleModel StarGiftCollectibleAttributeKind = "model"
|
||
StarGiftCollectiblePattern StarGiftCollectibleAttributeKind = "pattern"
|
||
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
|
||
)
|
||
|
||
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
|
||
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
|
||
type StarGiftCollectibleAttribute struct {
|
||
ID int64
|
||
CollectibleRevisionID int64
|
||
Kind StarGiftCollectibleAttributeKind
|
||
Name string
|
||
Document *Document
|
||
BackdropID int
|
||
CenterColor int
|
||
EdgeColor int
|
||
PatternColor int
|
||
TextColor int
|
||
RarityPermille int
|
||
SortOrder int
|
||
Animation *StarGiftAnimation
|
||
Blob *FileBlob
|
||
}
|
||
|
||
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
|
||
type StarGiftCollectibleRevision struct {
|
||
ID int64
|
||
GiftID int64
|
||
Revision int
|
||
UpgradeStars int64
|
||
SupplyTotal int
|
||
Issued int
|
||
SlugPrefix string
|
||
Published bool
|
||
Models []StarGiftCollectibleAttribute
|
||
Patterns []StarGiftCollectibleAttribute
|
||
Backdrops []StarGiftCollectibleAttribute
|
||
CreatedBy string
|
||
CreatedAt time.Time
|
||
PublishedAt time.Time
|
||
}
|
||
|
||
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
|
||
type StarGiftCollectibleWrite struct {
|
||
GiftID int64
|
||
UpgradeStars int64
|
||
SupplyTotal int
|
||
SlugPrefix string
|
||
Models []StarGiftCollectibleAttribute
|
||
Patterns []StarGiftCollectibleAttribute
|
||
Backdrops []StarGiftCollectibleAttribute
|
||
Actor string
|
||
CommandID string
|
||
}
|
||
|
||
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
|
||
type UniqueStarGift struct {
|
||
ID int64
|
||
GiftID int64
|
||
CollectibleRevisionID int64
|
||
SourceSavedGiftID int64
|
||
Title string
|
||
Slug string
|
||
Num int
|
||
Owner Peer
|
||
Model StarGiftCollectibleAttribute
|
||
Pattern StarGiftCollectibleAttribute
|
||
Backdrop StarGiftCollectibleAttribute
|
||
AvailabilityIssued int
|
||
AvailabilityTotal int
|
||
KeepOriginalDetails bool
|
||
OriginalFromUserID int64
|
||
OriginalOwner Peer
|
||
OriginalDate int
|
||
OriginalMessage string
|
||
OriginalNameHidden bool
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
|
||
type StarGiftUpgradePreview struct {
|
||
GiftID int64
|
||
Revision int
|
||
UpgradeStars int64
|
||
SupplyTotal int
|
||
Issued int
|
||
SlugPrefix string
|
||
Models []StarGiftCollectibleAttribute
|
||
Patterns []StarGiftCollectibleAttribute
|
||
Backdrops []StarGiftCollectibleAttribute
|
||
}
|
||
|
||
// StarGiftCollectibleAvailability is the lightweight current-pool projection used
|
||
// when rendering historical saved gifts. The saved gift keeps its immutable catalog
|
||
// revision for appearance and prices, while upgrade availability follows the pool
|
||
// currently published for the logical gift ID.
|
||
type StarGiftCollectibleAvailability struct {
|
||
UpgradeStars int64
|
||
SupplyTotal int
|
||
Issued int
|
||
}
|
||
|
||
// StarGiftUpgradeRequest is one idempotent user-owned upgrade command. Paid
|
||
// invoice upgrades set ChargeStars; the direct payments.upgradeStarGift path
|
||
// sets RequirePrepaid and charges zero at upgrade time.
|
||
type StarGiftUpgradeRequest struct {
|
||
UserID int64
|
||
Ref SavedStarGiftRef
|
||
KeepOriginalDetails bool
|
||
ChargeStars int64
|
||
RequirePrepaid bool
|
||
FormID int64
|
||
CommandKey string
|
||
Date int
|
||
OriginAuthKeyID [8]byte
|
||
OriginSessionID int64
|
||
}
|
||
|
||
type StarGiftUpgradeResult struct {
|
||
Saved SavedStarGift
|
||
Unique UniqueStarGift
|
||
Balance StarsBalance
|
||
Send SendPrivateTextResult
|
||
Duplicate bool
|
||
}
|
||
|
||
// StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
|
||
type StarGiftCollection struct {
|
||
Owner Peer
|
||
CollectionID int
|
||
Title string
|
||
GiftIDs []int64 // peer_star_gifts.id,按集合内顺序
|
||
Hash int64
|
||
SortOrder int
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// StarGiftCollectionPatch 描述 updateStarGiftCollection 的局部更新。
|
||
type StarGiftCollectionPatch struct {
|
||
Title *string
|
||
DeleteIDs []int64
|
||
AddIDs []int64
|
||
Order []int64
|
||
}
|
||
|
||
// StarGiftAnimationFormat 是后台导入源格式。服务端最终总是存储规范化 TGS。
|
||
type StarGiftAnimationFormat string
|
||
|
||
const (
|
||
StarGiftAnimationTGS StarGiftAnimationFormat = "tgs"
|
||
StarGiftAnimationLottie StarGiftAnimationFormat = "lottie"
|
||
)
|
||
|
||
// StarGiftAnimation 是已规范化并验证的动画。JSON 用于后台播放,TGS 用于客户端。
|
||
type StarGiftAnimation struct {
|
||
SourceName string
|
||
SourceFormat StarGiftAnimationFormat
|
||
JSON []byte
|
||
TGS []byte
|
||
SHA256 []byte
|
||
Width int
|
||
Height int
|
||
FrameRate float64
|
||
InPoint float64
|
||
OutPoint float64
|
||
}
|
||
|
||
// StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。
|
||
type StarGiftCatalogWrite struct {
|
||
GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision
|
||
Title string
|
||
Stars int64
|
||
ConvertStars int64
|
||
Enabled bool
|
||
SortOrder int
|
||
Document Document
|
||
Blob FileBlob
|
||
Animation StarGiftAnimation
|
||
Actor string
|
||
CommandID string
|
||
}
|
||
|
||
// StarGiftCatalogEntry 是管理后台目录视图。
|
||
type StarGiftCatalogEntry struct {
|
||
Gift StarGift
|
||
Enabled bool
|
||
SortOrder int
|
||
Revision int
|
||
SourceName string
|
||
SourceFormat StarGiftAnimationFormat
|
||
AnimationSHA []byte
|
||
AnimationSize int64
|
||
Width int
|
||
Height int
|
||
FrameRate float64
|
||
ReceivedCount int64
|
||
CreatedBy string
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
|
||
// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
|
||
type SavedStarGiftRef struct {
|
||
Owner Peer
|
||
MsgID int
|
||
SavedID int64
|
||
}
|
||
|
||
// Valid reports whether the reference has the identity required by its owner kind.
|
||
func (r SavedStarGiftRef) Valid() bool {
|
||
switch r.Owner.Type {
|
||
case PeerTypeUser:
|
||
return r.Owner.ID != 0 && r.MsgID > 0
|
||
case PeerTypeChannel:
|
||
return r.Owner.ID != 0 && r.SavedID > 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// SavedStarGiftPage 是一页已收到礼物 + keyset 分页游标。
|
||
type SavedStarGiftPage struct {
|
||
Gifts []SavedStarGift
|
||
NextOffset string // 空 = 无更多页(末页必须省略,客户端据此停止翻页)
|
||
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
|
||
}
|
||
|
||
// SavedStarGiftFilter describes the client-visible filters supported by
|
||
// payments.getSavedStarGifts. CollectionID is the collection membership filter;
|
||
// zero means all collections. The current catalog is used only to decide whether
|
||
// a regular gift remains upgradable, while its rendered gift snapshot still comes
|
||
// from RevisionID.
|
||
type SavedStarGiftFilter struct {
|
||
Owner Peer
|
||
ExcludeUnsaved bool
|
||
ExcludeSaved bool
|
||
ExcludeUnlimited bool
|
||
ExcludeUnique bool
|
||
ExcludeUpgradable bool
|
||
ExcludeUnupgradable bool
|
||
CollectionID int
|
||
Offset string
|
||
Limit int
|
||
}
|
||
|
||
// Star gift 边界常量。
|
||
const (
|
||
// MaxSavedStarGiftsLimit 是 getSavedStarGifts 单页上限。
|
||
MaxSavedStarGiftsLimit = 100
|
||
// MaxStarGiftMessageRunes 限制附言长度(对齐 stargifts_message_length_max 量级)。
|
||
MaxStarGiftMessageRunes = 255
|
||
// MaxStarGiftsOffsetBytes 是 keyset 游标字符串长度上限。
|
||
MaxStarGiftsOffsetBytes = 64
|
||
// MaxStarGiftTGSBytes 限制后台导入的压缩动画,避免管理面上传成为容量旁路。
|
||
MaxStarGiftTGSBytes int64 = 512 << 10
|
||
// MaxStarGiftLottieBytes 限制解压后的 Lottie JSON。
|
||
MaxStarGiftLottieBytes int64 = 4 << 20
|
||
// MaxStarGiftAnimationFrameRate / Seconds 限制管理后台播放器和客户端动画时间轴。
|
||
MaxStarGiftAnimationFrameRate = 120
|
||
MaxStarGiftAnimationSeconds = 30
|
||
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
|
||
MaxStarGiftCatalogSize = 500
|
||
MaxStarGiftTitleRunes = 128
|
||
MaxStarGiftCollectibleAttributesPerKind = 256
|
||
MaxStarGiftCollectionTitleRunes = 12
|
||
MaxStarGiftCollectionsPerPeer = 100
|
||
MaxStarGiftCollectionItems = 1000
|
||
)
|
||
|
||
// Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。
|
||
var (
|
||
// ErrStarGiftInvalid 表示礼物 id 不在目录里。
|
||
ErrStarGiftInvalid = errors.New("stargift: invalid gift id")
|
||
// ErrStarGiftNotFound 表示找不到该已收到礼物实例。
|
||
ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
|
||
// ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars(不可重复转换)。
|
||
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
|
||
ErrStarGiftFileInvalid = errors.New("stargift: invalid animation file")
|
||
ErrStarGiftCatalogFull = errors.New("stargift: catalog full")
|
||
ErrStarGiftCollectibleUnavailable = errors.New("stargift: collectible upgrade unavailable")
|
||
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
|
||
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
|
||
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
|
||
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
|
||
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
|
||
)
|
||
|
||
var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
|
||
|
||
// ValidateStarGiftCollectibleDraft validates the operator-authored definition before animation
|
||
// blobs/documents are allocated. This is the validation boundary used by admin dry-runs.
|
||
func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
|
||
write.SlugPrefix = strings.TrimSpace(strings.ToLower(write.SlugPrefix))
|
||
if write.GiftID <= 0 || write.UpgradeStars <= 0 || write.SupplyTotal <= 0 ||
|
||
!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
|
||
return err
|
||
}
|
||
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, false); err != nil {
|
||
return err
|
||
}
|
||
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false)
|
||
}
|
||
|
||
// ValidateStarGiftCollectibleWrite validates a complete publish command. Published pools are
|
||
// immutable, so partial definitions are rejected before any document/blob rows are written.
|
||
func ValidateStarGiftCollectibleWrite(write StarGiftCollectibleWrite) error {
|
||
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
|
||
return err
|
||
}
|
||
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, true); err != nil {
|
||
return err
|
||
}
|
||
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, true); err != nil {
|
||
return err
|
||
}
|
||
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true)
|
||
}
|
||
|
||
func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind StarGiftCollectibleAttributeKind, requireStoredAsset bool) error {
|
||
if len(attributes) == 0 || len(attributes) > MaxStarGiftCollectibleAttributesPerKind {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
seen := make(map[string]struct{}, len(attributes))
|
||
total := 0
|
||
for _, attribute := range attributes {
|
||
name := strings.TrimSpace(attribute.Name)
|
||
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes ||
|
||
attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
key := strings.ToLower(name)
|
||
if _, ok := seen[key]; ok {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
seen[key] = struct{}{}
|
||
total += attribute.RarityPermille
|
||
switch kind {
|
||
case StarGiftCollectibleModel, StarGiftCollectiblePattern:
|
||
if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
|
||
len(attribute.Animation.TGS) == 0 || len(attribute.Animation.SHA256) != 32 {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
if requireStoredAsset && (attribute.Document == nil || !attribute.Document.IsSticker() ||
|
||
attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
case StarGiftCollectibleBackdrop:
|
||
if attribute.BackdropID <= 0 || attribute.Document != nil ||
|
||
attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
|
||
attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
|
||
attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
|
||
attribute.TextColor < 0 || attribute.TextColor > 0xffffff {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
default:
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
}
|
||
if total != 1000 {
|
||
return ErrStarGiftCollectibleInvalid
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// StarGiftCatalogHash 由客户端可见目录字段折叠出稳定 hash,供 getStarGifts NotModified。
|
||
func StarGiftCatalogHash(catalog []StarGift) int {
|
||
var h uint64
|
||
for _, g := range catalog {
|
||
h ^= uint64(g.ID)
|
||
h = h*0x4f25 + uint64(g.ID)
|
||
h = h*0x4f25 + uint64(g.RevisionID)
|
||
h = h*0x4f25 + uint64(g.Stars)
|
||
h = h*0x4f25 + uint64(g.ConvertStars)
|
||
h = h*0x4f25 + uint64(g.UpgradeStars)
|
||
h = h*0x4f25 + uint64(g.UpgradeTotal)
|
||
h = h*0x4f25 + uint64(g.UpgradeIssued)
|
||
h = h*0x4f25 + uint64(g.Sticker.ID)
|
||
for _, r := range g.Title {
|
||
h = h*131 + uint64(r)
|
||
}
|
||
}
|
||
return int(h & 0x7fffffff)
|
||
}
|
||
|
||
// StarGiftCollectionsHash 按服务端返回顺序折叠每个集合自己的稳定 hash。
|
||
func StarGiftCollectionsHash(collections []StarGiftCollection) int64 {
|
||
var h uint64
|
||
for _, collection := range collections {
|
||
h = h*0x4f25 + uint64(collection.Hash)
|
||
}
|
||
return int64(h & 0x7fffffffffffffff)
|
||
}
|
||
|
||
// StarGiftCollectionHash returns the per-collection hash exposed by starGiftCollection.hash.
|
||
func StarGiftCollectionHash(title string, giftIDs []int64) int64 {
|
||
h := uint64(0x534743)
|
||
for _, r := range title {
|
||
h = h*131 + uint64(r)
|
||
}
|
||
for _, id := range giftIDs {
|
||
h = h*0x4f25 + uint64(id)
|
||
}
|
||
return int64(h & 0x7fffffffffffffff)
|
||
}
|
||
|
||
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id)。
|
||
func EncodeStarGiftCursor(id int64) string {
|
||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||
}
|
||
|
||
// DecodeStarGiftCursor 反解游标;无法解析(含空串)返回 ok=false(调用方从首页开始)。
|
||
func DecodeStarGiftCursor(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
|
||
}
|