owpengram-server/internal/domain/star_gift.go
2026-07-16 12:38:53 +08:00

484 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package domain
import (
"encoding/base64"
"errors"
"regexp"
"strconv"
"strings"
"time"
)
// Star giftpayments.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 // 升级为唯一礼物所需 Stars0 表示当前不可升级
UpgradeTotal int // 当前已发布属性池允许发行的唯一礼物总量
UpgradeIssued int // 当前已发行数量
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
}
// SavedStarGift 是一条已收到的礼物实例peer_star_gifts 一行)。
type SavedStarGift struct {
ID int64
Owner Peer // 收礼 peeruser/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
}