feat: sync collectible star gifts

This commit is contained in:
A 2026-07-16 12:38:53 +08:00
parent 47fcf0ea41
commit 5ecf4e912d
64 changed files with 7559 additions and 403 deletions

View file

@ -178,5 +178,8 @@ TELESRV_TRANSLATION_RATE_WINDOW=1m
# TELESRV_AI_KIMI_TEMPERATURE=0.6
# Business automation reply provider:
# echo (default), template/quick_reply, or ai/compose_ai/kimi to reuse AI providers.
# echo (default/empty), template/quick_reply/quick-reply, or
# ai/compose_ai/ai_compose/aicompose/kimi to reuse TELESRV_AI_PROVIDERS.
# Custom provider names such as "ollama" are selected through TELESRV_AI_PROVIDERS;
# use TELESRV_BUSINESS_AI_PROVIDER=ai for those.
TELESRV_BUSINESS_AI_PROVIDER=echo

View file

@ -57,7 +57,7 @@ codebase.
| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, linked discussion guests, history, send/edit/delete/read, reactions, public search, and previews. |
| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, canonical GIFv conversion, external media fetch, web page previews, map tile cache hooks, profile/channel photos. |
| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, saved GIFs, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. |
| ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. |
| ✅ | Gifts and stars | Dynamic star gift catalog, admin import tools, collectible/unique gift upgrade flows, prepaid upgrade tracking, and local stars ledger foundations. |
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |

View file

@ -54,7 +54,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、关联讨论组 guest 访问、history、send/edit/delete/read、reactions、公开搜索和预览。 |
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 |
| ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 |
| ✅ | Gifts 与 Stars | 动态 star gift catalog、后台导入、收藏品/唯一礼物升级流程、预付升级跟踪,以及本地 stars ledger 基础。 |
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |

View file

@ -10,6 +10,8 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
const (
@ -131,6 +133,60 @@ type ChannelDetail struct {
AuditLogs []AuditLogRow
}
type StarGiftRow struct {
GiftID int64
RevisionID int64
Revision int
Title string
Stars int64
ConvertStars int64
Enabled bool
SortOrder int
DocumentID int64
SourceName string
SourceFormat string
AnimationSHA string
AnimationSize int64
Width int
Height int
FrameRate float64
ReceivedCount int64
CreatedBy string
UpdatedAt time.Time
}
func (s *readStore) ListStarGifts(ctx context.Context) ([]StarGiftRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.gift_id, r.id, r.revision, r.title, r.stars, r.convert_stars,
c.enabled, c.sort_order, r.document_id, r.source_name, r.source_format,
encode(r.animation_sha256, 'hex'), d.size, r.width, r.height, r.frame_rate,
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id = c.gift_id),
r.created_by, c.updated_at
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
JOIN documents d ON d.id = r.document_id
ORDER BY c.sort_order, c.gift_id
LIMIT $1`, domain.MaxStarGiftCatalogSize)
if err != nil {
return nil, fmt.Errorf("list star gifts: %w", err)
}
defer rows.Close()
out := make([]StarGiftRow, 0)
for rows.Next() {
var row StarGiftRow
if err := rows.Scan(
&row.GiftID, &row.RevisionID, &row.Revision, &row.Title, &row.Stars, &row.ConvertStars,
&row.Enabled, &row.SortOrder, &row.DocumentID, &row.SourceName, &row.SourceFormat,
&row.AnimationSHA, &row.AnimationSize, &row.Width, &row.Height, &row.FrameRate,
&row.ReceivedCount, &row.CreatedBy, &row.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, error) {
q = strings.TrimSpace(q)
if q == "" {

View file

@ -10,6 +10,7 @@ import (
"fmt"
"io"
"io/fs"
"mime/multipart"
"net/http"
"path"
"strconv"
@ -56,6 +57,10 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
@ -64,6 +69,10 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
@ -163,6 +172,101 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
}
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, err := s.read.ListStarGifts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
}
func (s *server) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/gifts/%d/animation", s.cfg.AdminAPIURL, giftID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
if err != nil || len(raw) > 4<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
}
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
kind := r.PathValue("kind")
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 || (kind != "model" && kind != "pattern") {
writeAPIError(w, http.StatusBadRequest, "invalid collectible animation")
return
}
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles/%s/%d/animation", giftID, kind, attributeID), 4<<20)
}
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil || int64(len(raw)) > maxBytes {
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=30")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
@ -588,6 +692,184 @@ func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request)
writeCommandResultAPI(w, result, err)
}
type importStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body importStarGiftAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "animation file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
writeAPIError(w, http.StatusBadRequest, "animation file is empty or too large")
return
}
req := admin.ImportStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-gift"),
GiftID: body.GiftID,
Title: body.Title,
Stars: body.Stars,
ConvertStars: body.ConvertStars,
Enabled: body.Enabled,
SortOrder: body.SortOrder,
FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/gifts/import", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UpgradeStars int64 `json:"upgrade_stars"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
Patterns []admin.StarGiftCollectibleAnimationUpload `json:"patterns"`
Backdrops []admin.StarGiftCollectibleBackdropInput `json:"backdrops"`
}
func (s *server) handlePublishStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
giftID, err := strconv.ParseInt(r.URL.Query().Get("gift_id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body publishStarGiftCollectiblesAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
if len(body.Models)+len(body.Patterns) > 128 {
writeAPIError(w, http.StatusBadRequest, "too many collectible animation files")
return
}
seen := make(map[string]struct{}, len(body.Models)+len(body.Patterns))
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
upload.FileKey = strings.TrimSpace(upload.FileKey)
if upload.FileKey == "" {
return errors.New("animation file key is required")
}
if _, ok := seen[upload.FileKey]; ok {
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
}
seen[upload.FileKey] = struct{}{}
file, header, err := r.FormFile(upload.FileKey)
if err != nil {
return fmt.Errorf("animation file %q is required", upload.FileKey)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
}
upload.FileName = header.Filename
upload.Data = data
return nil
}
for i := range body.Models {
if err := load(&body.Models[i]); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
for i := range body.Patterns {
if err := load(&body.Patterns[i]); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
req := admin.PublishStarGiftCollectiblesRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "publish-gift-collectibles"),
GiftID: giftID, UpgradeStars: body.UpgradeStars, SupplyTotal: body.SupplyTotal,
SlugPrefix: body.SlugPrefix, Models: body.Models, Patterns: body.Patterns, Backdrops: body.Backdrops,
}
result, err := s.callAdminCollectibleMultipart(r.Context(), fmt.Sprintf("/v1/gifts/%d/collectibles/publish", giftID), req)
writeCommandResultAPI(w, result, err)
}
type setStarGiftEnabledAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
Enabled bool `json:"enabled"`
}
func (s *server) handleSetStarGiftEnabledAPI(w http.ResponseWriter, r *http.Request) {
var body setStarGiftEnabledAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStarGiftEnabledRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-enabled"),
GiftID: body.GiftID, Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-enabled", req)
writeCommandResultAPI(w, result, err)
}
type setStarGiftSortOrderAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id"`
SortOrder int `json:"sort_order"`
}
func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Request) {
var body setStarGiftSortOrderAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStarGiftSortOrderRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-sort-order"),
GiftID: body.GiftID, SortOrder: body.SortOrder,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-sort-order", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {
@ -639,6 +921,107 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
return result, nil
}
func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
meta, err := json.Marshal(metadata)
if err != nil {
return admin.CommandResult{}, err
}
if err := writer.WriteField("metadata", string(meta)); err != nil {
return admin.CommandResult{}, err
}
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return admin.CommandResult{}, err
}
if _, err := part.Write(data); err != nil {
return admin.CommandResult{}, err
}
if err := writer.Close(); err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func (s *server) callAdminCollectibleMultipart(ctx context.Context, apiPath string, payload admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
meta, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, err
}
if err := writer.WriteField("metadata", string(meta)); err != nil {
return admin.CommandResult{}, err
}
writeUploads := func(uploads []admin.StarGiftCollectibleAnimationUpload) error {
for _, upload := range uploads {
part, err := writer.CreateFormFile(upload.FileKey, upload.FileName)
if err != nil {
return err
}
if _, err := part.Write(upload.Data); err != nil {
return err
}
}
return nil
}
if err := writeUploads(payload.Models); err != nil {
return admin.CommandResult{}, err
}
if err := writeUploads(payload.Patterns); err != nil {
return admin.CommandResult{}, err
}
if err := writer.Close(); err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := decodeJSON(r, dst); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-DRWO_DgE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
<script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSxFgF7v.css">
</head>
<body>
<div id="root"></div>

View file

@ -8,6 +8,7 @@
"name": "telesrv-admin-ui",
"version": "0.1.0",
"dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@ -741,6 +742,12 @@
"loose-envify": "cli.js"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View file

@ -7,7 +7,9 @@ import type {
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse
MessageListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
} from "./types";
export class APIError extends Error {
@ -20,12 +22,10 @@ export class APIError extends Error {
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
},
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
...init
});
const text = await response.text();
@ -65,6 +65,12 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -7,7 +7,8 @@ import {
Server,
Shield,
ShieldCheck,
Users
Users,
Gift
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
@ -74,6 +75,7 @@ export function Shell({
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"

View file

@ -61,12 +61,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit",
"route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
"layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
@ -241,6 +244,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "Channel / Group",
"messages.pinned": "Pinned",
"messages.channelPost": "Channel post",
"gifts.pageTitle": "Star Gift Catalog",
"gifts.eyebrow": "Catalog, immutable revisions and animation assets",
"gifts.total": "Catalog entries",
"gifts.enabled": "Enabled",
"gifts.received": "Received gifts",
"gifts.formats": "Accepted formats",
"gifts.add": "Add gift",
"gifts.searchPlaceholder": "Search gift ID, title or format",
"gifts.listSummary": "Showing {shown} of {total}",
"gifts.idRevision": "ID / Revision",
"gifts.price": "Price / Conversion",
"gifts.importTitle": "Import a Star Gift",
"gifts.importEyebrow": "Gift catalog operation",
"gifts.newRevision": "Create revision for gift #{id}",
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
"gifts.animation": "Animation file",
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
"gifts.chooseFile": "Choose file",
"gifts.changeFile": "Change file",
"gifts.title": "Display title",
"gifts.titlePlaceholder": "e.g. Celebration Star",
"gifts.stars": "Price in Stars",
"gifts.convertStars": "Conversion Stars",
"gifts.sortOrder": "Sort order",
"gifts.reason": "Audit reason",
"gifts.reasonPlaceholder": "Briefly describe why this gift is being imported",
"gifts.enableAfterImport": "Enable after import",
"gifts.validate": "Dry-run validation",
"gifts.confirmImport": "Confirm import",
"gifts.stepDetails": "File and details",
"gifts.stepValidate": "Dry-run validation",
"gifts.stepImport": "Confirm import",
"gifts.fileRequired": "Choose a TGS or Lottie file first",
"gifts.source": "Source",
"gifts.replace": "New revision",
"gifts.disable": "Disable",
"gifts.enable": "Enable",
"gifts.empty": "No Star Gifts have been imported.",
"gifts.emptyHint": "Import the first animation above to build the gift catalog.",
"gifts.validationReady": "Validation passed",
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",
"collectibles.activeRevision": "Published revision {revision}",
"collectibles.published": "Published",
"collectibles.noPool": "No collectible pool published",
"collectibles.noPoolHint": "Publish models, patterns and backdrops to enable upgrades.",
"collectibles.publishNew": "Publish a new immutable revision",
"collectibles.immutableHint": "Dry-run checks every file and rarity total before the revision becomes active.",
"collectibles.upgradeStars": "Upgrade price in Stars",
"collectibles.supply": "Unique supply",
"collectibles.slug": "Public slug prefix",
"collectibles.models": "Models",
"collectibles.patterns": "Patterns",
"collectibles.backdrops": "Backdrops",
"collectibles.model": "Model",
"collectibles.pattern": "Pattern",
"collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰",
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
"collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute",
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
"collectibles.backdropID": "Backdrop ID",
"collectibles.color.center": "Center",
"collectibles.color.edge": "Edge",
"collectibles.color.pattern": "Pattern",
"collectibles.color.text": "Text",
"collectibles.validationReady": "Attribute pool is valid",
"collectibles.validationHint": "Review the normalized assets, then publish this immutable revision.",
"collectibles.publish": "Publish revision",
"messages.msgIDsInvalid": "Message IDs are invalid",
"auth.device": "Device",
"auth.platform": "Platform",
@ -332,12 +410,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息",
"route.gifts": "星星礼物",
"route.giftsSubtitle": "控制台 / 星星礼物",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
@ -512,6 +593,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"gifts.pageTitle": "星星礼物目录",
"gifts.eyebrow": "目录、不可变版本与动画资源",
"gifts.total": "目录条目",
"gifts.enabled": "已启用",
"gifts.received": "已领取礼物",
"gifts.formats": "支持格式",
"gifts.add": "添加礼物",
"gifts.searchPlaceholder": "搜索礼物 ID、标题或格式",
"gifts.listSummary": "显示 {shown} / {total} 项",
"gifts.idRevision": "ID / 版本",
"gifts.price": "售价 / 兑换",
"gifts.importTitle": "导入星星礼物",
"gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 TGS。",
"gifts.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验",
"gifts.chooseFile": "选择文件",
"gifts.changeFile": "更换文件",
"gifts.title": "显示标题",
"gifts.titlePlaceholder": "例如:庆典星星",
"gifts.stars": "售价 Stars",
"gifts.convertStars": "可兑换 Stars",
"gifts.sortOrder": "排序值",
"gifts.reason": "审计原因",
"gifts.reasonPlaceholder": "简要说明本次导入礼物的原因",
"gifts.enableAfterImport": "导入后启用",
"gifts.validate": "Dry-run 校验",
"gifts.confirmImport": "确认导入",
"gifts.stepDetails": "文件与信息",
"gifts.stepValidate": "Dry-run 校验",
"gifts.stepImport": "确认导入",
"gifts.fileRequired": "请先选择 TGS 或 Lottie 文件",
"gifts.source": "来源",
"gifts.replace": "创建新版本",
"gifts.disable": "停用",
"gifts.enable": "启用",
"gifts.empty": "尚未导入星星礼物。",
"gifts.emptyHint": "从上方导入第一个动画,开始搭建礼物目录。",
"gifts.validationReady": "校验已通过",
"gifts.validationHint": "确认规范化后的元数据无误,再执行正式导入。",
"gifts.confirmState": "确认执行礼物 #{id} 的状态变更吗?",
"collectibles.manage": "属性池",
"collectibles.title": "Collectibles 属性池 · 礼物 #{id}",
"collectibles.eyebrow": "唯一礼物属性管理",
"collectibles.activeRevision": "已发布版本 {revision}",
"collectibles.published": "已发布",
"collectibles.noPool": "尚未发布 Collectibles 属性池",
"collectibles.noPoolHint": "发布模型、图案与背景后,客户端即可升级为唯一礼物。",
"collectibles.publishNew": "发布新的不可变版本",
"collectibles.immutableHint": "Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。",
"collectibles.upgradeStars": "升级价格 Stars",
"collectibles.supply": "唯一礼物总量",
"collectibles.slug": "公开 Slug 前缀",
"collectibles.models": "模型",
"collectibles.patterns": "图案",
"collectibles.backdrops": "背景",
"collectibles.model": "模型",
"collectibles.pattern": "图案",
"collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性",
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
"collectibles.backdropID": "背景 ID",
"collectibles.color.center": "中心色",
"collectibles.color.edge": "边缘色",
"collectibles.color.pattern": "图案色",
"collectibles.color.text": "文字色",
"collectibles.validationReady": "属性池校验通过",
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
"collectibles.publish": "发布版本",
"messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备",
"auth.platform": "平台",

View file

@ -0,0 +1,4 @@
declare module "lottie-web/build/player/lottie_light_canvas" {
import lottie from "lottie-web";
export default lottie;
}

View file

@ -0,0 +1,236 @@
import { CheckCircle2, FileJson2, Gem, Loader2, Plus, ShieldCheck, Sparkles, Trash2, Upload, X } from "lucide-react";
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert, Badge } from "../components/ui";
import { useI18n } from "../i18n";
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
type AnimationData = Record<string, unknown>;
type AnimatedDraft = {
key: string;
name: string;
rarity: string;
sortOrder: string;
file: File | null;
animation: AnimationData | null;
fileError: string;
};
type BackdropDraft = {
key: string;
name: string;
backdropID: string;
rarity: string;
sortOrder: string;
center: string;
edge: string;
pattern: string;
text: string;
};
let draftSequence = 0;
const nextKey = (kind: string) => `${kind}-${++draftSequence}`;
const newAnimated = (kind: string): AnimatedDraft => ({ key: nextKey(kind), name: "", rarity: "1000", sortOrder: "0", file: null, animation: null, fileError: "" });
const newBackdrop = (): BackdropDraft => ({ key: nextKey("backdrop"), name: "", backdropID: "1", rarity: "1000", sortOrder: "0", center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" });
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!host.current) return;
const player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
return () => player.destroy();
}, [data]);
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
}
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
const [data, setData] = useState<AnimationData | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setFailed(false);
api.giftCollectibleAnimation(giftID, attribute.kind as "model" | "pattern", attribute.id)
.then((value) => { if (!cancelled) setData(value); })
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [giftID, attribute.id, attribute.kind]);
if (failed) return <div className="collectible-animation compact failed">!</div>;
if (!data) return <div className="collectible-animation compact loading"><Loader2 className="spin" size={15} /></div>;
return <AnimationPreview data={data} compact />;
}
async function parseAnimationFile(file: File): Promise<AnimationData> {
const bytes = new Uint8Array(await file.arrayBuffer());
let raw: Uint8Array = bytes;
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
if (!("DecompressionStream" in window)) throw new Error("This browser cannot preview TGS files");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
raw = new Uint8Array(await new Response(stream).arrayBuffer());
}
const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid Lottie JSON");
return parsed as AnimationData;
}
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState<CommandResult | null>(null);
const [upgradeStars, setUpgradeStars] = useState("100");
const [supplyTotal, setSupplyTotal] = useState("1000");
const [slugPrefix, setSlugPrefix] = useState(`gift-${gift.GiftID}`);
const [reason, setReason] = useState("");
const [models, setModels] = useState<AnimatedDraft[]>([newAnimated("model")]);
const [patterns, setPatterns] = useState<AnimatedDraft[]>([newAnimated("pattern")]);
const [backdrops, setBackdrops] = useState<BackdropDraft[]>([newBackdrop()]);
useEffect(() => {
let cancelled = false;
api.giftCollectibles(gift.GiftID).then((value) => {
if (cancelled) return;
setActive(value);
if (value.found) {
setUpgradeStars(String(value.upgrade_stars ?? 100));
setSupplyTotal(String(value.supply_total ?? 1000));
setSlugPrefix(value.slug_prefix ?? `gift-${gift.GiftID}`);
}
}).catch((err) => setError(errorMessage(err))).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [gift.GiftID]);
const rarityTotals = useMemo(() => ({
models: models.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
patterns: patterns.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
backdrops: backdrops.reduce((sum, value) => sum + Number(value.rarity || 0), 0)
}), [models, patterns, backdrops]);
const invalidate = () => setPreview(null);
const updateAnimated = (kind: "models" | "patterns", key: string, patch: Partial<AnimatedDraft>) => {
const setter = kind === "models" ? setModels : setPatterns;
setter((rows) => rows.map((row) => row.key === key ? { ...row, ...patch } : row));
invalidate();
};
async function chooseFile(kind: "models" | "patterns", row: AnimatedDraft, file: File | null) {
updateAnimated(kind, row.key, { file, animation: null, fileError: "" });
if (!file) return;
try {
const animation = await parseAnimationFile(file);
updateAnimated(kind, row.key, { animation, fileError: "" });
} catch (err) {
updateAnimated(kind, row.key, { animation: null, fileError: errorMessage(err) });
}
}
function buildForm(confirm: boolean, commandID = "") {
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired"));
const form = new FormData();
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
form.set("metadata", JSON.stringify({
command_id: commandID, reason: reason.trim(), confirm,
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
backdrops: backdrops.map((row) => ({
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
center_color: colorNumber(row.center), edge_color: colorNumber(row.edge), pattern_color: colorNumber(row.pattern), text_color: colorNumber(row.text)
}))
}));
for (const row of [...models, ...patterns]) form.set(row.key, row.file as File, (row.file as File).name);
return form;
}
async function validate() {
setBusy(true); setError(""); setPreview(null);
try { setPreview(await api.publishGiftCollectibles(gift.GiftID, buildForm(false))); }
catch (err) { setError(errorMessage(err)); }
finally { setBusy(false); }
}
async function publish() {
if (!preview) return;
setBusy(true); setError("");
try {
await api.publishGiftCollectibles(gift.GiftID, buildForm(true, preview.command_id));
onPublished(); onClose();
} catch (err) { setError(errorMessage(err)); }
finally { setBusy(false); }
}
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
<section className="collectible-section">
<div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
</div>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label>
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
<button className="icon-btn danger" type="button" disabled={rows.length === 1} onClick={() => { setRows(rows.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)}
</div>
</section>
);
return createPortal(<div className="modal-backdrop" role="presentation">
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={t("collectibles.title", { id: gift.GiftID })}>
<div className="modal-head">
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body collectible-modal-body">
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
<div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}</span></div></article>)}
</div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
<section className="collectible-definition">
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="gift-fields-grid collectible-main-fields">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
</div>
{renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
<button className="icon-btn danger" type="button" disabled={backdrops.length === 1} onClick={() => { setBackdrops(backdrops.filter((value) => value.key !== row.key)); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
</div>)}</div>
</section>
</section>
{error && <Alert>{error}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button>
</div>
</section>
</div>, document.body);
}

View file

@ -0,0 +1,237 @@
import { CheckCircle2, FileJson2, Gem, Loader2, Pause, Play, Plus, RefreshCw, Search, ShieldCheck, Upload, X } from "lucide-react";
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { CommandResult, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.giftAnimation(giftID).then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: structuredClone(data)
});
}).catch((err) => setError(errorMessage(err)));
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
}, [giftID, revision]);
function toggle() {
if (!animation.current) return;
if (playing) animation.current.pause();
else animation.current.play();
setPlaying(!playing);
}
return (
<div className={`gift-animation-shell ${compact ? "compact" : ""}`}>
<div className="gift-animation" ref={host}>{error && <span>{error}</span>}</div>
<button className="gift-play" type="button" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
{playing ? <Pause size={14} /> : <Play size={14} />}
</button>
</div>
);
}
export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [giftID, setGiftID] = useState(0);
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
const [convertStars, setConvertStars] = useState("50");
const [sortOrder, setSortOrder] = useState("0");
const [enabled, setEnabled] = useState(true);
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<CommandResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [importError, setImportError] = useState("");
async function load() {
setError("");
try {
setGifts((await api.gifts()).Gifts ?? []);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
return gifts.filter((gift) =>
String(gift.GiftID).includes(normalized) ||
gift.Title.toLowerCase().includes(normalized) ||
gift.SourceFormat.toLowerCase().includes(normalized)
);
}, [gifts, query]);
function uploadForm(confirm: boolean, commandID = "") {
if (!file) throw new Error(t("gifts.fileRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
const form = new FormData();
form.set("metadata", JSON.stringify({
command_id: commandID,
reason: reason.trim(),
confirm,
gift_id: giftID,
title: title.trim(),
stars: Number(stars),
convert_stars: Number(convertStars),
enabled,
sort_order: Number(sortOrder)
}));
form.set("file", file, file.name);
return form;
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
setPreview(await api.importGift(uploadForm(false)));
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
}
async function confirmImport() {
if (!preview) return;
setBusy(true); setImportError("");
try {
await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID(0); setTitle("");
await load();
setImportOpen(false);
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
}
function startImport() {
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
}
function startRevision(gift: StarGiftRow) {
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
}
return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button>
</>}>
{error && <Alert>{error}</Alert>}
<div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
</div>
</QueryPanel>
<div className="table-wrap gift-table-wrap">
<table className="data-table gift-table">
<thead><tr><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead>
<tbody>
{visibleGifts.map((gift) => (
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{t("gifts.sortOrder")}: {gift.SortOrder}</span></td>
<td><strong className="gift-table-price"> {gift.Stars}</strong><span className="gift-convert-price"> {gift.ConvertStars}</span></td>
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
<td>{gift.ReceivedCount}</td>
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? t("common.enabled") : t("common.disabled")}</Badge></td>
<td>{formatDate(gift.UpdatedAt)}</td>
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
</tr>
))}
{visibleGifts.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
{importOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
{importError && <Alert>{importError}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button>
</div>
</section>
</div>,
document.body
)}
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
</PageFrame>
);
}

View file

@ -8,6 +8,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
@ -24,6 +25,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
return t("route.dashboard");
}
@ -27,5 +28,6 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
return t("route.dashboardSubtitle");
}

View file

@ -252,3 +252,198 @@
border: 1px solid var(--line);
border-radius: 8px;
}
.gift-metrics .metric {
min-height: 68px;
padding: 12px;
background: linear-gradient(145deg, #ffffff, #f6f9f9);
}
.gift-metrics .metric strong { font-size: 17px; }
.gift-file-icon {
display: grid;
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: #eaf6f3;
border: 1px solid #c7e3dc;
}
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.gift-format-chips span { padding: 4px 8px; color: #33645d; background: #eef8f5; border: 1px solid #cfe5df; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
.gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; }
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
.gift-file-picker {
position: relative;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
min-height: 78px;
align-items: center;
gap: 12px;
padding: 12px 14px;
color: var(--text);
background: #ffffff;
border: 1px dashed #b7ccc8;
border-radius: 10px;
cursor: pointer;
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.gift-file-picker:hover,
.gift-file-picker.has-file { background: #f8fcfb; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .05); }
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #f0f8f6; border: 1px solid #c7e3dc; border-radius: 7px; font-size: 11px; font-weight: 800; }
.gift-fields-grid {
display: grid;
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
gap: 10px;
}
.gift-fields-grid label,
.gift-reason-field {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.gift-fields-grid input,
.gift-reason-field input {
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: #fff;
border: 1px solid var(--line);
border-radius: 7px;
}
.gift-fields-grid input:focus,
.gift-reason-field input:focus { border-color: #77b6aa; box-shadow: 0 0 0 3px rgba(23, 109, 97, .08); outline: none; }
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: #c8d0d5; border-radius: 999px; transition: background .16s ease; }
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(23, 109, 97, .16); outline-offset: 2px; }
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
.gift-validation-head div { display: grid; gap: 2px; }
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eef8f5); }
.gift-animation { width: 200px; height: 200px; }
.gift-animation canvas { width: 100% !important; height: 100% !important; }
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }
.gift-table-wrap { background: #ffffff; }
.gift-table { min-width: 1080px; }
.gift-table th:first-child { width: 74px; }
.gift-table td { vertical-align: middle; }
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
.gift-animation-shell.compact .gift-animation { width: 54px; height: 54px; }
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }
.gift-row-disabled { opacity: .68; }
.gift-table-title,
.gift-sort-order,
.gift-source-size,
.gift-convert-price { display: block; }
.gift-table-title { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gift-sort-order,
.gift-source-size,
.gift-convert-price { margin-top: 3px; color: var(--muted); font-size: 10px; }
.gift-table-price { color: #755b00; }
.gift-table-actions { display: flex; align-items: center; gap: 6px; }
.collectible-button { color: #6548a8; background: #f7f3ff; border-color: #ddd2f5; }
.collectible-button:hover { background: #efe8ff; border-color: #cbbaf0; }
.collectible-modal { width: min(1180px, 100%); max-height: min(92vh, 980px); }
.collectible-modal .modal-head p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: #f5f7fa; }
.collectible-loading { display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 8px; color: var(--muted); }
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: #66568c; background: linear-gradient(135deg, #fbf9ff, #f2f7ff); border: 1px dashed #cfc3e9; border-radius: 12px; }
.collectible-empty div,
.collectible-definition-head > div:first-child,
.collectible-section-head > div:first-child { display: grid; gap: 3px; }
.collectible-empty span,
.collectible-definition-head span,
.collectible-section-head span { color: var(--muted); font-size: 10px; font-weight: 500; }
.collectible-active { overflow: hidden; background: #ffffff; border: 1px solid #ddd6ee; border-radius: 12px; box-shadow: 0 5px 16px rgba(66, 46, 110, .05); }
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(100deg, #fbf9ff, #f4f9ff); border-bottom: 1px solid #e9e4f3; }
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: #60458f; }
.collectible-active-head > div > div { display: grid; gap: 2px; }
.collectible-active-head span { color: var(--muted); font-size: 10px; }
.collectible-active-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 1px; background: var(--line); }
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: #ffffff; }
.collectible-active-grid article > div:last-child { display: grid; min-width: 0; gap: 2px; }
.collectible-active-grid article strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.collectible-active-grid article span { color: var(--muted); font-size: 9px; }
.collectible-definition { overflow: hidden; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 24px rgba(16, 24, 40, .04); }
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: linear-gradient(110deg, #f8fbfa, #fbf9ff); border-bottom: 1px solid var(--line); }
.collectible-main-fields { padding: 14px 16px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
.collectible-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
.collectible-section:last-child { border-bottom: 0; }
.collectible-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.collectible-section-tools { display: flex; align-items: center; gap: 7px; }
.collectible-rows { display: grid; gap: 7px; }
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: #fafbfc; border: 1px solid #e1e6eb; border-radius: 9px; }
.collectible-row:hover { background: #ffffff; border-color: #cbd7dd; box-shadow: 0 3px 10px rgba(16, 24, 40, .035); }
.collectible-row.animated { grid-template-columns: minmax(120px, 1.2fr) 90px 78px minmax(160px, 1.4fr) 48px 30px; }
.collectible-row.backdrop { grid-template-columns: minmax(110px, 1.2fr) 70px 80px 70px repeat(4, 52px) 48px 30px; }
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: #71668c; background: #f0edf7; border-right: 1px solid #e0d9ed; border-radius: 8px 0 0 8px; font-size: 10px; font-weight: 800; }
.collectible-row label { display: grid; min-width: 0; gap: 4px; }
.collectible-row label > span { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .025em; }
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: #ffffff; border: 1px solid #d5dde3; border-radius: 7px; font: inherit; font-size: 11px; }
.collectible-row input:focus { border-color: #8d7aba; box-shadow: 0 0 0 3px rgba(111, 91, 174, .08); outline: none; }
.collectible-file input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: #625080; background: #f7f4fd; border: 1px dashed #cfc4e1; border-radius: 7px; font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: #8c7cae; background: radial-gradient(circle, #ffffff, #eee8f8); border: 1px solid #ded5ed; border-radius: 8px; }
.collectible-animation { width: 100%; height: 100%; overflow: hidden; }
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: radial-gradient(circle, #ffffff, #f0ebfa); border: 1px solid #e0d9ec; border-radius: 8px; }
.collectible-animation canvas { width: 100% !important; height: 100% !important; }
.collectible-animation.failed { color: #b42318; background: #fff4f2; }
.collectible-animation.loading { color: #807397; }
.collectible-file-error { grid-column: 1 / -1; color: #b42318; font-size: 10px; }
.collectible-color input { height: 32px !important; padding: 3px !important; cursor: pointer; }
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
.collectible-row .icon-btn { align-self: center; }
.collectible-row .icon-btn:disabled { opacity: .28; }
@media (max-width: 900px) {
.gift-fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collectible-inline-preview,
.collectible-backdrop-preview,
.collectible-row .icon-btn { align-self: center; justify-self: start; }
}
@media (max-width: 620px) {
.gift-import-note { align-items: flex-start; flex-direction: column; }
.gift-format-chips { justify-content: flex-start; }
.gift-file-picker { grid-template-columns: 40px minmax(0, 1fr); }
.gift-file-action { display: none; }
.gift-fields-grid { grid-template-columns: 1fr; }
.gift-list-summary { width: 100%; margin-left: 0; }
.collectible-modal-body { padding: 10px; }
.collectible-definition-head,
.collectible-section-head { align-items: flex-start; flex-direction: column; }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: 1fr; }
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
}

View file

@ -160,6 +160,58 @@ export type OutboxRow = {
UpdatedAt: string;
};
export type StarGiftRow = {
GiftID: number;
RevisionID: number;
Revision: number;
Title: string;
Stars: number;
ConvertStars: number;
Enabled: boolean;
SortOrder: number;
DocumentID: number;
SourceName: string;
SourceFormat: "tgs" | "lottie";
AnimationSHA: string;
AnimationSize: number;
Width: number;
Height: number;
FrameRate: number;
ReceivedCount: number;
CreatedBy: string;
UpdatedAt: string;
};
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
export type StarGiftCollectibleAttributeRow = {
id: number;
kind: "model" | "pattern" | "backdrop";
name: string;
rarity_permille: number;
sort_order: number;
source_name?: string;
source_format?: "tgs" | "lottie";
backdrop_id?: number;
center_color?: number;
edge_color?: number;
pattern_color?: number;
text_color?: number;
};
export type StarGiftCollectiblePreview = {
found: boolean;
gift_id: number;
revision?: number;
upgrade_stars?: number;
supply_total?: number;
issued?: number;
slug_prefix?: string;
models?: StarGiftCollectibleAttributeRow[];
patterns?: StarGiftCollectibleAttributeRow[];
backdrops?: StarGiftCollectibleAttributeRow[];
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;

View file

@ -634,7 +634,9 @@ func run(logger *zap.Logger) error {
starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant))
starGiftStore := postgres.NewStarGiftStore(pool)
giftsService := stargifts.NewService(starGiftStore, filesService)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore)
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
stargifts.WithUpgradeStore(starGiftUpgradeStore))
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
@ -776,6 +778,7 @@ func run(logger *zap.Logger) error {
RPCProjections: router,
BaseUsers: userCache,
BotProfiles: botsService,
StarGifts: giftsService,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
@ -789,6 +792,7 @@ func run(logger *zap.Logger) error {
Channels: channelsService,
ChannelNotifier: router,
Messages: messagesService,
Gifts: giftsService,
})
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。

View file

@ -0,0 +1,17 @@
DROP TRIGGER IF EXISTS star_gift_catalog_changed ON public.star_gift_catalog;
DROP FUNCTION IF EXISTS public.telesrv_notify_star_gift_catalog_changed();
ALTER TABLE public.peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_catalog_revision_fk,
DROP COLUMN IF EXISTS catalog_revision_id;
DROP INDEX IF EXISTS public.peer_star_gifts_gift_idx;
ALTER TABLE public.star_gift_catalog
DROP CONSTRAINT IF EXISTS star_gift_catalog_active_revision_fk;
DROP TABLE IF EXISTS public.star_gift_catalog_revisions;
DROP TABLE IF EXISTS public.star_gift_catalog;
DROP SEQUENCE IF EXISTS public.star_gift_catalog_revision_id_seq;
DROP SEQUENCE IF EXISTS public.star_gift_catalog_gift_id_seq;
DELETE FROM public.read_model_versions
WHERE model = 'star_gift_catalog' AND owner_user_id = 0 AND peer_type = '' AND peer_id = 0;

View file

@ -0,0 +1,81 @@
-- Durable, administrator-managed regular Star Gift catalog. The previous seven-item
-- animated_emoji-derived directory was development-only and is intentionally not migrated.
-- Existing received rows refer to those non-durable definitions, so clear them instead of
-- manufacturing a read-time compatibility fallback that could no longer reconstruct assets.
CREATE SEQUENCE public.star_gift_catalog_gift_id_seq AS bigint START WITH 9000000000000001;
CREATE SEQUENCE public.star_gift_catalog_revision_id_seq AS bigint START WITH 1;
CREATE TABLE public.star_gift_catalog (
gift_id bigint DEFAULT nextval('public.star_gift_catalog_gift_id_seq') NOT NULL,
active_revision_id bigint NOT NULL,
enabled boolean DEFAULT true NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_catalog_pkey PRIMARY KEY (gift_id)
);
CREATE TABLE public.star_gift_catalog_revisions (
id bigint DEFAULT nextval('public.star_gift_catalog_revision_id_seq') NOT NULL,
gift_id bigint NOT NULL,
revision integer NOT NULL,
title text DEFAULT '' NOT NULL,
stars bigint NOT NULL,
convert_stars bigint NOT NULL,
document_id bigint NOT NULL,
animation_json jsonb NOT NULL,
animation_sha256 bytea NOT NULL,
source_name text DEFAULT '' NOT NULL,
source_format text NOT NULL,
width integer NOT NULL,
height integer NOT NULL,
frame_rate double precision DEFAULT 0 NOT NULL,
in_point double precision DEFAULT 0 NOT NULL,
out_point double precision DEFAULT 0 NOT NULL,
created_by text DEFAULT '' NOT NULL,
command_id text DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_catalog_revisions_pkey PRIMARY KEY (id),
CONSTRAINT star_gift_catalog_revisions_gift_revision_uniq UNIQUE (gift_id, revision),
CONSTRAINT star_gift_catalog_revisions_document_uniq UNIQUE (document_id),
CONSTRAINT star_gift_catalog_revision_price_check CHECK (stars > 0 AND convert_stars >= 0 AND convert_stars <= stars),
CONSTRAINT star_gift_catalog_revision_shape_check CHECK (width = 512 AND height = 512 AND jsonb_typeof(animation_json) = 'object'),
CONSTRAINT star_gift_catalog_revision_source_check CHECK (source_format IN ('tgs', 'lottie')),
CONSTRAINT star_gift_catalog_revision_gift_fk FOREIGN KEY (gift_id)
REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT star_gift_catalog_revision_document_fk FOREIGN KEY (document_id)
REFERENCES public.documents(id) ON DELETE RESTRICT
);
ALTER TABLE public.star_gift_catalog
ADD CONSTRAINT star_gift_catalog_active_revision_fk FOREIGN KEY (active_revision_id)
REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX star_gift_catalog_enabled_order_idx
ON public.star_gift_catalog (sort_order, gift_id) WHERE enabled;
CREATE INDEX star_gift_catalog_revisions_gift_idx
ON public.star_gift_catalog_revisions (gift_id, revision DESC);
DELETE FROM public.peer_star_gifts;
ALTER TABLE public.peer_star_gifts
ADD COLUMN catalog_revision_id bigint NOT NULL,
ADD CONSTRAINT peer_star_gifts_catalog_revision_fk FOREIGN KEY (catalog_revision_id)
REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT;
CREATE INDEX peer_star_gifts_catalog_revision_idx
ON public.peer_star_gifts (catalog_revision_id);
CREATE INDEX peer_star_gifts_gift_idx
ON public.peer_star_gifts (gift_id);
CREATE FUNCTION public.telesrv_notify_star_gift_catalog_changed() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM public.telesrv_bump_read_model_version('star_gift_catalog', 0, '', 0);
RETURN NULL;
END;
$$;
CREATE TRIGGER star_gift_catalog_changed
AFTER INSERT OR UPDATE OR DELETE ON public.star_gift_catalog
FOR EACH STATEMENT EXECUTE FUNCTION public.telesrv_notify_star_gift_catalog_changed();

View file

@ -0,0 +1,31 @@
DROP TRIGGER IF EXISTS star_gift_collectible_backdrop_guard ON public.star_gift_collectible_backdrops;
DROP TRIGGER IF EXISTS star_gift_collectible_pattern_guard ON public.star_gift_collectible_patterns;
DROP TRIGGER IF EXISTS star_gift_collectible_model_guard ON public.star_gift_collectible_models;
DROP TRIGGER IF EXISTS star_gift_collectible_revision_guard ON public.star_gift_collectible_revisions;
DROP FUNCTION IF EXISTS public.telesrv_guard_collectible_attribute();
DROP FUNCTION IF EXISTS public.telesrv_guard_collectible_revision();
DROP TABLE IF EXISTS public.star_gift_collection_items;
DROP TABLE IF EXISTS public.star_gift_collections;
DROP TABLE IF EXISTS public.star_gift_upgrade_commands;
DROP INDEX IF EXISTS public.peer_star_gifts_unique_gift_uniq;
ALTER TABLE public.peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_pinned_order_check,
DROP CONSTRAINT IF EXISTS peer_star_gifts_terminal_state_check,
DROP CONSTRAINT IF EXISTS peer_star_gifts_unique_gift_fk,
DROP COLUMN IF EXISTS pinned_order,
DROP COLUMN IF EXISTS upgrade_msg_id,
DROP COLUMN IF EXISTS unique_gift_id;
DROP TABLE IF EXISTS public.unique_star_gifts;
ALTER TABLE public.star_gift_catalog
DROP CONSTRAINT IF EXISTS star_gift_catalog_collectible_revision_fk,
DROP COLUMN IF EXISTS collectible_revision_id;
DROP TABLE IF EXISTS public.star_gift_collectible_backdrops;
DROP TABLE IF EXISTS public.star_gift_collectible_patterns;
DROP TABLE IF EXISTS public.star_gift_collectible_models;
DROP TABLE IF EXISTS public.star_gift_collectible_revisions;
DROP SEQUENCE IF EXISTS public.unique_star_gift_id_seq;

View file

@ -0,0 +1,238 @@
-- Collectible Star Gifts: immutable published attribute pools, unique gift instances and
-- peer-owned gift collections. Marketplace/transfer/auction/craft/TON state is intentionally
-- absent from this schema.
CREATE SEQUENCE public.unique_star_gift_id_seq AS bigint START WITH 9200000000000001;
CREATE TABLE public.star_gift_collectible_revisions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
revision integer NOT NULL,
upgrade_stars bigint NOT NULL,
supply_total integer NOT NULL,
issued integer DEFAULT 0 NOT NULL,
slug_prefix text NOT NULL,
status text DEFAULT 'draft' NOT NULL,
created_by text DEFAULT '' NOT NULL,
command_id text DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
published_at timestamp with time zone,
CONSTRAINT star_gift_collectible_revision_uniq UNIQUE (gift_id, revision),
CONSTRAINT star_gift_collectible_command_uniq UNIQUE (gift_id, command_id),
CONSTRAINT star_gift_collectible_price_check CHECK (upgrade_stars > 0),
CONSTRAINT star_gift_collectible_supply_check CHECK (supply_total > 0 AND issued >= 0 AND issued <= supply_total),
CONSTRAINT star_gift_collectible_slug_check CHECK (slug_prefix ~ '^[a-z0-9][a-z0-9-]{0,47}$'),
CONSTRAINT star_gift_collectible_status_check CHECK (status IN ('draft', 'published')),
CONSTRAINT star_gift_collectible_publish_check CHECK (
(status = 'draft' AND published_at IS NULL) OR
(status = 'published' AND published_at IS NOT NULL)
)
);
CREATE TABLE public.star_gift_collectible_models (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
collectible_revision_id bigint NOT NULL REFERENCES public.star_gift_collectible_revisions(id) ON DELETE CASCADE,
name text NOT NULL,
document_id bigint NOT NULL REFERENCES public.documents(id) ON DELETE RESTRICT,
animation_json jsonb NOT NULL,
animation_sha256 bytea NOT NULL,
source_name text DEFAULT '' NOT NULL,
source_format text NOT NULL,
width integer NOT NULL,
height integer NOT NULL,
frame_rate double precision DEFAULT 0 NOT NULL,
in_point double precision DEFAULT 0 NOT NULL,
out_point double precision DEFAULT 0 NOT NULL,
rarity_permille integer NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
CONSTRAINT star_gift_collectible_model_name_uniq UNIQUE (collectible_revision_id, name),
CONSTRAINT star_gift_collectible_model_id_revision_uniq UNIQUE (id, collectible_revision_id),
CONSTRAINT star_gift_collectible_model_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000),
CONSTRAINT star_gift_collectible_model_shape_check CHECK (width = 512 AND height = 512 AND jsonb_typeof(animation_json) = 'object'),
CONSTRAINT star_gift_collectible_model_source_check CHECK (source_format IN ('tgs', 'lottie'))
);
CREATE TABLE public.star_gift_collectible_patterns (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
collectible_revision_id bigint NOT NULL REFERENCES public.star_gift_collectible_revisions(id) ON DELETE CASCADE,
name text NOT NULL,
document_id bigint NOT NULL REFERENCES public.documents(id) ON DELETE RESTRICT,
animation_json jsonb NOT NULL,
animation_sha256 bytea NOT NULL,
source_name text DEFAULT '' NOT NULL,
source_format text NOT NULL,
width integer NOT NULL,
height integer NOT NULL,
frame_rate double precision DEFAULT 0 NOT NULL,
in_point double precision DEFAULT 0 NOT NULL,
out_point double precision DEFAULT 0 NOT NULL,
rarity_permille integer NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
CONSTRAINT star_gift_collectible_pattern_name_uniq UNIQUE (collectible_revision_id, name),
CONSTRAINT star_gift_collectible_pattern_id_revision_uniq UNIQUE (id, collectible_revision_id),
CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000),
CONSTRAINT star_gift_collectible_pattern_shape_check CHECK (width = 512 AND height = 512 AND jsonb_typeof(animation_json) = 'object'),
CONSTRAINT star_gift_collectible_pattern_source_check CHECK (source_format IN ('tgs', 'lottie'))
);
CREATE TABLE public.star_gift_collectible_backdrops (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
collectible_revision_id bigint NOT NULL REFERENCES public.star_gift_collectible_revisions(id) ON DELETE CASCADE,
name text NOT NULL,
backdrop_id integer NOT NULL,
center_color integer NOT NULL,
edge_color integer NOT NULL,
pattern_color integer NOT NULL,
text_color integer NOT NULL,
rarity_permille integer NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
CONSTRAINT star_gift_collectible_backdrop_name_uniq UNIQUE (collectible_revision_id, name),
CONSTRAINT star_gift_collectible_backdrop_display_uniq UNIQUE (collectible_revision_id, backdrop_id),
CONSTRAINT star_gift_collectible_backdrop_id_revision_uniq UNIQUE (id, collectible_revision_id),
CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000),
CONSTRAINT star_gift_collectible_backdrop_color_check CHECK (
center_color BETWEEN 0 AND 16777215 AND edge_color BETWEEN 0 AND 16777215 AND
pattern_color BETWEEN 0 AND 16777215 AND text_color BETWEEN 0 AND 16777215
)
);
ALTER TABLE public.star_gift_catalog
ADD COLUMN collectible_revision_id bigint,
ADD CONSTRAINT star_gift_catalog_collectible_revision_fk FOREIGN KEY (collectible_revision_id)
REFERENCES public.star_gift_collectible_revisions(id) ON DELETE RESTRICT;
CREATE TABLE public.unique_star_gifts (
id bigint DEFAULT nextval('public.unique_star_gift_id_seq') PRIMARY KEY,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
collectible_revision_id bigint NOT NULL REFERENCES public.star_gift_collectible_revisions(id) ON DELETE RESTRICT,
source_saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
title text DEFAULT '' NOT NULL,
slug text NOT NULL,
num integer NOT NULL,
owner_peer_type text NOT NULL,
owner_peer_id bigint NOT NULL,
model_attribute_id bigint NOT NULL,
pattern_attribute_id bigint NOT NULL,
backdrop_attribute_id bigint NOT NULL,
keep_original_details boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT unique_star_gift_slug_uniq UNIQUE (slug),
CONSTRAINT unique_star_gift_number_uniq UNIQUE (gift_id, num),
CONSTRAINT unique_star_gift_source_uniq UNIQUE (source_saved_gift_id),
CONSTRAINT unique_star_gift_owner_check CHECK (owner_peer_type IN ('user', 'channel') AND owner_peer_id > 0),
CONSTRAINT unique_star_gift_num_check CHECK (num > 0),
CONSTRAINT unique_star_gift_model_fk FOREIGN KEY (model_attribute_id, collectible_revision_id)
REFERENCES public.star_gift_collectible_models(id, collectible_revision_id) ON DELETE RESTRICT,
CONSTRAINT unique_star_gift_pattern_fk FOREIGN KEY (pattern_attribute_id, collectible_revision_id)
REFERENCES public.star_gift_collectible_patterns(id, collectible_revision_id) ON DELETE RESTRICT,
CONSTRAINT unique_star_gift_backdrop_fk FOREIGN KEY (backdrop_attribute_id, collectible_revision_id)
REFERENCES public.star_gift_collectible_backdrops(id, collectible_revision_id) ON DELETE RESTRICT
);
ALTER TABLE public.peer_star_gifts
ADD COLUMN unique_gift_id bigint,
ADD COLUMN upgrade_msg_id integer DEFAULT 0 NOT NULL,
ADD COLUMN pinned_order integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT peer_star_gifts_unique_gift_fk FOREIGN KEY (unique_gift_id)
REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
ADD CONSTRAINT peer_star_gifts_terminal_state_check CHECK (NOT converted OR unique_gift_id IS NULL),
ADD CONSTRAINT peer_star_gifts_pinned_order_check CHECK (pinned_order >= 0);
CREATE UNIQUE INDEX peer_star_gifts_unique_gift_uniq ON public.peer_star_gifts(unique_gift_id)
WHERE unique_gift_id IS NOT NULL;
CREATE INDEX unique_star_gifts_owner_idx ON public.unique_star_gifts(owner_peer_type, owner_peer_id, id DESC);
CREATE TABLE public.star_gift_upgrade_commands (
user_id bigint NOT NULL,
command_key text NOT NULL,
source_saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
form_id bigint DEFAULT 0 NOT NULL,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
balance_after bigint NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_upgrade_commands_pkey PRIMARY KEY (user_id, command_key),
CONSTRAINT star_gift_upgrade_command_source_uniq UNIQUE (source_saved_gift_id)
);
CREATE TABLE public.star_gift_collections (
collection_id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_peer_type text NOT NULL,
owner_peer_id bigint NOT NULL,
title text NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
hash bigint DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_collection_owner_check CHECK (owner_peer_type IN ('user', 'channel') AND owner_peer_id > 0),
CONSTRAINT star_gift_collection_title_check CHECK (char_length(title) BETWEEN 1 AND 12),
CONSTRAINT star_gift_collection_owner_id_uniq UNIQUE (owner_peer_type, owner_peer_id, collection_id)
);
CREATE INDEX star_gift_collections_owner_order_idx
ON public.star_gift_collections(owner_peer_type, owner_peer_id, sort_order, collection_id);
CREATE TABLE public.star_gift_collection_items (
collection_id integer NOT NULL REFERENCES public.star_gift_collections(collection_id) ON DELETE CASCADE,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE CASCADE,
sort_order integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_collection_items_pkey PRIMARY KEY (collection_id, saved_gift_id)
);
CREATE INDEX star_gift_collection_items_order_idx
ON public.star_gift_collection_items(collection_id, sort_order, saved_gift_id);
CREATE INDEX star_gift_collection_items_saved_idx
ON public.star_gift_collection_items(saved_gift_id, collection_id);
-- Published definitions are immutable. The only permitted update is incrementing issued while
-- all definition columns remain byte-for-byte equal.
CREATE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status = 'published' THEN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER star_gift_collectible_revision_guard
BEFORE UPDATE OR DELETE ON public.star_gift_collectible_revisions
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_collectible_revision();
CREATE FUNCTION public.telesrv_guard_collectible_attribute() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
revision_status text;
BEGIN
SELECT status INTO revision_status FROM public.star_gift_collectible_revisions
WHERE id = COALESCE(OLD.collectible_revision_id, NEW.collectible_revision_id);
IF revision_status = 'published' THEN
RAISE EXCEPTION 'published collectible attributes are immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER star_gift_collectible_model_guard BEFORE UPDATE OR DELETE ON public.star_gift_collectible_models
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_collectible_attribute();
CREATE TRIGGER star_gift_collectible_pattern_guard BEFORE UPDATE OR DELETE ON public.star_gift_collectible_patterns
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_collectible_attribute();
CREATE TRIGGER star_gift_collectible_backdrop_guard BEFORE UPDATE OR DELETE ON public.star_gift_collectible_backdrops
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_collectible_attribute();

View file

@ -0,0 +1,3 @@
ALTER TABLE public.peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_prepaid_upgrade_check,
DROP COLUMN IF EXISTS prepaid_upgrade_stars;

View file

@ -0,0 +1,15 @@
ALTER TABLE public.peer_star_gifts
ADD COLUMN IF NOT EXISTS prepaid_upgrade_stars bigint DEFAULT 0 NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'public.peer_star_gifts'::regclass
AND conname = 'peer_star_gifts_prepaid_upgrade_check'
) THEN
ALTER TABLE public.peer_star_gifts
ADD CONSTRAINT peer_star_gifts_prepaid_upgrade_check CHECK (prepaid_upgrade_stars >= 0);
END IF;
END;
$$;

View file

@ -0,0 +1,21 @@
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status = 'published' THEN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;

View file

@ -0,0 +1,28 @@
-- Tighten the published collectible invariant. Issuance is the only mutable
-- field and every committed upgrade advances it exactly once.
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'published' THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
RETURN OLD;
END IF;
IF OLD.status = 'published' THEN
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
IF NEW.issued <> OLD.issued + 1 THEN
RAISE EXCEPTION 'published collectible issuance must advance exactly once';
END IF;
END IF;
RETURN NEW;
END;
$$;

View file

@ -126,7 +126,7 @@ This document describes every setting loaded by `internal/config`. Defaults and
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business auto-reply generator: `echo`, `template`/`quick_reply`, or `ai`/`compose_ai`/a configured provider name. |
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business auto-reply generator. Allowed values are `echo`/empty (echo the triggering text), `template`/`quick_reply`/`quick-reply` (use quick-reply templates), or `ai`/`compose_ai`/`ai_compose`/`aicompose`/`kimi` (reuse the `TELESRV_AI_PROVIDERS` provider chain). This setting does not accept arbitrary provider names; for example, with Ollama set `TELESRV_BUSINESS_AI_PROVIDER=ai` and select the actual provider through `TELESRV_AI_PROVIDERS=ollama,local`. |
| `TELESRV_AI_ENABLED` | bool / `true` | Enables client compose rewrite/polish. False returns no tones and hides the entry. |
| `TELESRV_AI_PROVIDERS` | list / `local` | Ordered provider chain. Empty resolves to deterministic `local`, which makes no external request. |
| `TELESRV_AI_TIMEOUT` | duration / `15s` | Total timeout for one provider call. |

View file

@ -126,7 +126,7 @@
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business 自动回复生成器`echo``template`/`quick_reply`,或 `ai`/`compose_ai`/已配置 provider 名。 |
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business 自动回复生成器。可填 `echo`/空值(回显触发文本)、`template`/`quick_reply`/`quick-reply`(使用 quick reply 模板),或 `ai`/`compose_ai`/`ai_compose`/`aicompose`/`kimi`(复用 `TELESRV_AI_PROVIDERS` provider 链)。这里不接受任意 provider 名;例如使用 Ollama 时填 `TELESRV_BUSINESS_AI_PROVIDER=ai`,实际 provider 由 `TELESRV_AI_PROVIDERS=ollama,local` 决定。 |
| `TELESRV_AI_ENABLED` | bool / `true` | 启用客户端输入框改写/润色;关闭时返回空 tone 集合并隐藏入口。 |
| `TELESRV_AI_PROVIDERS` | list / `local` | 按顺序尝试的 provider 链;空列表回退确定性 `local`,不访问外网。 |
| `TELESRV_AI_TIMEOUT` | duration / `15s` | 单次 provider 调用总超时。 |

View file

@ -2,10 +2,12 @@ package admin
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"sort"
"strings"
"time"
@ -22,6 +24,10 @@ const (
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
ActionImportStarGift = "gifts.import"
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
maxCommandIDLength = 128
maxActorLength = 128
@ -86,6 +92,17 @@ type MessagesService interface {
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
}
type GiftsService interface {
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error)
CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -98,6 +115,7 @@ type Dependencies struct {
Channels ChannelsService
ChannelNotifier ChannelNotifier
Messages MessagesService
Gifts GiftsService
Now func() time.Time
}
@ -113,6 +131,7 @@ type Service struct {
channels ChannelsService
channelNotifier ChannelNotifier
messages MessagesService
gifts GiftsService
now func() time.Time
}
@ -155,6 +174,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Messages != nil {
s.messages = deps.Messages
}
if deps.Gifts != nil {
s.gifts = deps.Gifts
}
if deps.Now != nil {
s.now = deps.Now
}
@ -184,6 +206,63 @@ type CommandResult struct {
Error string `json:"error,omitempty"`
}
type ImportStarGiftRequest struct {
CommandMeta
GiftID int64 `json:"gift_id,omitempty"`
Title string `json:"title"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
FileName string `json:"file_name"`
ContentSHA string `json:"content_sha256"`
Data []byte `json:"-"`
}
type SetStarGiftEnabledRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
Enabled bool `json:"enabled"`
}
type SetStarGiftSortOrderRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
SortOrder int `json:"sort_order"`
}
type StarGiftCollectibleAnimationUpload struct {
Name string `json:"name"`
RarityPermille int `json:"rarity_permille"`
SortOrder int `json:"sort_order"`
FileKey string `json:"file_key"`
FileName string `json:"file_name,omitempty"`
ContentSHA string `json:"content_sha256,omitempty"`
Data []byte `json:"-"`
}
type StarGiftCollectibleBackdropInput struct {
Name string `json:"name"`
BackdropID int `json:"backdrop_id"`
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
PatternColor int `json:"pattern_color"`
TextColor int `json:"text_color"`
RarityPermille int `json:"rarity_permille"`
SortOrder int `json:"sort_order"`
}
type PublishStarGiftCollectiblesRequest struct {
CommandMeta
GiftID int64 `json:"gift_id"`
UpgradeStars int64 `json:"upgrade_stars"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []StarGiftCollectibleAnimationUpload `json:"models"`
Patterns []StarGiftCollectibleAnimationUpload `json:"patterns"`
Backdrops []StarGiftCollectibleBackdropInput `json:"backdrops"`
}
type SetAccountFrozenRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
@ -701,6 +780,182 @@ func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHis
})
}
func (s *Service) ImportStarGift(ctx context.Context, req ImportStarGiftRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured")
}
if req.GiftID < 0 || req.Stars <= 0 || req.ConvertStars < 0 || req.ConvertStars > req.Stars ||
req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 ||
len([]rune(strings.TrimSpace(req.Title))) > domain.MaxStarGiftTitleRunes {
return CommandResult{}, domain.ErrStarGiftInvalid
}
animation, err := s.gifts.PrepareAnimation(req.FileName, req.Data)
if err != nil {
return CommandResult{}, err
}
req.ContentSHA = hex.EncodeToString(animation.SHA256)
return s.runCommand(ctx, req.CommandMeta, ActionImportStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"gift_id": req.GiftID, "title": strings.TrimSpace(req.Title), "stars": req.Stars,
"convert_stars": req.ConvertStars, "enabled": req.Enabled, "sort_order": req.SortOrder,
"source_format": animation.SourceFormat, "source_name": animation.SourceName,
"sha256": req.ContentSHA, "width": animation.Width, "height": animation.Height,
"frame_rate": animation.FrameRate, "compressed_bytes": len(animation.TGS), "json_bytes": len(animation.JSON),
}
if req.DryRun {
return CommandResult{Message: "star gift import validated", Details: details}, nil
}
entry, err := s.gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: req.GiftID, Title: req.Title, Stars: req.Stars, ConvertStars: req.ConvertStars,
Enabled: req.Enabled, SortOrder: req.SortOrder, Animation: animation,
Actor: req.Actor, CommandID: req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, err
}
details["gift_id"] = entry.Gift.ID
details["revision_id"] = entry.Gift.RevisionID
details["revision"] = entry.Revision
return CommandResult{Message: "star gift imported", Details: details}, nil
})
}
func (s *Service) PublishStarGiftCollectibles(ctx context.Context, req PublishStarGiftCollectiblesRequest) (CommandResult, error) {
if s == nil || s.gifts == nil {
return CommandResult{}, fmt.Errorf("star gift service is not configured")
}
toAttributes := func(kind domain.StarGiftCollectibleAttributeKind, uploads []StarGiftCollectibleAnimationUpload) ([]domain.StarGiftCollectibleAttribute, error) {
attributes := make([]domain.StarGiftCollectibleAttribute, len(uploads))
for i := range uploads {
animation, err := s.gifts.PrepareAnimation(uploads[i].FileName, uploads[i].Data)
if err != nil {
return nil, fmt.Errorf("prepare %s %q: %w", kind, uploads[i].Name, err)
}
uploads[i].ContentSHA = hex.EncodeToString(animation.SHA256)
attributes[i] = domain.StarGiftCollectibleAttribute{
Kind: kind, Name: strings.TrimSpace(uploads[i].Name), RarityPermille: uploads[i].RarityPermille,
SortOrder: uploads[i].SortOrder, Animation: &animation,
}
}
return attributes, nil
}
models, err := toAttributes(domain.StarGiftCollectibleModel, req.Models)
if err != nil {
return CommandResult{}, err
}
patterns, err := toAttributes(domain.StarGiftCollectiblePattern, req.Patterns)
if err != nil {
return CommandResult{}, err
}
backdrops := make([]domain.StarGiftCollectibleAttribute, len(req.Backdrops))
for i, backdrop := range req.Backdrops {
backdrops[i] = domain.StarGiftCollectibleAttribute{
Kind: domain.StarGiftCollectibleBackdrop, Name: strings.TrimSpace(backdrop.Name), BackdropID: backdrop.BackdropID,
CenterColor: backdrop.CenterColor, EdgeColor: backdrop.EdgeColor, PatternColor: backdrop.PatternColor,
TextColor: backdrop.TextColor, RarityPermille: backdrop.RarityPermille, SortOrder: backdrop.SortOrder,
}
}
write := domain.StarGiftCollectibleWrite{
GiftID: req.GiftID, UpgradeStars: req.UpgradeStars, SupplyTotal: req.SupplyTotal,
SlugPrefix: strings.ToLower(strings.TrimSpace(req.SlugPrefix)), Models: models, Patterns: patterns, Backdrops: backdrops,
Actor: req.Actor, CommandID: req.CommandID,
}
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return CommandResult{}, err
}
// Persist normalized content hashes in the command payload so retries with changed files are
// rejected by the shared idempotency boundary even though raw file bytes are not audit-logged.
for i := range req.Models {
req.Models[i].ContentSHA = hex.EncodeToString(models[i].Animation.SHA256)
}
for i := range req.Patterns {
req.Patterns[i].ContentSHA = hex.EncodeToString(patterns[i].Animation.SHA256)
}
return s.runCommand(ctx, req.CommandMeta, ActionPublishGiftCollectibles, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"gift_id": req.GiftID, "upgrade_stars": req.UpgradeStars, "supply_total": req.SupplyTotal,
"slug_prefix": write.SlugPrefix, "models": collectibleUploadDetails(req.Models),
"patterns": collectibleUploadDetails(req.Patterns), "backdrops": len(req.Backdrops),
}
if req.DryRun {
return CommandResult{Message: "star gift collectible pool validated", Details: details}, nil
}
revision, err := s.gifts.CreateCollectibleRevision(ctx, write)
if err != nil {
return CommandResult{Details: details}, err
}
details["revision_id"] = revision.ID
details["revision"] = revision.Revision
details["published"] = revision.Published
return CommandResult{Message: "star gift collectible pool published", Details: details}, nil
})
}
func collectibleUploadDetails(uploads []StarGiftCollectibleAnimationUpload) []map[string]any {
details := make([]map[string]any, 0, len(uploads))
for _, upload := range uploads {
details = append(details, map[string]any{
"name": strings.TrimSpace(upload.Name), "rarity_permille": upload.RarityPermille,
"sort_order": upload.SortOrder, "source_name": upload.FileName, "sha256": upload.ContentSHA,
})
}
return details
}
func (s *Service) SetStarGiftEnabled(ctx context.Context, req SetStarGiftEnabledRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || req.GiftID <= 0 {
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"gift_id": req.GiftID, "enabled": req.Enabled}
if req.DryRun {
return CommandResult{Message: "star gift state change validated", Details: details}, nil
}
changed, err := s.gifts.SetCatalogEnabled(ctx, req.GiftID, req.Enabled)
details["changed"] = changed
return CommandResult{Message: "star gift state updated", Details: details}, err
})
}
func (s *Service) SetStarGiftSortOrder(ctx context.Context, req SetStarGiftSortOrderRequest) (CommandResult, error) {
if s == nil || s.gifts == nil || req.GiftID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid star gift and service are required")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStarGiftSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"gift_id": req.GiftID, "sort_order": req.SortOrder}
if req.DryRun {
return CommandResult{Message: "star gift order change validated", Details: details}, nil
}
changed, err := s.gifts.SetCatalogSortOrder(ctx, req.GiftID, req.SortOrder)
details["changed"] = changed
return CommandResult{Message: "star gift order updated", Details: details}, err
})
}
func (s *Service) StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return nil, false, nil
}
return s.gifts.AnimationJSON(ctx, giftID)
}
func (s *Service) StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
return s.gifts.CollectiblePreview(ctx, giftID)
}
func (s *Service) StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 || attributeID <= 0 {
return nil, false, nil
}
if kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern {
return nil, false, domain.ErrStarGiftCollectibleInvalid
}
return s.gifts.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) {
if s == nil || s.commands == nil {
return CommandResult{}, fmt.Errorf("admin command store is not configured")
@ -737,6 +992,9 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
return CommandResult{}, err
}
if !created {
if cmd.Action != action || cmd.DryRun != meta.DryRun || !sameJSON(cmd.RequestJSON, requestJSON) {
return CommandResult{CommandID: meta.CommandID, Action: action, Status: string(domain.AdminCommandFailed), Error: "COMMAND_ID_CONFLICT", Message: "command_id is already bound to a different request"}, fmt.Errorf("COMMAND_ID_CONFLICT")
}
return resultFromCommand(cmd), nil
}
result, opErr := fn()
@ -770,6 +1028,14 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
return result, opErr
}
func sameJSON(a, b []byte) bool {
var left, right any
if json.Unmarshal(a, &left) != nil || json.Unmarshal(b, &right) != nil {
return string(a) == string(b)
}
return reflect.DeepEqual(left, right)
}
func resultFromCommand(cmd domain.AdminCommand) CommandResult {
var result CommandResult
if len(cmd.ResultJSON) > 0 {

View file

@ -2,6 +2,7 @@ package admin
import (
"context"
"crypto/sha256"
"errors"
"reflect"
"strings"
@ -695,6 +696,95 @@ type fakeChannelNotifier struct {
channels []int64
}
func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
base := ImportStarGiftRequest{
Title: "Cake", Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 3,
FileName: "cake.lottie", Data: []byte(`{"v":"5.7"}`),
}
base.CommandMeta = CommandMeta{CommandID: "dry-gift", Actor: "ops", Reason: "catalog", DryRun: true}
preview, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["source_format"] != domain.StarGiftAnimationLottie {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
result, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(22) {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
func TestCommandIDConflictRejectsDifferentGiftBytes(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
req := ImportStarGiftRequest{
CommandMeta: CommandMeta{CommandID: "same", Actor: "ops", Reason: "catalog", DryRun: true},
Title: "Gift", Stars: 10, ConvertStars: 5, Enabled: true, FileName: "a.lottie", Data: []byte("one"),
}
if _, err := svc.ImportStarGift(context.Background(), req); err != nil {
t.Fatal(err)
}
req.Data = []byte("two")
if _, err := svc.ImportStarGift(context.Background(), req); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
t.Fatalf("conflict err=%v", err)
}
}
func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
base := PublishStarGiftCollectiblesRequest{
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
Models: []StarGiftCollectibleAnimationUpload{{Name: "Ruby", RarityPermille: 1000, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")}},
Patterns: []StarGiftCollectibleAnimationUpload{{Name: "Stars", RarityPermille: 1000, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")}},
Backdrops: []StarGiftCollectibleBackdropInput{{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 1000}},
}
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["models"] == nil {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
result, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != int64(33) || result.Details["published"] != true {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
type fakeGiftsService struct{ createCalls int }
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data)
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationLottie,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil
}
func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
f.createCalls++
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil
}
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}
func (*fakeGiftsService) SetCatalogSortOrder(context.Context, int64, int) (bool, error) {
return true, nil
}
func (*fakeGiftsService) AnimationJSON(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeGiftsService) CreateCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
f.createCalls++
return domain.StarGiftCollectibleRevision{ID: 33, GiftID: write.GiftID, Revision: 2, Published: true}, nil
}
func (*fakeGiftsService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}
func (*fakeGiftsService) CollectibleAnimationJSON(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
f.channels = append(f.channels, ch.ID)
return nil

View file

@ -5,13 +5,16 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
type Config struct {
@ -28,6 +31,13 @@ type Service interface {
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
ImportStarGift(ctx context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error)
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -84,6 +94,13 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
return mux
}
@ -170,6 +187,222 @@ func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleImportStarGift(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.ImportStarGiftRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "animation file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
writeError(w, http.StatusBadRequest, "animation file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.ImportStarGift(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handlePublishStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.PublishStarGiftCollectiblesRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
req.GiftID = giftID
seen := make(map[string]struct{}, len(req.Models)+len(req.Patterns))
if len(req.Models)+len(req.Patterns) > 128 {
writeError(w, http.StatusBadRequest, "too many collectible animation files")
return
}
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
upload.FileKey = strings.TrimSpace(upload.FileKey)
if upload.FileKey == "" {
return fmt.Errorf("animation file key is required")
}
if _, ok := seen[upload.FileKey]; ok {
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
}
seen[upload.FileKey] = struct{}{}
file, header, err := r.FormFile(upload.FileKey)
if err != nil {
return fmt.Errorf("animation file %q is required", upload.FileKey)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
}
upload.FileName = header.Filename
upload.Data = data
return nil
}
for i := range req.Models {
if err := load(&req.Models[i]); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
for i := range req.Patterns {
if err := load(&req.Patterns[i]); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
result, err := s.svc.PublishStarGiftCollectibles(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStarGiftEnabled(w http.ResponseWriter, r *http.Request) {
var req admin.SetStarGiftEnabledRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStarGiftEnabled(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Request) {
var req admin.SetStarGiftSortOrderRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetStarGiftSortOrder(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
raw, found, err := s.svc.StarGiftAnimation(r.Context(), giftID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "gift animation not found")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeError(w, http.StatusBadRequest, "invalid gift id")
return
}
preview, found, err := s.svc.StarGiftCollectibles(r.Context(), giftID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeJSON(w, http.StatusOK, map[string]any{"found": false, "gift_id": giftID})
return
}
writeJSON(w, http.StatusOK, collectiblePreviewResponse(preview))
}
func collectiblePreviewResponse(preview domain.StarGiftUpgradePreview) map[string]any {
attribute := func(value domain.StarGiftCollectibleAttribute) map[string]any {
result := map[string]any{
"id": value.ID, "name": value.Name, "rarity_permille": value.RarityPermille,
"sort_order": value.SortOrder, "kind": value.Kind,
}
if value.Animation != nil {
result["source_name"] = value.Animation.SourceName
result["source_format"] = value.Animation.SourceFormat
}
if value.Kind == domain.StarGiftCollectibleBackdrop {
result["backdrop_id"] = value.BackdropID
result["center_color"] = value.CenterColor
result["edge_color"] = value.EdgeColor
result["pattern_color"] = value.PatternColor
result["text_color"] = value.TextColor
}
return result
}
mapAttributes := func(values []domain.StarGiftCollectibleAttribute) []map[string]any {
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, attribute(value))
}
return result
}
return map[string]any{
"found": true, "gift_id": preview.GiftID, "revision": preview.Revision, "upgrade_stars": preview.UpgradeStars,
"supply_total": preview.SupplyTotal, "issued": preview.Issued,
"slug_prefix": preview.SlugPrefix,
"models": mapAttributes(preview.Models), "patterns": mapAttributes(preview.Patterns),
"backdrops": mapAttributes(preview.Backdrops),
}
}
func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
kind := domain.StarGiftCollectibleAttributeKind(r.PathValue("kind"))
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 ||
(kind != domain.StarGiftCollectibleModel && kind != domain.StarGiftCollectiblePattern) {
writeError(w, http.StatusBadRequest, "invalid collectible animation")
return
}
raw, found, err := s.svc.StarGiftCollectibleAnimation(r.Context(), giftID, kind, attributeID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "collectible animation not found")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -1,13 +1,16 @@
package adminapi
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
func TestAdminAPIRequiresBearerToken(t *testing.T) {
@ -80,6 +83,74 @@ func TestAdminAPISetChannelVerified(t *testing.T) {
}
}
func TestAdminAPIImportStarGiftMultipart(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("metadata", `{"command_id":"gift-1","actor":"ops","reason":"catalog","dry_run":true,"title":"Gift","stars":50,"convert_stars":25,"enabled":true,"sort_order":3}`); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "gift.lottie")
if err != nil {
t.Fatal(err)
}
animation := []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`)
if _, err := part.Write(animation); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
svc := &captureGiftService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/import", &body)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.req.CommandID != "gift-1" || svc.req.FileName != "gift.lottie" || !bytes.Equal(svc.req.Data, animation) || svc.req.Stars != 50 || svc.req.ConvertStars != 25 {
t.Fatalf("decoded gift request = %+v", svc.req)
}
}
func TestAdminAPIPublishStarGiftCollectiblesMultipart(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
metadata := `{"command_id":"pool-1","actor":"ops","reason":"pool","dry_run":true,"upgrade_stars":125,"supply_total":100,"slug_prefix":"cake","models":[{"name":"Ruby","rarity_permille":1000,"sort_order":0,"file_key":"model-0"}],"patterns":[{"name":"Stars","rarity_permille":1000,"sort_order":0,"file_key":"pattern-0"}],"backdrops":[{"name":"Night","backdrop_id":1,"center_color":1122867,"edge_color":2241348,"pattern_color":3359829,"text_color":16777215,"rarity_permille":1000,"sort_order":0}]}`
if err := writer.WriteField("metadata", metadata); err != nil {
t.Fatal(err)
}
for key, name := range map[string]string{"model-0": "ruby.lottie", "pattern-0": "stars.tgs"} {
part, err := writer.CreateFormFile(key, name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(key)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
svc := &captureCollectibleService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/gifts/11/collectibles/publish", &body)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.req.GiftID != 11 || len(svc.req.Models) != 1 || svc.req.Models[0].FileName != "ruby.lottie" ||
string(svc.req.Patterns[0].Data) != "pattern-0" || len(svc.req.Backdrops) != 1 {
t.Fatalf("decoded collectible request = %+v", svc.req)
}
}
type fakeService struct{}
type captureFreezeService struct {
@ -87,11 +158,31 @@ type captureFreezeService struct {
req admin.SetAccountFrozenRequest
}
type captureGiftService struct {
fakeService
req admin.ImportStarGiftRequest
}
type captureCollectibleService struct {
fakeService
req admin.PublishStarGiftCollectiblesRequest
}
func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureGiftService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
@ -123,3 +214,31 @@ func (fakeService) DeletePrivateMessages(context.Context, admin.DeletePrivateMes
func (fakeService) DeletePrivateHistory(context.Context, admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}
func (fakeService) ImportStarGift(_ context.Context, req admin.ImportStarGiftRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) PublishStarGiftCollectibles(_ context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetStarGiftEnabled(_ context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetStarGiftSortOrder(_ context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}

View file

@ -1,97 +0,0 @@
package files
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与
// blob不复制字节镜像 EnsureDefaultEmojiStatusSet。目录是静态的不入库。
// 礼物 ID 取明显隔离的常量段避免撞键。
const starGiftIDBase int64 = 8_888_000_000_000_000
type starGiftSeed struct {
id int64
emoticon string
stars int64
title string
}
// starGiftSeeds 是固定礼物目录emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。
// convert_stars = starsv1 全额转换,视作用新购 Stars 买入)。
var starGiftSeeds = []starGiftSeed{
{starGiftIDBase + 1, "❤", 15, "Heart"},
{starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂
{starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉
{starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥
{starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆
{starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎
{starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀
}
// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。
// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。
func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) {
source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji")
if err != nil {
return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err)
}
if !found || len(source.Packs) == 0 {
return nil, nil
}
byEmoticon := make(map[string]int64, len(source.Packs))
for _, pack := range source.Packs {
key := normalizeStatusEmoticon(pack.Emoticon)
if key == "" || len(pack.DocumentIDs) == 0 {
continue
}
if _, ok := byEmoticon[key]; !ok {
byEmoticon[key] = pack.DocumentIDs[0]
}
}
// 收集要加载的文档 id去重
docIDs := make([]int64, 0, len(starGiftSeeds))
chosen := make([]starGiftSeed, 0, len(starGiftSeeds))
seen := make(map[int64]struct{})
for _, seed := range starGiftSeeds {
id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
if !ok || id == 0 {
continue
}
chosen = append(chosen, seed)
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
docIDs = append(docIDs, id)
}
}
if len(chosen) == 0 {
return nil, nil
}
docs, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
return nil, fmt.Errorf("load star gift sticker documents: %w", err)
}
docByID := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
docByID[d.ID] = d
}
catalog := make([]domain.StarGift, 0, len(chosen))
for _, seed := range chosen {
id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)]
doc, ok := docByID[id]
if !ok || doc.ID == 0 {
continue
}
catalog = append(catalog, domain.StarGift{
ID: seed.id,
Stars: seed.stars,
ConvertStars: seed.stars,
Title: seed.title,
Sticker: doc,
})
}
return catalog, nil
}

View file

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

View file

@ -0,0 +1,93 @@
package stargifts
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const validGiftLottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4,"nm":"gift"}],"assets":[]}`
func TestPrepareAnimationNormalizesLottieAndTGS(t *testing.T) {
fromJSON, err := prepareAnimation("gift.lottie", []byte(" \n"+validGiftLottie+"\n"))
if err != nil {
t.Fatalf("prepare lottie: %v", err)
}
if fromJSON.SourceFormat != domain.StarGiftAnimationLottie || len(fromJSON.TGS) == 0 || fromJSON.Width != 512 || fromJSON.Height != 512 {
t.Fatalf("prepared lottie = %+v", fromJSON)
}
fromTGS, err := prepareAnimation("gift.tgs", fromJSON.TGS)
if err != nil {
t.Fatalf("prepare tgs: %v", err)
}
if fromTGS.SourceFormat != domain.StarGiftAnimationTGS || string(fromTGS.JSON) != string(fromJSON.JSON) || hex.EncodeToString(fromTGS.SHA256) != hex.EncodeToString(fromJSON.SHA256) {
t.Fatalf("tgs round trip differs: json=%v hash=%x/%x", string(fromTGS.JSON) == string(fromJSON.JSON), fromTGS.SHA256, fromJSON.SHA256)
}
}
func TestPrepareAnimationRejectsExternalAssetAndExpression(t *testing.T) {
for name, raw := range map[string]string{
"external": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{}],"assets":[{"p":"https://example.test/x.png"}]}`,
"expression": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":512,"h":512,"layers":[{"ks":{"o":{"x":"time*10"}}}]}`,
"wrong-size": `{"v":"5.7","fr":30,"ip":0,"op":30,"w":256,"h":256,"layers":[{}]}`,
"frame-rate": `{"v":"5.7","fr":121,"ip":0,"op":30,"w":512,"h":512,"layers":[{}]}`,
"duration": `{"v":"5.7","fr":30,"ip":0,"op":901,"w":512,"h":512,"layers":[{}]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := prepareAnimation("gift.json", []byte(raw)); !errors.Is(err, domain.ErrStarGiftFileInvalid) {
t.Fatalf("err=%v, want ErrStarGiftFileInvalid", err)
}
})
}
}
type testGiftBlob struct{ data map[string][]byte }
func (b *testGiftBlob) Name() string { return "localfs" }
func (b *testGiftBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *testGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
ctx := context.Background()
store := memory.NewStarGiftStore()
svc := NewService(store, &testGiftBlob{data: map[string][]byte{}}, 2)
animation, err := svc.PrepareAnimation("gift.json", []byte(validGiftLottie))
if err != nil {
t.Fatal(err)
}
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation,
})
if err != nil {
t.Fatalf("create first: %v", err)
}
second, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: first.Gift.ID, Stars: 80, ConvertStars: 40, Enabled: true, SortOrder: 1, Title: "Second", Animation: animation,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
current, found, _ := svc.GiftByID(ctx, first.Gift.ID)
if !found || current.RevisionID != second.Gift.RevisionID || current.Stars != 80 {
t.Fatalf("current=%+v found=%v", current, found)
}
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "First" {
t.Fatalf("historical=%+v found=%v", historical, found)
}
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("disable missing err=%v, want ErrStarGiftNotFound", err)
}
}

View file

@ -1,124 +1,413 @@
// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的
// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText
// 本层只管目录与持久化。
// Package stargifts implements the durable Star Gift catalog and received-gift state.
package stargifts
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// CatalogProvider 合成礼物目录app/files 实现)。
type CatalogProvider interface {
BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error)
// BlobBackend is the content-addressed media boundary used by the catalog importer.
type BlobBackend interface {
Name() string
Put(ctx context.Context, data []byte) (string, error)
Get(ctx context.Context, objectKey string) ([]byte, error)
}
// Service 是 Star 礼物应用服务。
type Service struct {
store store.StarGiftStore
catalog CatalogProvider
upgrades store.StarGiftUpgradeStore
blobs BlobBackend
dc int
mu sync.Mutex
mu sync.RWMutex
built bool
gifts []domain.StarGift
byID map[int64]domain.StarGift
hash int
}
// NewService 创建 Star 礼物服务。
func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service {
return &Service{store: st, catalog: catalog}
type Option func(*Service)
func WithUpgradeStore(upgrades store.StarGiftUpgradeStore) Option {
return func(service *Service) { service.upgrades = upgrades }
}
func NewService(st store.StarGiftStore, blobs BlobBackend, dc int, opts ...Option) *Service {
service := &Service{store: st, blobs: blobs, dc: dc}
for _, opt := range opts {
opt(service)
}
return service
}
// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。
func (s *Service) ensureCatalog(ctx context.Context) error {
s.mu.RLock()
built := s.built
s.mu.RUnlock()
if built {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.built {
return nil
}
gifts, err := s.catalog.BuildStarGiftCatalog(ctx)
if s.store == nil {
return fmt.Errorf("star gift store is not configured")
}
gifts, err := s.store.Catalog(ctx)
if err != nil {
return err
}
s.gifts = gifts
s.byID = make(map[int64]domain.StarGift, len(gifts))
for _, g := range gifts {
s.byID[g.ID] = g
for _, gift := range gifts {
s.byID[gift.ID] = gift
}
s.hash = domain.StarGiftCatalogHash(gifts)
s.built = true
return nil
}
// Catalog 返回礼物目录。
func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) {
if err := s.ensureCatalog(ctx); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.StarGift, len(s.gifts))
copy(out, s.gifts)
return out, nil
s.mu.RLock()
defer s.mu.RUnlock()
return append([]domain.StarGift(nil), s.gifts...), nil
}
// CatalogHash 返回目录 hashgetStarGifts NotModified 判定)。
func (s *Service) CatalogHash(ctx context.Context) (int, error) {
if err := s.ensureCatalog(ctx); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
return s.hash, nil
}
// GiftByID 返回目录中指定礼物,不存在返回 ok=false。
func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) {
if err := s.ensureCatalog(ctx); err != nil {
return domain.StarGift{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.byID[id]
return g, ok, nil
s.mu.RLock()
defer s.mu.RUnlock()
gift, ok := s.byID[id]
return gift, ok, nil
}
func (s *Service) GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if s == nil || s.store == nil {
return domain.StarGift{}, false, nil
}
return s.store.CatalogRevision(ctx, revisionID)
}
// InvalidateStarGiftCatalog implements the shared PostgreSQL read-model listener boundary.
func (s *Service) InvalidateStarGiftCatalog() {
if s == nil {
return
}
s.mu.Lock()
s.built = false
s.gifts = nil
s.byID = nil
s.hash = 0
s.mu.Unlock()
}
func (s *Service) FlushStarGiftCatalog() { s.InvalidateStarGiftCatalog() }
func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
}
write.Title = strings.TrimSpace(write.Title)
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
objectKey, err := s.blobs.Put(ctx, write.Animation.TGS)
if err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("store star gift animation: %w", err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("generate star gift file reference: %w", err)
}
write.Document = domain.Document{
ID: documentID,
AccessHash: accessHash,
FileReference: fileReference,
Date: int(time.Now().Unix()),
MimeType: "application/x-tgsticker",
Size: int64(len(write.Animation.TGS)),
DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: "gift.tgs"},
},
}
write.Blob = domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(write.Animation.TGS)),
SHA256: append([]byte(nil), write.Animation.SHA256...),
MimeType: "application/x-tgsticker",
}
entry, err := s.store.CreateCatalogRevision(ctx, write)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
s.InvalidateStarGiftCatalog()
return entry, nil
}
func (s *Service) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
changed, err := s.store.SetCatalogEnabled(ctx, giftID, enabled)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
changed, err := s.store.SetCatalogSortOrder(ctx, giftID, sortOrder)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return changed, err
}
func (s *Service) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
return s.store.AnimationJSON(ctx, giftID)
}
func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
}
revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return revision, err
}
// CreateCollectibleRevision materializes the normalized model/pattern animations and then
// atomically publishes the complete immutable attribute pool. Callers must pass animations
// produced by PrepareAnimation; partial revisions are never exposed to clients.
func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
}
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
materialize := func(attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes {
animation := attributes[i].Animation
if animation == nil {
return domain.ErrStarGiftCollectibleInvalid
}
objectKey, err := s.blobs.Put(ctx, animation.TGS)
if err != nil {
return fmt.Errorf("store collectible %s animation: %w", attributes[i].Kind, err)
}
documentID, err := randomPositiveInt64()
if err != nil {
return err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return err
}
fileReference := make([]byte, 16)
if _, err := rand.Read(fileReference); err != nil {
return fmt.Errorf("generate collectible file reference: %w", err)
}
attributes[i].Document = &domain.Document{
ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
}
attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey, Size: int64(len(animation.TGS)),
SHA256: append([]byte(nil), animation.SHA256...), MimeType: "application/x-tgsticker",
}
}
return nil
}
if err := materialize(write.Models); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if err := materialize(write.Patterns); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return s.PublishCollectibleRevision(ctx, write)
}
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil
}
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
if err != nil || !ok || !revision.Published {
return domain.StarGiftUpgradePreview{}, false, err
}
return domain.StarGiftUpgradePreview{
GiftID: giftID, Revision: revision.Revision, UpgradeStars: revision.UpgradeStars, SupplyTotal: revision.SupplyTotal,
Issued: revision.Issued, Models: revision.Models, Patterns: revision.Patterns, Backdrops: revision.Backdrops,
SlugPrefix: revision.SlugPrefix,
}, true, nil
}
func (s *Service) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
if s == nil || s.store == nil || len(giftIDs) == 0 {
return map[int64]domain.StarGiftCollectibleAvailability{}, nil
}
return s.store.CollectibleAvailability(ctx, giftIDs)
}
func (s *Service) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
if s == nil || s.store == nil {
return nil, false, nil
}
return s.store.CollectibleAnimationJSON(ctx, giftID, kind, attributeID)
}
func (s *Service) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueBySlug(ctx, slug)
}
func (s *Service) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
if s == nil || s.store == nil {
return domain.UniqueStarGift{}, false, nil
}
return s.store.UniqueByID(ctx, uniqueGiftID)
}
func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
if s == nil || s.store == nil || len(uniqueGiftIDs) == 0 {
return map[int64]domain.UniqueStarGift{}, nil
}
return s.store.UniqueByIDs(ctx, uniqueGiftIDs)
}
func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.UpgradeStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
return s.store.ListCollections(ctx, owner)
}
func (s *Service) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
return s.store.CreateCollection(ctx, owner, title, savedGiftIDs)
}
func (s *Service) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
return s.store.UpdateCollection(ctx, owner, collectionID, patch)
}
func (s *Service) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
return s.store.DeleteCollection(ctx, owner, collectionID)
}
func (s *Service) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return s.store.ReorderCollections(ctx, owner, collectionIDs)
}
func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return s.store.SetPinned(ctx, owner, savedGiftIDs)
}
// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
return s.store.Create(ctx, gift)
}
// ListSaved 分页返回某 owner 收到的礼物。
func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
if len(offset) > domain.MaxStarGiftsOffsetBytes {
offset = ""
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit)
return s.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *Service) ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
offset := filter.Offset
if len(offset) > domain.MaxStarGiftsOffsetBytes {
filter.Offset = ""
}
if filter.Limit <= 0 || filter.Limit > domain.MaxSavedStarGiftsLimit {
filter.Limit = domain.MaxSavedStarGiftsLimit
}
return s.store.ListByOwnerFiltered(ctx, filter)
}
// GetSaved 按协议引用取礼物实例。
func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
return s.store.GetByRef(ctx, ref)
}
// CountSaved 返回某 owner 展示在资料的礼物数full.stargifts_count
func (s *Service) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
return s.store.ResolveSavedIDs(ctx, owner, refs)
}
func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) {
return s.store.CountByOwner(ctx, owner)
}
// ToggleSaved 切换礼物在资料的展示saveStarGift
func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
return s.store.SetUnsaved(ctx, ref, unsaved)
}
// Convert 把礼物标记为已转换convertStarGift返回该行供调用方据 ConvertStars 入账。
func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
return s.store.MarkConverted(ctx, ref)
}
func randomPositiveInt64() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, fmt.Errorf("generate star gift id: %w", err)
}
id := int64(binary.LittleEndian.Uint64(raw[:]) & 0x7fffffffffffffff)
if id == 0 {
id = 1
}
return id, nil
}

View file

@ -9,40 +9,28 @@ import (
"telesrv/internal/store/memory"
)
type fakeCatalog struct {
gifts []domain.StarGift
calls int
}
func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
f.calls++
return f.gifts, nil
}
func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) {
cat := &fakeCatalog{gifts: gifts}
return NewService(memory.NewStarGiftStore(), cat), cat
func newTestService(gifts []domain.StarGift) (*Service, *memory.StarGiftStore) {
st := memory.NewStarGiftStore()
st.SeedCatalog(gifts)
return NewService(st, nil, 2), st
}
func TestCatalogCachedAndHash(t *testing.T) {
gifts := []domain.StarGift{
{ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"},
{ID: 1, RevisionID: 11, Stars: 15, ConvertStars: 15, Title: "Heart"},
{ID: 2, RevisionID: 12, Stars: 50, ConvertStars: 50, Title: "Cake"},
}
svc, cat := newTestService(gifts)
svc, _ := newTestService(gifts)
ctx := context.Background()
got, err := svc.Catalog(ctx)
if err != nil || len(got) != 2 {
t.Fatalf("catalog = %d err %v, want 2", len(got), err)
}
// 再取一次不重新构建(缓存)
// 再取一次命中进程内目录缓存
if _, err := svc.Catalog(ctx); err != nil {
t.Fatalf("catalog#2: %v", err)
}
if cat.calls != 1 {
t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls)
}
hash, err := svc.CatalogHash(ctx)
if err != nil || hash != domain.StarGiftCatalogHash(gifts) {
t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts))
@ -61,11 +49,15 @@ func TestSavedGiftLifecycle(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 50, Date: 1700000000, ConvertStars: 15,
})
if err != nil || id == 0 {
t.Fatalf("RecordSavedGift = %d err %v", id, err)
}
collection, err := svc.CreateCollection(ctx, owner, "Inbox", []int64{id})
if err != nil || len(collection.GiftIDs) != 1 {
t.Fatalf("CreateCollection = %+v err %v", collection, err)
}
page, err := svc.ListSaved(ctx, owner, false, "", 100)
if err != nil || len(page.Gifts) != 1 || page.Count != 1 {
@ -99,6 +91,11 @@ func TestSavedGiftLifecycle(t *testing.T) {
if len(after.Gifts) != 0 {
t.Fatalf("list after convert = %d, want 0", len(after.Gifts))
}
collections, err := svc.ListCollections(ctx, owner)
if err != nil || len(collections) != 1 || len(collections[0].GiftIDs) != 0 ||
collections[0].Hash != domain.StarGiftCollectionHash("Inbox", nil) {
t.Fatalf("collection after convert = %+v err %v, want empty membership and refreshed hash", collections, err)
}
// 重复转换被拒。
if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) {
t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err)
@ -111,7 +108,7 @@ func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0,
Owner: owner, FromUserID: 1001, GiftID: 1, RevisionID: 11, MsgID: 0, SavedID: 0,
Date: 1700000000, ConvertStars: 15,
})
if err != nil || savedID == 0 {
@ -133,7 +130,7 @@ func TestSavedGiftPagination(t *testing.T) {
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
for i := 0; i < 5; i++ {
if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
Owner: owner, FromUserID: 2002, GiftID: 1, RevisionID: 11, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15,
}); err != nil {
t.Fatalf("record#%d: %v", i, err)
}

View file

@ -561,6 +561,10 @@ const (
// MessageServiceActionStarGift 映射 messageActionStarGift收到一份 Star 礼物。
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
// 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"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -617,6 +621,7 @@ type MessageServiceAction struct {
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
}
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
@ -635,6 +640,23 @@ type MessageStarGiftAction struct {
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"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
}
// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade
// service message. Commercial transfer/resale/export fields are intentionally
// absent from the collectibles mainline.
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"`
}
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。

View file

@ -259,6 +259,10 @@ type SendPrivateTextRequest struct {
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
// OriginUserID identifies the authenticated initiator when a server-generated
// service message is authored by another user. Zero preserves the ordinary
// send path where the sender is the initiator.
OriginUserID int64
RecipientBlocked bool
// IdempotencyFingerprint 是调用边界对原始、不可变发送请求计算的 SHA-256。
// RPC 层应优先填入原始 TL 请求指纹,避免链接预览、骰子结果、上传媒体

View file

@ -3,18 +3,25 @@ package domain
import (
"encoding/base64"
"errors"
"regexp"
"strconv"
"strings"
"time"
)
// Star giftpayments.sendStarsForm + inputInvoiceStarGift领域模型。目录是从已 seed 的
// animated_emoji 合成的静态集合StarGiftpeer 收到的礼物实例落 peer_star_giftsSavedStarGift
// Star giftpayments.sendStarsForm + inputInvoiceStarGift领域模型。目录和不可变版本
// 持久化在 star_gift_catalog(_revisions)peer 收到的礼物实例落 peer_star_gifts
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
// StarGift 是一个可购买礼物目录项(合成、非用户持有)
// StarGift 是一个可购买礼物目录项。RevisionID 标识不可变的标题/价格/动画快照
type StarGift struct {
ID int64
RevisionID int64
Stars int64 // 购买价Stars
ConvertStars int64 // 收礼人可转换回的 Starsv1 = Stars全额
ConvertStars int64 // 收礼人可转换回的 Stars
UpgradeStars int64 // 升级为唯一礼物所需 Stars0 表示当前不可升级
UpgradeTotal int // 当前已发布属性池允许发行的唯一礼物总量
UpgradeIssued int // 当前已发行数量
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
}
@ -25,6 +32,7 @@ type SavedStarGift struct {
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 秒
@ -32,7 +40,218 @@ type SavedStarGift struct {
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 的协议中立引用。
@ -62,6 +281,24 @@ type SavedStarGiftPage struct {
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 单页上限。
@ -70,6 +307,20 @@ const (
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
@ -80,19 +331,137 @@ var (
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")
)
// StarGiftCatalogHash 由目录的 (gift_id, stars) 折叠出稳定 hash供 getStarGifts NotModified。
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)))

View file

@ -24,6 +24,7 @@ const (
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)

View file

@ -220,6 +220,25 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGift(m.ServiceAction.StarGift)
case domain.MessageServiceActionStarGiftUnique:
action := m.ServiceAction.StarGiftUnique
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Gift: tgUniqueStarGift(action.Gift),
}
if action.FromUserID != 0 {
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
default:
return &tg.MessageActionEmpty{}
}

View file

@ -864,12 +864,27 @@ type GiftsService interface {
Catalog(ctx context.Context) ([]domain.StarGift, error)
CatalogHash(ctx context.Context) (int, error)
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error)
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
}
// StarsService 抽象 Stars 本地账本app/stars余额查询、贷记/借记、流水分页。

View file

@ -82,7 +82,7 @@ func TestAccountGetWallPapersReturnsDefaultCatalog(t *testing.T) {
t.Fatalf("boxed response type = %T, want *tg.AccountWallPapers", box.WallPapers)
}
if wallpapers.Hash == 0 || len(wallpapers.Wallpapers) == 0 {
t.Fatalf("wallpapers = %+v, want stable Default catalog", wallpapers)
t.Fatalf("wallpapers = %+v, want stable default catalog", wallpapers)
}
}
@ -189,7 +189,7 @@ func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) {
}
}
func TestPaymentsGetStarGiftCollectionsReturnsEmptyAndValidatesPeer(t *testing.T) {
func TestPaymentsGetStarGiftCollectionsNoServiceFallbackAndValidatesPeer(t *testing.T) {
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)

View file

@ -30,25 +30,21 @@ func (r *Router) registerPayments(d *tg.ServerDispatcher) {
return tdesktop.StarGiftActiveAuctions(), nil
})
d.OnPaymentsGetStarGifts(r.onPaymentsGetStarGifts)
d.OnPaymentsGetStarGiftUpgradePreview(r.onPaymentsGetStarGiftUpgradePreview)
d.OnPaymentsGetUniqueStarGift(r.onPaymentsGetUniqueStarGift)
d.OnPaymentsGetPaymentForm(r.onPaymentsGetPaymentForm)
d.OnPaymentsSendStarsForm(r.onPaymentsSendStarsForm)
d.OnPaymentsGetSavedStarGifts(r.onPaymentsGetSavedStarGifts)
d.OnPaymentsGetSavedStarGift(r.onPaymentsGetSavedStarGift)
d.OnPaymentsSaveStarGift(r.onPaymentsSaveStarGift)
d.OnPaymentsConvertStarGift(r.onPaymentsConvertStarGift)
d.OnPaymentsGetStarGiftCollections(func(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req == nil {
return nil, peerIDInvalidErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
return tdesktop.StarGiftCollections(), nil
})
d.OnPaymentsUpgradeStarGift(r.onPaymentsUpgradeStarGift)
d.OnPaymentsGetStarGiftCollections(r.onPaymentsGetStarGiftCollections)
d.OnPaymentsCreateStarGiftCollection(r.onPaymentsCreateStarGiftCollection)
d.OnPaymentsUpdateStarGiftCollection(r.onPaymentsUpdateStarGiftCollection)
d.OnPaymentsDeleteStarGiftCollection(r.onPaymentsDeleteStarGiftCollection)
d.OnPaymentsReorderStarGiftCollections(r.onPaymentsReorderStarGiftCollections)
d.OnPaymentsToggleStarGiftsPinnedToTop(r.onPaymentsToggleStarGiftsPinnedToTop)
d.OnPaymentsGetStarsRevenueAdsAccountURL(func(ctx context.Context, peer tg.InputPeerClass) (*tg.PaymentsStarsRevenueAdsAccountURL, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {

View file

@ -0,0 +1,235 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onPaymentsGetStarGiftCollections(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if r.deps.Gifts == nil {
return &tg.PaymentsStarGiftCollections{Collections: []tg.StarGiftCollection{}}, nil
}
collections, err := r.deps.Gifts.ListCollections(ctx, owner)
if err != nil {
return nil, starGiftCollectionErr(err)
}
if req.Hash != 0 && req.Hash == domain.StarGiftCollectionsHash(collections) {
return &tg.PaymentsStarGiftCollectionsNotModified{}, nil
}
return &tg.PaymentsStarGiftCollections{Collections: tgStarGiftCollections(collections)}, nil
}
func (r *Router) onPaymentsCreateStarGiftCollection(ctx context.Context, req *tg.PaymentsCreateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return nil, err
}
collection, err := r.deps.Gifts.CreateCollection(ctx, owner, strings.TrimSpace(req.Title), ids)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsUpdateStarGiftCollection(ctx context.Context, req *tg.PaymentsUpdateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
patch := domain.StarGiftCollectionPatch{}
if title, ok := req.GetTitle(); ok {
title = strings.TrimSpace(title)
patch.Title = &title
}
if refs, ok := req.GetDeleteStargift(); ok {
patch.DeleteIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetAddStargift(); ok {
patch.AddIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetOrder(); ok {
patch.Order, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
collection, err := r.deps.Gifts.UpdateCollection(ctx, owner, req.CollectionID, patch)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsDeleteStarGiftCollection(ctx context.Context, req *tg.PaymentsDeleteStarGiftCollectionRequest) (bool, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
deleted, err := r.deps.Gifts.DeleteCollection(ctx, owner, req.CollectionID)
if err != nil {
return false, starGiftCollectionErr(err)
}
if !deleted {
return false, starGiftCollectionErr(domain.ErrStarGiftCollectionNotFound)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) onPaymentsReorderStarGiftCollections(ctx context.Context, req *tg.PaymentsReorderStarGiftCollectionsRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
if err := r.deps.Gifts.ReorderCollections(ctx, owner, req.Order); err != nil {
return false, starGiftCollectionErr(err)
}
return true, nil
}
func (r *Router) onPaymentsToggleStarGiftsPinnedToTop(ctx context.Context, req *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return false, err
}
if err := r.deps.Gifts.SetPinned(ctx, owner, ids); err != nil {
return false, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) resolveStarGiftCollectionRefs(ctx context.Context, userID int64, owner domain.Peer, refs []tg.InputSavedStarGiftClass) ([]int64, error) {
if len(refs) > domain.MaxStarGiftCollectionItems {
return nil, inputRequestInvalidErr()
}
domainRefs := make([]domain.SavedStarGiftRef, 0, len(refs))
for _, input := range refs {
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
if !ok || ref.Owner != owner {
return nil, starGiftInvalidErr()
}
domainRefs = append(domainRefs, ref)
}
ids, err := r.deps.Gifts.ResolveSavedIDs(ctx, owner, domainRefs)
if err != nil {
return nil, starGiftCollectionErr(err)
}
return ids, nil
}
func tgStarGiftCollections(in []domain.StarGiftCollection) []tg.StarGiftCollection {
out := make([]tg.StarGiftCollection, 0, len(in))
for _, collection := range in {
out = append(out, tgStarGiftCollection(collection))
}
return out
}
func tgStarGiftCollection(in domain.StarGiftCollection) tg.StarGiftCollection {
return tg.StarGiftCollection{
CollectionID: in.CollectionID,
Title: in.Title,
GiftsCount: len(in.GiftIDs),
Hash: in.Hash,
}
}
func starGiftCollectionErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid),
errors.Is(err, domain.ErrStarGiftCollectionNotFound),
errors.Is(err, domain.ErrStarGiftCollectionsFull):
return inputRequestInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,278 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentFormClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails),
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Star gift upgrade", Amount: preview.UpgradeStars}},
},
}, nil
}
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: preview.UpgradeStars,
FormID: formID, CommandKey: fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
updates := r.tgStarGiftUpgradeUpdates(ctx, userID, result, true)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.PaymentsUpgradeStarGiftRequest) (tg.UpdatesClass, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
saved, _, err := r.starGiftUpgradeTarget(ctx, userID, req.Stargift)
if err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: domain.SavedStarGiftRef{Owner: saved.Owner, MsgID: saved.MsgID},
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
CommandKey: fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
return r.tgStarGiftUpgradeUpdates(ctx, userID, result, false), nil
}
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
}
if !ok || ref.Owner.Type != domain.PeerTypeUser || ref.Owner.ID != userID {
// Channel gift upgrades require a channel pts aggregate and are not silently
// routed through the private-message transaction.
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || saved.Converted || saved.UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
return saved, preview, nil
}
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
message, event := result.Send.RecipientMessage, result.Send.RecipientEvent
if result.Send.SenderMessage.OwnerUserID == ownerUserID {
message, event = result.Send.SenderMessage, result.Send.SenderEvent
}
updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(ctx, ownerUserID, message))
if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
}
return updates
}
func starGiftUpgradeFormID(userID, savedGiftID, stars int64, keepOriginal bool) int64 {
id := userID*0x9e3779b1 ^ savedGiftID<<11 ^ stars<<19 ^ 0x55504752414445
if keepOriginal {
id ^= 0x4b454550
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 1
}
return id
}
func starGiftUpgradeErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsInsufficient):
return starsErr(err)
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable),
errors.Is(err, domain.ErrStarGiftCollectibleSoldOut),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid):
return starGiftInvalidErr()
default:
return internalErr()
}
}
func sessionIDOrZero(ctx context.Context) int64 {
sessionID, _ := SessionIDFrom(ctx)
return sessionID
}
func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradePreview, error) {
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
if err != nil {
return nil, internalErr()
}
if !found || preview.Issued >= preview.SupplyTotal {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradePreview{
SampleAttributes: tgStarGiftPreviewAttributes(preview),
Prices: []tg.StarGiftUpgradePrice{},
NextPrices: []tg.StarGiftUpgradePrice{},
}, nil
}
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr()
}
viewerUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, starGiftInvalidErr()
}
out := &tg.PaymentsUniqueStarGift{
Gift: tgUniqueStarGift(unique),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
switch unique.Owner.Type {
case domain.PeerTypeUser:
ids := []int64{unique.Owner.ID}
if unique.KeepOriginalDetails && !unique.OriginalNameHidden && unique.OriginalFromUserID != 0 && unique.OriginalFromUserID != unique.Owner.ID {
ids = append(ids, unique.OriginalFromUserID)
}
out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids))
case domain.PeerTypeChannel:
out.Chats = r.tgChatsForChannelIDs(ctx, viewerUserID, []int64{unique.Owner.ID})
}
return out, nil
}
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attribute := range preview.Models {
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Patterns {
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Backdrops {
out = append(out, tgStarGiftAttribute(attribute))
}
return out
}
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
switch attribute.Kind {
case domain.StarGiftCollectibleModel:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity}
case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributePattern{Name: attribute.Name, Document: document, Rarity: rarity}
case domain.StarGiftCollectibleBackdrop:
return &tg.StarGiftAttributeBackdrop{
Name: attribute.Name, BackdropID: attribute.BackdropID,
CenterColor: attribute.CenterColor, EdgeColor: attribute.EdgeColor,
PatternColor: attribute.PatternColor, TextColor: attribute.TextColor, Rarity: rarity,
}
default:
return &tg.StarGiftAttributeBackdrop{Name: attribute.Name, Rarity: rarity}
}
}
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model),
tgStarGiftAttribute(unique.Pattern),
tgStarGiftAttribute(unique.Backdrop),
}
if unique.KeepOriginalDetails && unique.OriginalOwner.ID != 0 {
original := &tg.StarGiftAttributeOriginalDetails{
RecipientID: tgPeer(unique.OriginalOwner),
Date: unique.OriginalDate,
}
if unique.OriginalFromUserID != 0 && !unique.OriginalNameHidden {
original.SetSenderID(&tg.PeerUser{UserID: unique.OriginalFromUserID})
}
if unique.OriginalMessage != "" {
original.SetMessage(tg.TextWithEntities{Text: unique.OriginalMessage})
}
attributes = append(attributes, original)
}
out := &tg.StarGiftUnique{
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
}
if owner := tgPeer(unique.Owner); owner != nil {
out.SetOwnerID(owner)
}
return out
}

View file

@ -77,6 +77,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}
return r.starsTopupPaymentForm(userID, purpose), nil
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -85,18 +88,32 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if r.deps.Gifts == nil {
return nil, notImplementedErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
// Channel upgrades remain blocked until they can advance channel pts and
// publish a durable channel update. Never collect a prepaid upgrade that
// the recipient cannot consume.
return nil, starGiftInvalidErr()
}
gift, err := r.starGiftFromCatalog(ctx, inv.GiftID)
if err != nil {
return nil, err
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
return nil, starGiftInvalidErr()
}
upgradeStars = gift.UpgradeStars
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftFormID(userID, inv.GiftID),
FormID: starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade),
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars}},
Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars + upgradeStars}},
},
}, nil
}
@ -119,6 +136,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
@ -131,6 +151,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if inv.IncludeUpgrade && peer.Type != domain.PeerTypeUser {
return nil, starGiftInvalidErr()
}
if r.deps.Stars == nil || r.deps.Gifts == nil {
return nil, notImplementedErr()
}
@ -144,13 +167,24 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
if err != nil {
return nil, err
}
upgradeStars := int64(0)
if inv.IncludeUpgrade {
if gift.UpgradeStars <= 0 || gift.UpgradeIssued >= gift.UpgradeTotal {
return nil, starGiftInvalidErr()
}
upgradeStars = gift.UpgradeStars
}
if req.FormID != starGiftFormIDWithUpgrade(userID, peer, gift, inv.IncludeUpgrade) {
return nil, starsFormAmountMismatchErr()
}
giftMessage := ""
if m, ok := inv.GetMessage(); ok {
giftMessage = clampGiftMessage(m.Text)
}
// 1. Debit 送礼人不足→BALANCE_TOO_LOW
balance, err := r.deps.Stars.Debit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
purchaseStars := gift.Stars + upgradeStars
balance, err := r.deps.Stars.Debit(ctx, userID, purchaseStars, domain.StarsReasonGift, peer, "Star gift", gift.Title)
if err != nil {
return nil, starsErr(err)
}
@ -158,14 +192,14 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
var updates *tg.Updates
switch peer.Type {
case domain.PeerTypeUser:
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
case domain.PeerTypeChannel:
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage)
default:
err = domain.ErrStarGiftInvalid
}
if err != nil {
r.refundStarGift(ctx, userID, peer, gift)
r.refundStarGift(ctx, userID, peer, gift, purchaseStars)
return nil, internalErr()
}
@ -260,9 +294,9 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars)
if err != nil {
return nil, err
}
@ -271,11 +305,13 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
Message: message,
}); err != nil {
return nil, err
@ -303,12 +339,16 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
FromUserID: senderID,
NameHidden: hideName,
Saved: true,
CanUpgrade: false,
PrepaidUpgrade: false,
UpgradeStars: 0,
},
}
savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
FromUserID: senderID,
GiftID: gift.ID,
RevisionID: gift.RevisionID,
MsgID: 0,
SavedID: 0,
Date: now,
@ -335,7 +375,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
}
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (domain.SendPrivateTextResult, error) {
func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SendPrivateTextResult, error) {
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -357,6 +397,9 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
PeerUserID: recipientID,
NameHidden: hideName,
Saved: true,
CanUpgrade: gift.UpgradeStars > 0,
PrepaidUpgrade: prepaidUpgradeStars > 0,
UpgradeStars: gift.UpgradeStars,
},
},
}
@ -374,8 +417,8 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
}
// refundStarGift 补偿退款(投递/记账失败时把已 Debit 的星退回)。
func (r *Router) refundStarGift(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift) {
if _, err := r.deps.Stars.Credit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift refund", gift.Title); err != nil {
func (r *Router) refundStarGift(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift, amount int64) {
if _, err := r.deps.Stars.Credit(ctx, userID, amount, domain.StarsReasonGift, peer, "Star gift refund", gift.Title); err != nil {
r.log.Error("star gift refund failed", zap.Int64("user_id", userID), zap.Int64("gift_id", gift.ID), zap.Error(err))
}
}
@ -396,11 +439,27 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
if r.deps.Gifts == nil {
return emptySavedStarGifts(), nil
}
page, err := r.deps.Gifts.ListSaved(ctx, owner, req.ExcludeUnsaved, req.Offset, req.Limit)
collectionID, _ := req.GetCollectionID()
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner,
ExcludeUnsaved: req.ExcludeUnsaved,
ExcludeSaved: req.ExcludeSaved,
ExcludeUnlimited: req.ExcludeUnlimited,
ExcludeUnique: req.ExcludeUnique,
ExcludeUpgradable: req.ExcludeUpgradable,
ExcludeUnupgradable: req.ExcludeUnupgradable,
CollectionID: collectionID,
Offset: req.Offset,
Limit: req.Limit,
})
if err != nil {
return nil, internalErr()
}
return r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset), nil
response, err := r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset)
if err != nil {
return nil, internalErr()
}
return response, nil
}
// onPaymentsGetSavedStarGift 按 InputSavedStarGift 引用取指定礼物。
@ -429,7 +488,11 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
gifts = append(gifts, g)
}
}
return r.tgSavedStarGiftsResponse(ctx, userID, gifts, len(gifts), ""), nil
response, err := r.tgSavedStarGiftsResponse(ctx, userID, gifts, len(gifts), "")
if err != nil {
return nil, internalErr()
}
return response, nil
}
// onPaymentsSaveStarGift 切换礼物在资料的展示unsave=true 隐藏)。
@ -565,9 +628,15 @@ func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.
}
func (r *Router) ensureCanManageStarGiftOwner(ctx context.Context, userID int64, owner domain.Peer) error {
if owner.Type != domain.PeerTypeChannel {
if owner.Type == domain.PeerTypeUser {
if owner.ID != userID {
return peerIDInvalidErr()
}
return nil
}
if owner.Type != domain.PeerTypeChannel {
return peerIDInvalidErr()
}
if r.deps.Channels == nil {
return notImplementedErr()
}
@ -590,11 +659,41 @@ func (r *Router) invalidateStarGiftOwnerProjection(owner domain.Peer) {
}
}
func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int64, gifts []domain.SavedStarGift, count int, nextOffset string) *tg.PaymentsSavedStarGifts {
catalog := r.resolveStarGiftCatalog(ctx, gifts)
func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int64, gifts []domain.SavedStarGift, count int, nextOffset string) (*tg.PaymentsSavedStarGifts, error) {
uniqueIDs := make([]int64, 0)
seenUnique := make(map[int64]struct{})
for _, gift := range gifts {
if gift.UniqueGiftID != 0 {
if _, seen := seenUnique[gift.UniqueGiftID]; !seen {
seenUnique[gift.UniqueGiftID] = struct{}{}
uniqueIDs = append(uniqueIDs, gift.UniqueGiftID)
}
}
}
if len(uniqueIDs) > 0 {
uniques, err := r.deps.Gifts.UniqueByIDs(ctx, uniqueIDs)
if err != nil {
return nil, err
}
for i := range gifts {
if unique, ok := uniques[gifts[i].UniqueGiftID]; ok {
copy := unique
gifts[i].Unique = &copy
}
}
}
catalog, err := r.resolveStarGiftCatalog(ctx, gifts)
if err != nil {
return nil, err
}
availability, err := r.resolveStarGiftCollectibleAvailability(ctx, gifts)
if err != nil {
return nil, err
}
projected := tgSavedStarGifts(gifts, catalog, availability)
out := &tg.PaymentsSavedStarGifts{
Count: count,
Gifts: tgSavedStarGifts(gifts, catalog),
Gifts: projected,
Chats: []tg.ChatClass{},
}
if ids := savedStarGiftUserIDs(gifts); len(ids) > 0 {
@ -605,7 +704,35 @@ func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int6
if nextOffset != "" {
out.SetNextOffset(nextOffset)
}
return out
return out, nil
}
func (r *Router) resolveStarGiftCollectibleAvailability(ctx context.Context, gifts []domain.SavedStarGift) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability)
if r.deps.Gifts == nil {
return out, nil
}
ids := make([]int64, 0, len(gifts))
seen := make(map[int64]struct{}, len(gifts))
for _, gift := range gifts {
if gift.UniqueGiftID != 0 {
continue
}
if gift.Owner.Type != domain.PeerTypeUser {
// Channel upgrade RPCs are deliberately blocked until the channel pts
// aggregate exists, so do not advertise a dead-end action.
continue
}
if _, ok := seen[gift.GiftID]; ok {
continue
}
seen[gift.GiftID] = struct{}{}
ids = append(ids, gift.GiftID)
}
if len(ids) == 0 {
return out, nil
}
return r.deps.Gifts.CollectibleAvailability(ctx, ids)
}
func emptySavedStarGifts() *tg.PaymentsSavedStarGifts {
@ -636,6 +763,9 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
if g.Title != "" {
gift.SetTitle(g.Title)
}
if g.UpgradeStars > 0 && g.UpgradeIssued < g.UpgradeTotal {
gift.SetUpgradeStars(g.UpgradeStars)
}
return gift
}
@ -667,6 +797,14 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
if in.Converted {
action.Converted = true
}
action.CanUpgrade = in.CanUpgrade
action.PrepaidUpgrade = in.PrepaidUpgrade
if in.UpgradeStars > 0 {
action.SetUpgradeStars(in.UpgradeStars)
}
if in.UpgradeMsgID > 0 {
action.SetUpgradeMsgID(in.UpgradeMsgID)
}
if in.ConvertStars > 0 {
action.SetConvertStars(in.ConvertStars)
}
@ -687,30 +825,38 @@ func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionC
return action
}
// resolveStarGiftCatalog 解析这批 saved gift 涉及的目录项giftID → StarGift供下发完整贴纸/星价
func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.SavedStarGift) map[int64]domain.StarGift {
// resolveStarGiftCatalog 解析这批 saved gift 涉及的不可变目录版本revisionID → StarGift
func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.SavedStarGift) (map[int64]domain.StarGift, error) {
out := make(map[int64]domain.StarGift, len(gifts))
if r.deps.Gifts == nil {
return out
return out, nil
}
for _, g := range gifts {
if _, ok := out[g.GiftID]; ok {
if g.RevisionID == 0 {
return nil, domain.ErrStarGiftInvalid
}
if _, ok := out[g.RevisionID]; ok {
continue
}
if gift, found, err := r.deps.Gifts.GiftByID(ctx, g.GiftID); err == nil && found {
out[g.GiftID] = gift
gift, found, err := r.deps.Gifts.GiftRevisionByID(ctx, g.RevisionID)
if err != nil {
return nil, err
}
if !found {
return nil, domain.ErrStarGiftInvalid
}
return out
out[g.RevisionID] = gift
}
return out, nil
}
// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。
func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift) []tg.SavedStarGift {
func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) []tg.SavedStarGift {
out := make([]tg.SavedStarGift, 0, len(gifts))
for _, g := range gifts {
item := tg.SavedStarGift{
Date: g.Date,
Gift: tgSavedStarGiftGift(g, catalog),
Gift: tgSavedStarGiftGift(g, catalog, availability),
}
if g.NameHidden {
item.NameHidden = true
@ -733,19 +879,47 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
if g.Message != "" {
item.SetMessage(tg.TextWithEntities{Text: g.Message})
}
if g.UniqueGiftID == 0 {
current, available := availability[g.GiftID]
canIssue := available && current.UpgradeStars > 0 && current.Issued < current.SupplyTotal
if canIssue {
item.CanUpgrade = true
}
if g.PrepaidUpgradeStars > 0 && canIssue {
item.SetUpgradeStars(g.PrepaidUpgradeStars)
item.CanUpgrade = true
}
}
if g.PinnedOrder > 0 {
item.PinnedToTop = true
}
if len(g.CollectionIDs) > 0 {
item.SetCollectionID(append([]int(nil), g.CollectionIDs...))
}
if g.Unique != nil {
item.SetGiftNum(g.Unique.Num)
}
out = append(out, item)
}
return out
}
// tgSavedStarGiftGift 把 SavedStarGift 内嵌礼物投影为 tg.StarGift优先用目录解析出完整贴纸/星价
// (客户端据有效 sticker 渲染目录缺失礼物已下架时兜底最小形态。convert_stars 用实例值。
func tgSavedStarGiftGift(g domain.SavedStarGift, catalog map[int64]domain.StarGift) tg.StarGiftClass {
if gift, ok := catalog[g.GiftID]; ok {
// tgSavedStarGiftGift 按收到时的不可变 revision 投影,目录停用或后续改版不影响历史显示。
func tgSavedStarGiftGift(g domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) tg.StarGiftClass {
if g.Unique != nil {
return tgUniqueStarGift(*g.Unique)
}
if gift, ok := catalog[g.RevisionID]; ok {
if current, ok := availability[g.GiftID]; ok {
gift.UpgradeStars = current.UpgradeStars
gift.UpgradeTotal = current.SupplyTotal
gift.UpgradeIssued = current.Issued
}
out := tgStarGift(gift)
out.ConvertStars = g.ConvertStars
return out
}
// resolveStarGiftCatalog 在进入投影前保证每个 revision 都存在;该分支仅保留类型完备性。
return &tg.StarGift{
ID: g.GiftID,
Sticker: &tg.DocumentEmpty{},
@ -770,8 +944,18 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids
}
func starGiftFormID(userID, giftID int64) int64 {
id := userID*0x9e3779b1 ^ (giftID << 7) ^ 0x5347494654
func starGiftFormID(userID int64, peer domain.Peer, gift domain.StarGift) int64 {
return starGiftFormIDWithUpgrade(userID, peer, gift, false)
}
func starGiftFormIDWithUpgrade(userID int64, peer domain.Peer, gift domain.StarGift, includeUpgrade bool) int64 {
id := userID*0x9e3779b1 ^ (gift.ID << 7) ^ (gift.RevisionID << 11) ^ (gift.Stars << 17) ^ (peer.ID << 23) ^ 0x5347494654
if includeUpgrade {
id ^= gift.UpgradeStars<<29 ^ 0x55504752414445
}
for _, ch := range string(peer.Type) {
id = id*131 + int64(ch)
}
if id == 0 {
id = 0x5347
}

View file

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
@ -18,12 +19,6 @@ import (
"telesrv/internal/store/memory"
)
type stubGiftCatalog struct{ gifts []domain.StarGift }
func (s stubGiftCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) {
return s.gifts, nil
}
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
t.Helper()
ctx := context.Background()
@ -40,10 +35,12 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
t.Fatalf("create recipient: %v", err)
}
gift := domain.StarGift{
ID: 8001, Stars: 50, ConvertStars: 50, Title: "Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "image/webp"},
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
}
gifts := appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}})
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
gifts := appstargifts.NewService(giftStore, nil, 2)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
@ -54,6 +51,328 @@ func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain
return r, sender, recipient, gift
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
}
func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.unique, slug == s.unique.Slug, nil
}
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233
attribute.EdgeColor = 0x223344
attribute.PatternColor = 0x334455
attribute.TextColor = 0xffffff
return attribute
}
attribute.Document = &domain.Document{
ID: id, AccessHash: id + 1, FileReference: []byte("collectible-rpc"), Date: 1700000000,
MimeType: "application/x-tgsticker", Size: 3, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}, {Kind: domain.DocAttrFilename, FileName: "gift.tgs"}},
}
attribute.Animation = &domain.StarGiftAnimation{
SourceName: "gift.tgs", SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: make([]byte, 32), Width: 512, Height: 512,
}
attribute.Blob = &domain.FileBlob{LocationKey: "doc:test", Backend: domain.MediaBackendLocalFS, ObjectKey: "test", Size: 3}
return attribute
}
func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleAvailability(t *testing.T) {
historical := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 25, Title: "Historical Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker"},
}
saved := domain.SavedStarGift{GiftID: historical.ID, RevisionID: historical.RevisionID, MsgID: 44, Date: 100, ConvertStars: historical.ConvertStars}
availability := map[int64]domain.StarGiftCollectibleAvailability{
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
}
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)
if len(projected) != 1 || !projected[0].CanUpgrade {
t.Fatalf("saved gift = %#v, want current pool to make historical gift upgradable", projected)
}
gift, ok := projected[0].Gift.(*tg.StarGift)
if !ok {
t.Fatalf("saved gift inner = %T, want *tg.StarGift", projected[0].Gift)
}
if gift.Title != historical.Title || gift.Stars != historical.Stars || gift.ConvertStars != historical.ConvertStars {
t.Fatalf("historical snapshot changed: %#v", gift)
}
if upgradeStars, ok := gift.GetUpgradeStars(); !ok || upgradeStars != 75 {
t.Fatalf("upgrade_stars = %d ok=%v, want current price 75", upgradeStars, ok)
}
for _, profile := range []tg.LayerProfile{tg.LayerProfile227, tg.LayerProfile228} {
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
encoded := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorPaymentsSavedStarGiftsType(), wire, encoded); err != nil {
t.Fatalf("encode Layer %d saved gift: %v", profile, err)
}
decoded, err := tg.DecodeLayer(profile, tg.LayerConstructorPaymentsSavedStarGiftsType(), &bin.Buffer{Buf: encoded.Buf})
if err != nil {
t.Fatalf("decode Layer %d saved gift: %v", profile, err)
}
inner, ok := decoded.Gifts[0].Gift.(*tg.StarGift)
if !ok || !decoded.Gifts[0].CanUpgrade || inner.UpgradeStars != 75 {
t.Fatalf("Layer %d projection lost upgrade flags: %#v", profile, decoded.Gifts[0])
}
}
availability[historical.ID] = domain.StarGiftCollectibleAvailability{UpgradeStars: 75, SupplyTotal: 500, Issued: 500}
soldOut := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOut.CanUpgrade {
t.Fatal("sold-out collectible pool must not advertise upgrade")
}
if gift, ok := soldOut.Gift.(*tg.StarGift); !ok {
t.Fatalf("sold-out inner = %T, want *tg.StarGift", soldOut.Gift)
} else if _, ok := gift.GetUpgradeStars(); ok {
t.Fatal("sold-out catalog projection must not expose upgrade_stars")
}
saved.PrepaidUpgradeStars = 75
soldOutPrepaid := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOutPrepaid.CanUpgrade {
t.Fatal("sold-out prepaid gift must not advertise an upgrade the aggregate will reject")
}
if _, ok := soldOutPrepaid.GetUpgradeStars(); ok {
t.Fatal("sold-out prepaid gift must not expose stale prepaid upgrade_stars")
}
}
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
ownerCtx := WithUserID(ctx, owner.ID)
giftService, ok := r.deps.Gifts.(*appstargifts.Service)
if !ok {
t.Fatalf("gift service = %T", r.deps.Gifts)
}
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, FromUserID: sender.ID,
GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: 444, Date: 1700000000, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record upgrade target: %v", err)
}
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ownerCtx, gift.ID)
if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err)
}
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get upgrade payment form: %v", err)
}
form, ok := formClass.(*tg.PaymentsPaymentFormStarGift)
if !ok || form.FormID == 0 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 75 {
t.Fatalf("upgrade payment form = %T %#v", formClass, formClass)
}
unique := domain.UniqueStarGift{
ID: 9200000000000001, GiftID: gift.ID, Title: gift.Title, Slug: "cake-1", Num: 1,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Model: model, Pattern: pattern, Backdrop: backdrop,
AvailabilityIssued: 1, AvailabilityTotal: 500, KeepOriginalDetails: true,
OriginalFromUserID: sender.ID, OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
OriginalDate: 1700000000, OriginalMessage: "hello",
}
r.deps.Gifts = &uniqueGiftRPCService{GiftsService: r.deps.Gifts, unique: unique}
uniqueResponse, err := r.onPaymentsGetUniqueStarGift(WithUserID(ctx, sender.ID), unique.Slug)
if err != nil {
t.Fatalf("get unique gift = %#v err %v", uniqueResponse, err)
}
uniqueGift, ok := uniqueResponse.Gift.(*tg.StarGiftUnique)
if !ok || uniqueGift.Slug != unique.Slug || len(uniqueGift.Attributes) != 4 || len(uniqueResponse.Users) != 2 {
t.Fatalf("get unique gift = %#v", uniqueResponse)
}
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true,
},
}}}
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
if !ok {
t.Fatalf("unique service action type = %T", tgMessageServiceAction(message))
}
projectedGift, giftOK := action.Gift.(*tg.StarGiftUnique)
if !giftOK || !action.Upgrade || !action.Saved || projectedGift.ID != unique.ID || projectedGift.Slug != unique.Slug {
t.Fatalf("unique service action = %#v", tgMessageServiceAction(message))
}
if peer, ok := action.GetPeer(); !ok {
t.Fatal("unique service action missing owner peer")
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer)
}
for _, profile := range []tg.LayerProfile{tg.LayerProfile227, tg.LayerProfile228} {
responseWire := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorPaymentsUniqueStarGiftType(), uniqueResponse, responseWire); err != nil {
t.Fatalf("encode Layer %d unique response: %v", profile, err)
}
decodedResponse, err := tg.DecodeLayer(profile, tg.LayerConstructorPaymentsUniqueStarGiftType(), &bin.Buffer{Buf: responseWire.Buf})
if err != nil {
t.Fatalf("decode Layer %d unique response: %v", profile, err)
}
decodedGift, ok := decodedResponse.Gift.(*tg.StarGiftUnique)
if !ok || decodedGift.Slug != unique.Slug || len(decodedGift.Attributes) != 4 {
t.Fatalf("Layer %d unique response lost fields: %#v", profile, decodedResponse.Gift)
}
actionWire := &bin.Buffer{}
if err := tg.EncodeLayer(profile, tg.LayerConstructorMessageActionStarGiftUniqueType(), action, actionWire); err != nil {
t.Fatalf("encode Layer %d unique action: %v", profile, err)
}
decodedAction, err := tg.DecodeLayer(profile, tg.LayerConstructorMessageActionStarGiftUniqueType(), &bin.Buffer{Buf: actionWire.Buf})
if err != nil {
t.Fatalf("decode Layer %d unique action: %v", profile, err)
}
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
}
}
}
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
r, _, owner, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
for _, msgID := range []int{101, 102} {
if _, err := r.deps.Gifts.RecordSavedGift(context.Background(), domain.SavedStarGift{
Owner: ownerPeer, GiftID: gift.ID, RevisionID: gift.RevisionID,
MsgID: msgID, Date: 1700000000 + msgID, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record saved gift %d: %v", msgID, err)
}
}
ref101 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 101})
ref102 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 102})
first, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: " Favorites ", Stargift: []tg.InputSavedStarGiftClass{ref101},
})
if err != nil {
t.Fatalf("create first collection: %v", err)
}
second, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: "Archive", Stargift: []tg.InputSavedStarGiftClass{ref102},
})
if err != nil {
t.Fatalf("create second collection: %v", err)
}
if first.Title != "Favorites" || first.GiftsCount != 1 || second.GiftsCount != 1 {
t.Fatalf("created collections = %#v / %#v", first, second)
}
listedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list collections: %v", err)
}
listed, ok := listedClass.(*tg.PaymentsStarGiftCollections)
if !ok || len(listed.Collections) != 2 {
t.Fatalf("list collections = %T %#v, want two", listedClass, listedClass)
}
domainCollections, err := r.deps.Gifts.ListCollections(context.Background(), ownerPeer)
if err != nil {
t.Fatalf("list domain collections: %v", err)
}
if notModified, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Hash: domain.StarGiftCollectionsHash(domainCollections),
}); err != nil {
t.Fatalf("hash list collections: %v", err)
} else if _, ok := notModified.(*tg.PaymentsStarGiftCollectionsNotModified); !ok {
t.Fatalf("hash response = %T, want not modified", notModified)
}
update := &tg.PaymentsUpdateStarGiftCollectionRequest{Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID}
update.SetTitle("Best")
update.SetAddStargift([]tg.InputSavedStarGiftClass{ref102})
update.SetOrder([]tg.InputSavedStarGiftClass{ref102, ref101})
updated, err := r.onPaymentsUpdateStarGiftCollection(ctx, update)
if err != nil {
t.Fatalf("update collection: %v", err)
}
if updated.Title != "Best" || updated.GiftsCount != 2 {
t.Fatalf("updated collection = %#v", updated)
}
filteredReq := &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10}
filteredReq.SetCollectionID(first.CollectionID)
filtered, err := r.onPaymentsGetSavedStarGifts(ctx, filteredReq)
if err != nil {
t.Fatalf("filter saved gifts by collection: %v", err)
}
if filtered.Count != 2 || len(filtered.Gifts) != 2 {
t.Fatalf("filtered gifts count=%d len=%d, want 2/2", filtered.Count, len(filtered.Gifts))
}
for _, saved := range filtered.Gifts {
ids, ok := saved.GetCollectionID()
if !ok || len(ids) == 0 || ids[0] != first.CollectionID {
t.Fatalf("saved gift collection projection = %#v", saved)
}
}
if ok, err := r.onPaymentsToggleStarGiftsPinnedToTop(ctx, &tg.PaymentsToggleStarGiftsPinnedToTopRequest{
Peer: &tg.InputPeerSelf{}, Stargift: []tg.InputSavedStarGiftClass{ref101, ref102},
}); err != nil || !ok {
t.Fatalf("pin gifts = %v err %v", ok, err)
}
pinned, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list pinned gifts: %v", err)
}
if len(pinned.Gifts) != 2 || !pinned.Gifts[0].PinnedToTop || !pinned.Gifts[1].PinnedToTop {
t.Fatalf("pinned projection = %#v", pinned.Gifts)
}
if ok, err := r.onPaymentsReorderStarGiftCollections(ctx, &tg.PaymentsReorderStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Order: []int{second.CollectionID, first.CollectionID},
}); err != nil || !ok {
t.Fatalf("reorder collections = %v err %v", ok, err)
}
reorderedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list reordered collections: %v", err)
}
reordered := reorderedClass.(*tg.PaymentsStarGiftCollections)
if reordered.Collections[0].CollectionID != second.CollectionID {
t.Fatalf("reordered collections = %#v", reordered.Collections)
}
if ok, err := r.onPaymentsDeleteStarGiftCollection(ctx, &tg.PaymentsDeleteStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID,
}); err != nil || !ok {
t.Fatalf("delete collection = %v err %v", ok, err)
}
afterDelete, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list gifts after collection delete: %v", err)
}
for _, saved := range afterDelete.Gifts {
if ids, ok := saved.GetCollectionID(); ok {
for _, id := range ids {
if id == first.CollectionID {
t.Fatalf("deleted collection %d leaked in saved gift %#v", id, saved)
}
}
}
}
}
// 完整 star gift sagacatalog → getPaymentForm(paymentFormStarGift) → sendStarsForm(扣费+服务消息
// +paymentResult) → 收礼人 getSavedStarGifts → save/convert。
func TestStarGiftSaga(t *testing.T) {
@ -214,6 +533,21 @@ func TestStarGiftChannelSaga(t *testing.T) {
channel := created.Channel
channelPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
channelInput := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
giftService := r.deps.Gifts.(*appstargifts.Service)
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 10, SlugPrefix: "channel-cake",
Models: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8201, "Aurora")},
Patterns: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8202, "Orbit")},
Backdrops: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Midnight")},
Actor: "test", CommandID: "channel-collectible-rpc",
}); err != nil {
t.Fatalf("publish channel collectible pool: %v", err)
}
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true,
}}); err == nil {
t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked")
}
inv := &tg.InputInvoiceStarGift{
Peer: channelPeer,
GiftID: gift.ID,
@ -265,6 +599,9 @@ func TestStarGiftChannelSaga(t *testing.T) {
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
}
if savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked")
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok)
@ -366,17 +703,20 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
msgStore := memory.NewMessageStore(dialogs)
sender, _ := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "Poor"})
recipient, _ := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "Rich"})
gift := domain.StarGift{ID: 8002, Stars: 5000, ConvertStars: 5000, Title: "Expensive",
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "image/webp"}}
gift := domain.StarGift{ID: 8002, RevisionID: 9002, Stars: 5000, ConvertStars: 5000, Title: "Expensive",
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}}
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000
Gifts: appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}}),
Gifts: appstargifts.NewService(giftStore, nil, 2),
}, zaptest.NewLogger(t), clock.System)
senderCtx := WithUserID(ctx, sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{Invoice: inv}); err == nil {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil {
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
}
// 余额未变。
@ -385,6 +725,25 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
}
}
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
base := starGiftFormID(sender.ID, peer, gift)
changedRevision := gift
changedRevision.RevisionID++
changedPrice := gift
changedPrice.Stars++
changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1}
if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) {
t.Fatal("star gift form id must bind revision, price and recipient")
}
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("bad form err=%v", err)
}
}
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := context.Background()

View file

@ -3,6 +3,7 @@ package memory
import (
"context"
"sort"
"strings"
"sync"
"telesrv/internal/domain"
@ -12,12 +13,254 @@ import (
type StarGiftStore struct {
mu sync.Mutex
nextID int64
nextGiftID int64
nextRevID int64
gifts []domain.SavedStarGift // 追加序
catalog map[int64]domain.StarGift
revisions map[int64]domain.StarGift
enabled map[int64]bool
sortOrder map[int64]int
animations map[int64][]byte
collectibles map[int64]domain.StarGiftCollectibleRevision
uniqueByID map[int64]domain.UniqueStarGift
uniqueBySlug map[string]int64
collections map[domain.Peer][]domain.StarGiftCollection
nextAttributeID int64
nextCollectionID int
}
// NewStarGiftStore 创建内存 StarGiftStore。
func NewStarGiftStore() *StarGiftStore {
return &StarGiftStore{}
return &StarGiftStore{
catalog: make(map[int64]domain.StarGift), revisions: make(map[int64]domain.StarGift),
enabled: make(map[int64]bool), sortOrder: make(map[int64]int), animations: make(map[int64][]byte),
collectibles: make(map[int64]domain.StarGiftCollectibleRevision),
uniqueByID: make(map[int64]domain.UniqueStarGift), uniqueBySlug: make(map[string]int64),
collections: make(map[domain.Peer][]domain.StarGiftCollection),
}
}
// SeedCatalog installs valid immutable catalog snapshots for tests.
func (s *StarGiftStore) SeedCatalog(gifts []domain.StarGift) {
s.mu.Lock()
defer s.mu.Unlock()
for _, gift := range gifts {
if gift.RevisionID == 0 {
s.nextRevID++
gift.RevisionID = s.nextRevID
}
if gift.ID > s.nextGiftID {
s.nextGiftID = gift.ID
}
if gift.RevisionID > s.nextRevID {
s.nextRevID = gift.RevisionID
}
s.catalog[gift.ID] = gift
s.revisions[gift.RevisionID] = gift
s.enabled[gift.ID] = true
}
}
func (s *StarGiftStore) Catalog(_ context.Context) ([]domain.StarGift, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.StarGift, 0, len(s.catalog))
for id, gift := range s.catalog {
if s.enabled[id] {
out = append(out, gift)
}
}
sort.Slice(out, func(i, j int) bool {
if s.sortOrder[out[i].ID] == s.sortOrder[out[j].ID] {
return out[i].ID < out[j].ID
}
return s.sortOrder[out[i].ID] < s.sortOrder[out[j].ID]
})
return out, nil
}
func (s *StarGiftStore) CatalogGift(_ context.Context, giftID int64) (domain.StarGift, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
gift, ok := s.catalog[giftID]
return gift, ok && s.enabled[giftID], nil
}
func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (domain.StarGift, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
gift, ok := s.revisions[revisionID]
return gift, ok, nil
}
func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
giftID := write.GiftID
if giftID == 0 {
s.nextGiftID++
giftID = s.nextGiftID
} else if _, ok := s.catalog[giftID]; !ok {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound
}
s.nextRevID++
gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document}
s.catalog[giftID] = gift
s.revisions[gift.RevisionID] = gift
s.enabled[giftID] = write.Enabled
s.sortOrder[giftID] = write.SortOrder
s.animations[giftID] = append([]byte(nil), write.Animation.JSON...)
return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil
}
func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.catalog[giftID]; !ok {
return false, domain.ErrStarGiftNotFound
}
changed := s.enabled[giftID] != enabled
s.enabled[giftID] = enabled
return changed, nil
}
func (s *StarGiftStore) SetCatalogSortOrder(_ context.Context, giftID int64, sortOrder int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.catalog[giftID]; !ok {
return false, domain.ErrStarGiftNotFound
}
changed := s.sortOrder[giftID] != sortOrder
s.sortOrder[giftID] = sortOrder
return changed, nil
}
func (s *StarGiftStore) AnimationJSON(_ context.Context, giftID int64) ([]byte, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
raw, ok := s.animations[giftID]
return append([]byte(nil), raw...), ok, nil
}
func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.catalog[write.GiftID]; !ok {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound
}
previous := s.collectibles[write.GiftID]
revision := domain.StarGiftCollectibleRevision{
ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1,
UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal,
SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true,
CreatedBy: write.Actor,
}
if revision.ID == 1 {
revision.ID = write.GiftID*1000 + 1
}
revision.Models = s.allocateCollectibleAttributes(write.Models, revision.ID)
revision.Patterns = s.allocateCollectibleAttributes(write.Patterns, revision.ID)
revision.Backdrops = s.allocateCollectibleAttributes(write.Backdrops, revision.ID)
s.collectibles[write.GiftID] = revision
gift := s.catalog[write.GiftID]
gift.UpgradeStars = revision.UpgradeStars
gift.UpgradeTotal = revision.SupplyTotal
gift.UpgradeIssued = revision.Issued
s.catalog[write.GiftID] = gift
return cloneCollectibleRevision(revision), nil
}
func (s *StarGiftStore) allocateCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, revisionID int64) []domain.StarGiftCollectibleAttribute {
out := make([]domain.StarGiftCollectibleAttribute, len(in))
for i, attribute := range in {
s.nextAttributeID++
attribute.ID = s.nextAttributeID
attribute.CollectibleRevisionID = revisionID
out[i] = cloneCollectibleAttribute(attribute)
}
return out
}
func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
revision, ok := s.collectibles[giftID]
return cloneCollectibleRevision(revision), ok, nil
}
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
for _, giftID := range giftIDs {
revision, ok := s.collectibles[giftID]
if !ok || !revision.Published {
continue
}
out[giftID] = domain.StarGiftCollectibleAvailability{
UpgradeStars: revision.UpgradeStars,
SupplyTotal: revision.SupplyTotal,
Issued: revision.Issued,
}
}
return out, nil
}
func (s *StarGiftStore) CollectibleAnimationJSON(_ context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
revision, ok := s.collectibles[giftID]
if !ok {
return nil, false, nil
}
var attributes []domain.StarGiftCollectibleAttribute
switch kind {
case domain.StarGiftCollectibleModel:
attributes = revision.Models
case domain.StarGiftCollectiblePattern:
attributes = revision.Patterns
default:
return nil, false, nil
}
for _, attribute := range attributes {
if attribute.ID == attributeID && attribute.Animation != nil {
return append([]byte(nil), attribute.Animation.JSON...), true, nil
}
}
return nil, false, nil
}
func (s *StarGiftStore) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(slug))]
if !ok {
return domain.UniqueStarGift{}, false, nil
}
unique, ok := s.uniqueByID[id]
return unique, ok, nil
}
func (s *StarGiftStore) UniqueByID(_ context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
unique, ok := s.uniqueByID[uniqueGiftID]
return unique, ok, nil
}
func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
for _, id := range uniqueGiftIDs {
if gift, ok := s.uniqueByID[id]; ok {
out[id] = gift
}
}
return out, nil
}
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
@ -37,6 +280,13 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in
}
func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
return s.ListByOwnerFiltered(context.Background(), domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
if !validStarGiftOwner(owner) {
return domain.SavedStarGiftPage{}, nil
}
@ -50,7 +300,31 @@ func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, exclud
if g.Owner != owner || g.Converted {
continue
}
if excludeUnsaved && g.Unsaved {
if filter.ExcludeUnsaved && g.Unsaved {
continue
}
if filter.ExcludeSaved && !g.Unsaved {
continue
}
if filter.ExcludeUnique && g.UniqueGiftID != 0 {
continue
}
if filter.ExcludeUnlimited && g.UniqueGiftID == 0 {
continue
}
upgradable := false
if g.UniqueGiftID == 0 {
if gift, ok := s.catalog[g.GiftID]; ok {
upgradable = gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal
}
}
if filter.ExcludeUpgradable && upgradable {
continue
}
if filter.ExcludeUnupgradable && !upgradable {
continue
}
if filter.CollectionID > 0 && !containsInt(g.CollectionIDs, filter.CollectionID) {
continue
}
matched = append(matched, g)
@ -82,6 +356,37 @@ func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, exclud
return page, nil
}
func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, 0, len(refs))
seen := make(map[int64]struct{}, len(refs))
for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound
}
var id int64
for _, gift := range s.gifts {
if savedStarGiftMatchesRef(gift, ref) && !gift.Converted {
id = gift.ID
break
}
}
if id == 0 {
return nil, domain.ErrStarGiftNotFound
}
if _, exists := seen[id]; exists {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
if !ref.Valid() {
return domain.SavedStarGift{}, false, nil
@ -134,19 +439,298 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
defer s.mu.Unlock()
for i := range s.gifts {
if savedStarGiftMatchesRef(s.gifts[i], ref) {
if s.gifts[i].UniqueGiftID != 0 {
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
}
if s.gifts[i].Converted {
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
}
s.gifts[i].Converted = true
s.gifts[i].Unsaved = true
s.gifts[i].PinnedOrder = 0
for collectionIndex := range s.collections[ref.Owner] {
collection := &s.collections[ref.Owner][collectionIndex]
next := collection.GiftIDs[:0]
for _, giftID := range collection.GiftIDs {
if giftID != s.gifts[i].ID {
next = append(next, giftID)
}
}
collection.GiftIDs = next
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
}
s.refreshCollectionMembershipsLocked(ref.Owner)
return s.gifts[i], nil
}
}
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
}
func (s *StarGiftStore) ListCollections(_ context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
s.mu.Lock()
defer s.mu.Unlock()
return cloneStarGiftCollections(s.collections[owner]), nil
}
func (s *StarGiftStore) CreateCollection(_ context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
title = strings.TrimSpace(title)
if !validStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.collections[owner]) >= domain.MaxStarGiftCollectionsPerPeer {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionsFull
}
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
if err != nil {
return domain.StarGiftCollection{}, err
}
s.nextCollectionID++
collection := domain.StarGiftCollection{Owner: owner, CollectionID: s.nextCollectionID, Title: title, GiftIDs: ids, SortOrder: len(s.collections[owner])}
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
s.collections[owner] = append(s.collections[owner], collection)
s.refreshCollectionMembershipsLocked(owner)
return collection, nil
}
func (s *StarGiftStore) UpdateCollection(_ context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
s.mu.Lock()
defer s.mu.Unlock()
collections := s.collections[owner]
index := -1
for i := range collections {
if collections[i].CollectionID == collectionID {
index = i
break
}
}
if index < 0 {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionNotFound
}
collection := collections[index]
if patch.Title != nil {
title := strings.TrimSpace(*patch.Title)
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
collection.Title = title
}
deleteSet := make(map[int64]struct{}, len(patch.DeleteIDs))
for _, id := range patch.DeleteIDs {
deleteSet[id] = struct{}{}
}
next := make([]int64, 0, len(collection.GiftIDs)+len(patch.AddIDs))
for _, id := range collection.GiftIDs {
if _, deleted := deleteSet[id]; !deleted {
next = append(next, id)
}
}
add, err := s.validCollectionGiftIDsLocked(owner, patch.AddIDs)
if err != nil {
return domain.StarGiftCollection{}, err
}
next = appendUniqueInt64(next, add...)
if patch.Order != nil {
order, err := s.validCollectionGiftIDsLocked(owner, patch.Order)
if err != nil || !sameInt64Set(order, next) {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
next = order
}
if len(next) > domain.MaxStarGiftCollectionItems {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
collection.GiftIDs = next
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
collections[index] = collection
s.collections[owner] = collections
s.refreshCollectionMembershipsLocked(owner)
return collection, nil
}
func (s *StarGiftStore) DeleteCollection(_ context.Context, owner domain.Peer, collectionID int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
collections := s.collections[owner]
for i := range collections {
if collections[i].CollectionID == collectionID {
collections = append(collections[:i], collections[i+1:]...)
for j := range collections {
collections[j].SortOrder = j
}
s.collections[owner] = collections
s.refreshCollectionMembershipsLocked(owner)
return true, nil
}
}
return false, nil
}
func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer, collectionIDs []int) error {
s.mu.Lock()
defer s.mu.Unlock()
collections := s.collections[owner]
if len(collectionIDs) != len(collections) {
return domain.ErrStarGiftCollectibleInvalid
}
byID := make(map[int]domain.StarGiftCollection, len(collections))
for _, collection := range collections {
byID[collection.CollectionID] = collection
}
next := make([]domain.StarGiftCollection, 0, len(collections))
for order, id := range collectionIDs {
collection, ok := byID[id]
if !ok {
return domain.ErrStarGiftCollectibleInvalid
}
delete(byID, id)
collection.SortOrder = order
next = append(next, collection)
}
s.collections[owner] = next
return nil
}
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
s.mu.Lock()
defer s.mu.Unlock()
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
if err != nil {
return err
}
order := make(map[int64]int, len(ids))
for i, id := range ids {
order[id] = i + 1
}
for i := range s.gifts {
if s.gifts[i].Owner == owner {
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
}
}
return nil
}
// refreshCollectionMembershipsLocked keeps the in-memory saved-gift projection
// equivalent to the PostgreSQL join projection. Callers must hold s.mu.
func (s *StarGiftStore) refreshCollectionMembershipsLocked(owner domain.Peer) {
memberships := make(map[int64][]int)
for _, collection := range s.collections[owner] {
for _, giftID := range collection.GiftIDs {
memberships[giftID] = append(memberships[giftID], collection.CollectionID)
}
}
for i := range s.gifts {
if s.gifts[i].Owner != owner {
continue
}
s.gifts[i].CollectionIDs = append([]int(nil), memberships[s.gifts[i].ID]...)
}
}
func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []int64) ([]int64, error) {
if len(ids) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
valid := false
for _, gift := range s.gifts {
if gift.ID == id && gift.Owner == owner && !gift.Converted {
valid = true
break
}
}
if !valid {
return nil, domain.ErrStarGiftNotFound
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func appendUniqueInt64(dst []int64, values ...int64) []int64 {
seen := make(map[int64]struct{}, len(dst)+len(values))
for _, id := range dst {
seen[id] = struct{}{}
}
for _, id := range values {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
dst = append(dst, id)
}
}
return dst
}
func sameInt64Set(a, b []int64) bool {
if len(a) != len(b) {
return false
}
seen := make(map[int64]int, len(a))
for _, id := range a {
seen[id]++
}
for _, id := range b {
seen[id]--
if seen[id] < 0 {
return false
}
}
return true
}
func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.StarGiftCollectibleAttribute {
out := in
if in.Document != nil {
document := *in.Document
out.Document = &document
}
if in.Animation != nil {
animation := *in.Animation
animation.JSON = append([]byte(nil), in.Animation.JSON...)
animation.TGS = append([]byte(nil), in.Animation.TGS...)
animation.SHA256 = append([]byte(nil), in.Animation.SHA256...)
out.Animation = &animation
}
if in.Blob != nil {
blob := *in.Blob
out.Blob = &blob
}
return out
}
func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision {
out := in
clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute {
copy := make([]domain.StarGiftCollectibleAttribute, len(attributes))
for i, attribute := range attributes {
copy[i] = cloneCollectibleAttribute(attribute)
}
return copy
}
out.Models = clone(in.Models)
out.Patterns = clone(in.Patterns)
out.Backdrops = clone(in.Backdrops)
return out
}
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
out := make([]domain.StarGiftCollection, len(in))
for i, collection := range in {
out[i] = collection
out[i].GiftIDs = append([]int64(nil), collection.GiftIDs...)
}
return out
}
func validSavedStarGift(g domain.SavedStarGift) bool {
if g.GiftID == 0 || !validStarGiftOwner(g.Owner) {
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
return false
}
switch g.Owner.Type {

View file

@ -5,6 +5,7 @@ import (
"crypto/rand"
"errors"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
@ -19,6 +20,13 @@ func testPool(t *testing.T) *pgxpool.Pool {
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
parsed, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse TELESRV_TEST_POSTGRES_DSN: %v", err)
}
if !strings.Contains(strings.ToLower(parsed.ConnConfig.Database), "test") {
t.Fatalf("TELESRV_TEST_POSTGRES_DSN must name a dedicated test database, got %q", parsed.ConnConfig.Database)
}
if err := Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}

View file

@ -76,8 +76,17 @@ ON CONFLICT (id) DO NOTHING
}
func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) {
return s.sendPrivateTextWithHooks(ctx, req, privateSendTxHooks{})
}
type privateSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
}
func (s *MessageStore) sendPrivateTextWithHooks(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
for attempt := 0; attempt < 2; attempt++ {
res, err = s.sendPrivateTextOnce(ctx, req)
res, err = s.sendPrivateTextOnce(ctx, req, hooks)
if err == nil {
return res, nil
}
@ -91,7 +100,7 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
return domain.SendPrivateTextResult{}, err
}
func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) {
func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest, hooks privateSendTxHooks) (res domain.SendPrivateTextResult, err error) {
if req.SenderUserID == 0 || req.RecipientUserID == 0 {
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id")
}
@ -108,10 +117,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err != nil {
return domain.SendPrivateTextResult{}, err
}
mediaJSON, err := encodeMessageMedia(req.Media)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
// reply_markupbot inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil {
@ -181,6 +186,15 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err)
}
if hooks.before != nil {
if err := hooks.before(ctx, tx, &req); err != nil {
return domain.SendPrivateTextResult{}, err
}
}
mediaJSON, err := encodeMessageMedia(req.Media)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
ttlPeriod := req.TTLPeriod
if ttlPeriod == 0 {
ttlPeriod, err = privateHistoryTTLPeriod(ctx, tx, req.SenderUserID, req.RecipientUserID)
@ -298,12 +312,21 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err := appendNewMessageEvent(ctx, qtx, sender); err != nil {
return domain.SendPrivateTextResult{}, err
}
originUserID := req.OriginUserID
if originUserID == 0 {
originUserID = req.SenderUserID
}
senderExcludeAuthKeyID, senderExcludeSessionID := int64(0), int64(0)
if originUserID == req.SenderUserID {
senderExcludeAuthKeyID = authKeyIDToInt64(req.OriginAuthKeyID)
senderExcludeSessionID = req.OriginSessionID
}
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.SenderUserID,
Pts: int32(senderPts),
EventType: string(domain.UpdateEventNewMessage),
ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID),
ExcludeSessionID: req.OriginSessionID,
ExcludeAuthKeyID: senderExcludeAuthKeyID,
ExcludeSessionID: senderExcludeSessionID,
}); err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err)
}
@ -360,12 +383,17 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil {
return domain.SendPrivateTextResult{}, err
}
recipientExcludeAuthKeyID, recipientExcludeSessionID := int64(0), int64(0)
if originUserID == req.RecipientUserID {
recipientExcludeAuthKeyID = authKeyIDToInt64(req.OriginAuthKeyID)
recipientExcludeSessionID = req.OriginSessionID
}
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.RecipientUserID,
Pts: int32(recipientPts),
EventType: string(domain.UpdateEventNewMessage),
ExcludeAuthKeyID: 0,
ExcludeSessionID: 0,
ExcludeAuthKeyID: recipientExcludeAuthKeyID,
ExcludeSessionID: recipientExcludeSessionID,
}); err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err)
}
@ -397,17 +425,23 @@ WHERE sender_user_id = $1
if tag.RowsAffected() != 1 {
return domain.SendPrivateTextResult{}, fmt.Errorf("save private send receipt: private message %d already has or lost its immutable receipt", pm.ID)
}
result := domain.SendPrivateTextResult{
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: eventFromMessage(sender),
RecipientEvent: eventFromMessage(recipient),
}
if hooks.after != nil {
if err := hooks.after(ctx, tx, result); err != nil {
return domain.SendPrivateTextResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err)
}
committed = true
return domain.SendPrivateTextResult{
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: eventFromMessage(sender),
RecipientEvent: eventFromMessage(recipient),
}, nil
return result, nil
}
// LookupPrivateSendReplay reads an existing receipt without permission checks, source/media

View file

@ -33,6 +33,12 @@ type ReadModelCacheSet struct {
RPCProjections RPCProjectionReadModelCache
BaseUsers BaseUserCache
BotProfiles BotProfileReadModelCache
StarGifts StarGiftCatalogCache
}
type StarGiftCatalogCache interface {
InvalidateStarGiftCatalog()
FlushStarGiftCatalog()
}
// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除
@ -214,7 +220,8 @@ func (l *ReadModelChangeListener) empty() bool {
l.caches.PrivateMediaCounts == nil &&
l.caches.RPCProjections == nil &&
l.caches.BaseUsers == nil &&
l.caches.BotProfiles == nil
l.caches.BotProfiles == nil &&
l.caches.StarGifts == nil
}
func (l *ReadModelChangeListener) flush(reasons ...string) {
@ -287,6 +294,10 @@ func (l *ReadModelChangeListener) flush(reasons ...string) {
l.caches.BotProfiles.FlushBotProfileReadModel()
flushed = append(flushed, "bot_profiles")
}
if l.caches.StarGifts != nil {
l.caches.StarGifts.FlushStarGiftCatalog()
flushed = append(flushed, "star_gifts")
}
// 注意BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存整库清空会误伤
// 其它实例;漏掉的通知由其 5min TTL 兜底。
l.log.Info("read model caches flushed",
@ -315,6 +326,10 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
}
}
switch evt.Model {
case "star_gift_catalog":
if l.caches.StarGifts != nil {
l.caches.StarGifts.InvalidateStarGiftCatalog()
}
case "user_base":
if evt.PeerType == "user" && evt.PeerID != 0 {
if l.caches.RPCProjections != nil {

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
@ -21,6 +22,290 @@ func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore {
return &StarGiftStore{db: db}
}
const starGiftCatalogSelect = `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
JOIN documents d ON d.id = r.document_id`
func (s *StarGiftStore) Catalog(ctx context.Context) ([]domain.StarGift, error) {
rows, err := s.db.Query(ctx, starGiftCatalogSelect+`
WHERE c.enabled
ORDER BY c.sort_order, c.gift_id`)
if err != nil {
return nil, fmt.Errorf("list star gift catalog: %w", err)
}
defer rows.Close()
out := make([]domain.StarGift, 0)
for rows.Next() {
gift, err := scanCatalogGift(rows)
if err != nil {
return nil, err
}
out = append(out, gift)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate star gift catalog: %w", err)
}
return out, nil
}
func (s *StarGiftStore) CatalogGift(ctx context.Context, giftID int64) (domain.StarGift, bool, error) {
if giftID <= 0 {
return domain.StarGift{}, false, nil
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, starGiftCatalogSelect+`
WHERE c.enabled AND c.gift_id = $1`, giftID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, false, nil
}
if err != nil {
return domain.StarGift{}, false, err
}
return gift, true, nil
}
func (s *StarGiftStore) CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error) {
if revisionID <= 0 {
return domain.StarGift{}, false, nil
}
gift, err := scanCatalogGift(s.db.QueryRow(ctx, `
SELECT r.gift_id, r.id, r.stars, r.convert_stars, r.title,
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM star_gift_catalog_revisions r
JOIN star_gift_catalog c ON c.gift_id = r.gift_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id = c.collectible_revision_id AND cr.status = 'published'
JOIN documents d ON d.id = r.document_id
WHERE r.id = $1`, revisionID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGift{}, false, nil
}
if err != nil {
return domain.StarGift{}, false, err
}
return gift, true, nil
}
func scanCatalogGift(row rowScanner) (domain.StarGift, error) {
var gift domain.StarGift
var attrsJSON, thumbsJSON string
if err := row.Scan(
&gift.ID, &gift.RevisionID, &gift.Stars, &gift.ConvertStars, &gift.Title,
&gift.UpgradeStars, &gift.UpgradeTotal, &gift.UpgradeIssued,
&gift.Sticker.ID, &gift.Sticker.AccessHash, &gift.Sticker.FileReference, &gift.Sticker.Date,
&gift.Sticker.MimeType, &gift.Sticker.Size, &gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
); err != nil {
return domain.StarGift{}, err
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document attributes: %w", err)
}
thumbs, err := decodePhotoSizes(thumbsJSON)
if err != nil {
return domain.StarGift{}, fmt.Errorf("decode star gift document thumbs: %w", err)
}
gift.Sticker.Attributes = attrs
gift.Sticker.Thumbs = thumbs
if !gift.Sticker.IsSticker() || gift.Sticker.MimeType != "application/x-tgsticker" {
return domain.StarGift{}, fmt.Errorf("invalid star gift revision %d document %d", gift.RevisionID, gift.Sticker.ID)
}
return gift, nil
}
func (s *StarGiftStore) CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Document.ID <= 0 || !write.Document.IsSticker() || write.Document.MimeType != "application/x-tgsticker" ||
len(write.Animation.JSON) == 0 || len(write.Animation.SHA256) != 32 {
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftInvalid
}
var entry domain.StarGiftCatalogEntry
err := withTx(ctx, s.db, "create star gift catalog revision", func(tx pgx.Tx) error {
giftID := write.GiftID
var revisionID int64
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_revision_id_seq')`).Scan(&revisionID); err != nil {
return fmt.Errorf("allocate star gift revision id: %w", err)
}
revision := 1
if giftID == 0 {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('star_gift_catalog', 0))`); err != nil {
return fmt.Errorf("lock star gift catalog capacity: %w", err)
}
var catalogCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_catalog`).Scan(&catalogCount); err != nil {
return fmt.Errorf("count star gift catalog: %w", err)
}
if catalogCount >= domain.MaxStarGiftCatalogSize {
return domain.ErrStarGiftCatalogFull
}
if err := tx.QueryRow(ctx, `SELECT nextval('star_gift_catalog_gift_id_seq')`).Scan(&giftID); err != nil {
return fmt.Errorf("allocate star gift id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog (gift_id, active_revision_id, enabled, sort_order)
VALUES ($1,$2,$3,$4)`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
return fmt.Errorf("insert star gift catalog: %w", err)
}
} else {
var ignored int64
if err := tx.QueryRow(ctx, `
SELECT active_revision_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, giftID).Scan(&ignored); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock star gift catalog: %w", err)
}
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(revision), 0) + 1
FROM star_gift_catalog_revisions
WHERE gift_id = $1`, giftID).Scan(&revision); err != nil {
return fmt.Errorf("lock star gift catalog: %w", err)
}
}
media := NewMediaStore(tx)
if err := media.PutDocument(ctx, write.Document); err != nil {
return fmt.Errorf("put star gift document: %w", err)
}
if err := media.PutFileBlob(ctx, write.Blob); err != nil {
return fmt.Errorf("put star gift blob: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_catalog_revisions (
id, gift_id, revision, title, stars, convert_stars, document_id,
animation_json, animation_sha256, source_name, source_format,
width, height, frame_rate, in_point, out_point, created_by, command_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
revisionID, giftID, revision, write.Title, write.Stars, write.ConvertStars, write.Document.ID,
string(write.Animation.JSON), write.Animation.SHA256, write.Animation.SourceName, string(write.Animation.SourceFormat),
write.Animation.Width, write.Animation.Height, write.Animation.FrameRate, write.Animation.InPoint, write.Animation.OutPoint,
write.Actor, write.CommandID,
); err != nil {
return fmt.Errorf("insert star gift revision: %w", err)
}
if write.GiftID != 0 {
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog
SET active_revision_id=$2, enabled=$3, sort_order=$4, updated_at=now()
WHERE gift_id=$1`, giftID, revisionID, write.Enabled, write.SortOrder); err != nil {
return fmt.Errorf("activate star gift revision: %w", err)
}
}
write.GiftID = giftID
var err error
entry, err = catalogEntryByID(ctx, tx, giftID)
return err
})
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
return entry, nil
}
func (s *StarGiftStore) SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET enabled=$2, updated_at=now()
WHERE gift_id=$1 AND enabled IS DISTINCT FROM $2`, giftID, enabled)
if err != nil {
return false, fmt.Errorf("set star gift enabled: %w", err)
}
if tag.RowsAffected() > 0 {
return true, nil
}
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
return false, fmt.Errorf("check star gift enabled target: %w", err)
}
if !exists {
return false, domain.ErrStarGiftNotFound
}
return false, nil
}
func (s *StarGiftStore) SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE star_gift_catalog SET sort_order=$2, updated_at=now()
WHERE gift_id=$1 AND sort_order IS DISTINCT FROM $2`, giftID, sortOrder)
if err != nil {
return false, fmt.Errorf("set star gift sort order: %w", err)
}
if tag.RowsAffected() > 0 {
return true, nil
}
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM star_gift_catalog WHERE gift_id=$1)`, giftID).Scan(&exists); err != nil {
return false, fmt.Errorf("check star gift sort target: %w", err)
}
if !exists {
return false, domain.ErrStarGiftNotFound
}
return false, nil
}
func (s *StarGiftStore) AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error) {
var raw []byte
err := s.db.QueryRow(ctx, `
SELECT r.animation_json::text
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
WHERE c.gift_id=$1`, giftID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get star gift animation: %w", err)
}
return raw, true, nil
}
func catalogEntryByID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (domain.StarGiftCatalogEntry, error) {
row := db.QueryRow(ctx, `
SELECT c.gift_id, r.id, r.stars, r.convert_stars, r.title,
COALESCE(cr.upgrade_stars, 0), COALESCE(cr.supply_total, 0), COALESCE(cr.issued, 0),
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text,
c.enabled, c.sort_order, r.revision, r.source_name, r.source_format,
r.animation_sha256, r.width, r.height, r.frame_rate, r.created_by, c.updated_at,
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id=c.gift_id)
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id=c.active_revision_id
LEFT JOIN star_gift_collectible_revisions cr ON cr.id=c.collectible_revision_id AND cr.status='published'
JOIN documents d ON d.id=r.document_id
WHERE c.gift_id=$1`, giftID)
var entry domain.StarGiftCatalogEntry
var attrsJSON, thumbsJSON, sourceFormat string
if err := row.Scan(
&entry.Gift.ID, &entry.Gift.RevisionID, &entry.Gift.Stars, &entry.Gift.ConvertStars, &entry.Gift.Title,
&entry.Gift.UpgradeStars, &entry.Gift.UpgradeTotal, &entry.Gift.UpgradeIssued,
&entry.Gift.Sticker.ID, &entry.Gift.Sticker.AccessHash, &entry.Gift.Sticker.FileReference, &entry.Gift.Sticker.Date,
&entry.Gift.Sticker.MimeType, &entry.Gift.Sticker.Size, &entry.Gift.Sticker.DCID, &attrsJSON, &thumbsJSON,
&entry.Enabled, &entry.SortOrder, &entry.Revision, &entry.SourceName, &sourceFormat,
&entry.AnimationSHA, &entry.Width, &entry.Height, &entry.FrameRate, &entry.CreatedBy, &entry.UpdatedAt,
&entry.ReceivedCount,
); err != nil {
return domain.StarGiftCatalogEntry{}, err
}
attrs, err := decodeDocumentAttributes(attrsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
thumbs, err := decodePhotoSizes(thumbsJSON)
if err != nil {
return domain.StarGiftCatalogEntry{}, err
}
entry.Gift.Sticker.Attributes = attrs
entry.Gift.Sticker.Thumbs = thumbs
entry.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
entry.AnimationSize = entry.Gift.Sticker.Size
return entry, nil
}
func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
if !validSavedStarGift(gift) {
return 0, domain.ErrStarGiftInvalid
@ -30,14 +315,14 @@ func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (
WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id
)
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message)
SELECT next_id.id, $1,$2,$3,$4,$5,
CASE WHEN $1 = 'channel' AND $6::bigint = 0 THEN next_id.id ELSE $6::bigint END,
$7,$8,$9,false,$10,$11
INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, catalog_revision_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, prepaid_upgrade_stars, message)
SELECT next_id.id, $1,$2,$3,$4,$5,$6,
CASE WHEN $1 = 'channel' AND $7::bigint = 0 THEN next_id.id ELSE $7::bigint END,
$8,$9,$10,false,$11,$12,$13
FROM next_id
RETURNING id`,
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.MsgID, gift.SavedID, gift.Date,
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.Message).Scan(&id)
string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.RevisionID, gift.MsgID, gift.SavedID, gift.Date,
gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.PrepaidUpgradeStars, gift.Message).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create star gift: %w", err)
}
@ -45,38 +330,80 @@ RETURNING id`,
}
func (s *StarGiftStore) ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
return s.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
})
}
func (s *StarGiftStore) ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
if !validStarGiftOwner(owner) {
return domain.SavedStarGiftPage{}, nil
}
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
limit = domain.MaxSavedStarGiftsLimit
}
// 总数(未转换 + 可选 excludeUnsaved 过滤)。
countQuery := `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted`
if excludeUnsaved {
countQuery += ` AND NOT unsaved`
joins := `
JOIN star_gift_catalog c ON c.gift_id = p.gift_id
LEFT JOIN star_gift_collectible_revisions acr
ON acr.id = c.collectible_revision_id AND acr.status = 'published'`
conditions := []string{"p.owner_peer_type = $1", "p.owner_peer_id = $2", "NOT p.converted"}
args := []any{string(owner.Type), owner.ID}
if filter.ExcludeUnsaved {
conditions = append(conditions, "NOT p.unsaved")
}
if filter.ExcludeSaved {
conditions = append(conditions, "p.unsaved")
}
if filter.ExcludeUnique {
conditions = append(conditions, "p.unique_gift_id IS NULL")
}
// telesrv ordinary catalog gifts are currently unlimited. Unique gifts are
// collectibles and therefore survive exclude_unlimited.
if filter.ExcludeUnlimited {
conditions = append(conditions, "p.unique_gift_id IS NOT NULL")
}
upgradable := `(p.unique_gift_id IS NULL AND acr.id IS NOT NULL AND acr.upgrade_stars > 0 AND acr.issued < acr.supply_total)`
if filter.ExcludeUpgradable {
conditions = append(conditions, "NOT "+upgradable)
}
if filter.ExcludeUnupgradable {
conditions = append(conditions, upgradable)
}
if filter.CollectionID > 0 {
args = append(args, filter.CollectionID)
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM star_gift_collection_items ci
JOIN star_gift_collections cc ON cc.collection_id = ci.collection_id
WHERE ci.saved_gift_id = p.id AND ci.collection_id = $%d
AND cc.owner_peer_type = p.owner_peer_type AND cc.owner_peer_id = p.owner_peer_id)`, len(args)))
}
where := strings.Join(conditions, " AND ")
countQuery := `SELECT COUNT(*) FROM peer_star_gifts p ` + joins + ` WHERE ` + where
var total int
if err := s.db.QueryRow(ctx, countQuery, string(owner.Type), owner.ID).Scan(&total); err != nil {
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("count star gifts: %w", err)
}
page := domain.SavedStarGiftPage{Count: total}
where := "owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted"
if excludeUnsaved {
where += " AND NOT unsaved"
}
args := []any{string(owner.Type), owner.ID, limit + 1}
if cursor, ok := domain.DecodeStarGiftCursor(offset); ok {
where += " AND id < $4"
args = append(args, cursor)
where += fmt.Sprintf(" AND p.id < $%d", len(args))
}
args = append(args, limit+1)
limitPlaceholder := len(args)
rows, err := s.db.Query(ctx, `
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
FROM peer_star_gifts
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
FROM peer_star_gifts p `+joins+`
WHERE `+where+`
ORDER BY id DESC
LIMIT $3`, args...)
ORDER BY p.id DESC
LIMIT $`+fmt.Sprint(limitPlaceholder), args...)
if err != nil {
return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err)
}
@ -100,14 +427,73 @@ LIMIT $3`, args...)
return page, nil
}
func (s *StarGiftStore) ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
if len(refs) == 0 {
return []int64{}, nil
}
values := make([]int64, 0, len(refs))
seenValues := make(map[int64]struct{}, len(refs))
column := "msg_id"
for _, ref := range refs {
if ref.Owner != owner || !ref.Valid() {
return nil, domain.ErrStarGiftNotFound
}
value := int64(ref.MsgID)
if owner.Type == domain.PeerTypeChannel {
column = "saved_id"
value = ref.SavedID
}
if _, duplicate := seenValues[value]; duplicate {
return nil, domain.ErrStarGiftCollectibleInvalid
}
seenValues[value] = struct{}{}
values = append(values, value)
}
rows, err := s.db.Query(ctx, `SELECT `+column+`::bigint, id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND `+column+`::bigint=ANY($3::bigint[])`, string(owner.Type), owner.ID, values)
if err != nil {
return nil, fmt.Errorf("resolve saved star gifts: %w", err)
}
defer rows.Close()
resolved := make(map[int64]int64, len(values))
for rows.Next() {
var value, id int64
if err := rows.Scan(&value, &id); err != nil {
return nil, fmt.Errorf("scan resolved saved star gift: %w", err)
}
resolved[value] = id
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resolved saved star gifts: %w", err)
}
out := make([]int64, 0, len(values))
for _, value := range values {
id := resolved[value]
if id == 0 {
return nil, domain.ErrStarGiftNotFound
}
out = append(out, id)
}
return out, nil
}
func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
if !ref.Valid() {
return domain.SavedStarGift{}, false, nil
}
where, args := savedStarGiftRefWhere(ref)
row := s.db.QueryRow(ctx, `
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
FROM peer_star_gifts
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
FROM peer_star_gifts p
WHERE `+where, args...)
g, err := scanSavedStarGift(row)
if err != nil {
@ -151,10 +537,19 @@ func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarG
}
out := domain.SavedStarGift{}
err := withTx(ctx, s.db, "convert star gift", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
return fmt.Errorf("lock star gift owner collections: %w", err)
}
where, args := savedStarGiftRefWhere(ref)
row := tx.QueryRow(ctx, `
SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message
FROM peer_star_gifts
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
FROM peer_star_gifts p
WHERE `+where+` FOR UPDATE`, args...)
g, err := scanSavedStarGift(row)
if err != nil {
@ -166,11 +561,19 @@ WHERE `+where+` FOR UPDATE`, args...)
if g.Converted {
return domain.ErrStarGiftAlreadyConverted
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true WHERE id = $1`, g.ID); err != nil {
if g.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true, pinned_order = 0 WHERE id = $1`, g.ID); err != nil {
return fmt.Errorf("mark star gift converted: %w", err)
}
if err := removeSavedGiftFromCollections(ctx, tx, g.Owner, g.ID); err != nil {
return err
}
g.Converted = true
g.Unsaved = true
g.PinnedOrder = 0
g.CollectionIDs = nil
out = g
return nil
})
@ -183,8 +586,9 @@ WHERE `+where+` FOR UPDATE`, args...)
func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) {
var g domain.SavedStarGift
var ownerType string
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.MsgID, &g.SavedID, &g.Date,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.Message); err != nil {
if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.RevisionID, &g.MsgID, &g.SavedID, &g.Date,
&g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.PrepaidUpgradeStars, &g.Message, &g.UniqueGiftID,
&g.UpgradeMsgID, &g.PinnedOrder, &g.CollectionIDs); err != nil {
return domain.SavedStarGift{}, err
}
g.Owner.Type = domain.PeerType(ownerType)
@ -204,7 +608,7 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
}
func validSavedStarGift(g domain.SavedStarGift) bool {
if g.GiftID == 0 || !validStarGiftOwner(g.Owner) {
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
return false
}
switch g.Owner.Type {

View file

@ -0,0 +1,781 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *StarGiftStore) PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
write.Actor = strings.TrimSpace(write.Actor)
write.CommandID = strings.TrimSpace(write.CommandID)
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
var result domain.StarGiftCollectibleRevision
err := withTx(ctx, s.db, "publish collectible star gift revision", func(tx pgx.Tx) error {
var ignored int64
if err := tx.QueryRow(ctx, `SELECT gift_id FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, write.GiftID).Scan(&ignored); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftNotFound
}
return fmt.Errorf("lock collectible catalog gift: %w", err)
}
var revision int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(revision), 0) + 1 FROM star_gift_collectible_revisions WHERE gift_id=$1`, write.GiftID).Scan(&revision); err != nil {
return fmt.Errorf("allocate collectible revision: %w", err)
}
var revisionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collectible_revisions
(gift_id, revision, upgrade_stars, supply_total, slug_prefix, status, created_by, command_id)
VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)
RETURNING id`, write.GiftID, revision, write.UpgradeStars, write.SupplyTotal, write.SlugPrefix, write.Actor, write.CommandID).Scan(&revisionID); err != nil {
return fmt.Errorf("insert collectible revision: %w", err)
}
media := NewMediaStore(tx)
insertAnimated := func(table string, attributes []domain.StarGiftCollectibleAttribute) error {
for _, attribute := range attributes {
if err := media.PutDocument(ctx, *attribute.Document); err != nil {
return fmt.Errorf("put collectible %s document: %w", attribute.Kind, err)
}
if err := media.PutFileBlob(ctx, *attribute.Blob); err != nil {
return fmt.Errorf("put collectible %s blob: %w", attribute.Kind, err)
}
animation := attribute.Animation
query := fmt.Sprintf(`
INSERT INTO %s
(collectible_revision_id, name, document_id, animation_json, animation_sha256,
source_name, source_format, width, height, frame_rate, in_point, out_point,
rarity_permille, sort_order)
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, table)
if _, err := tx.Exec(ctx, query, revisionID, strings.TrimSpace(attribute.Name), attribute.Document.ID,
string(animation.JSON), animation.SHA256, animation.SourceName, string(animation.SourceFormat),
animation.Width, animation.Height, animation.FrameRate, animation.InPoint, animation.OutPoint,
attribute.RarityPermille, attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible %s attribute: %w", attribute.Kind, err)
}
}
return nil
}
if err := insertAnimated("star_gift_collectible_models", write.Models); err != nil {
return err
}
if err := insertAnimated("star_gift_collectible_patterns", write.Patterns); err != nil {
return err
}
for _, attribute := range write.Backdrops {
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_collectible_backdrops
(collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, revisionID, strings.TrimSpace(attribute.Name), attribute.BackdropID,
attribute.CenterColor, attribute.EdgeColor, attribute.PatternColor, attribute.TextColor,
attribute.RarityPermille, attribute.SortOrder); err != nil {
return fmt.Errorf("insert collectible backdrop: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collectible_revisions SET status='published', published_at=now() WHERE id=$1`, revisionID); err != nil {
return fmt.Errorf("publish collectible revision: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE gift_id=$1`, write.GiftID, revisionID); err != nil {
return fmt.Errorf("activate collectible revision: %w", err)
}
var err error
result, err = collectibleRevisionByID(ctx, tx, revisionID)
return err
})
return result, err
}
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
var revisionID int64
err := s.db.QueryRow(ctx, `
SELECT collectible_revision_id FROM star_gift_catalog
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCollectibleRevision{}, false, nil
}
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
}
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
if err != nil {
return domain.StarGiftCollectibleRevision{}, false, err
}
return revision, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
if len(giftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT c.gift_id, r.upgrade_stars, r.supply_total, r.issued
FROM star_gift_catalog c
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
if err != nil {
return nil, fmt.Errorf("list collectible availability: %w", err)
}
defer rows.Close()
for rows.Next() {
var giftID int64
var availability domain.StarGiftCollectibleAvailability
if err := rows.Scan(&giftID, &availability.UpgradeStars, &availability.SupplyTotal, &availability.Issued); err != nil {
return nil, fmt.Errorf("scan collectible availability: %w", err)
}
out[giftID] = availability
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list collectible availability rows: %w", err)
}
return out, nil
}
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
var publishedAt pgtype.Timestamptz
if err := db.QueryRow(ctx, `
SELECT id, gift_id, revision, upgrade_stars, supply_total, issued, slug_prefix, status,
created_by, created_at, published_at
FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
&revision.ID, &revision.GiftID, &revision.Revision, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status, &revision.CreatedBy, &revision.CreatedAt, &publishedAt,
); err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("get collectible revision: %w", err)
}
revision.Published = status == "published"
if publishedAt.Valid {
revision.PublishedAt = publishedAt.Time
}
var err error
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
return domain.StarGiftCollectibleRevision{}, err
}
return revision, nil
}
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, domain.ErrStarGiftCollectibleInvalid
}
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_permille, a.sort_order,
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text, d.thumbs::text
FROM %s a JOIN documents d ON d.id=a.document_id
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`, table), revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Document: &domain.Document{}, Animation: &domain.StarGiftAnimation{}}
var attrsJSON, thumbsJSON, sourceFormat string
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.RarityPermille, &attribute.SortOrder,
&attribute.Animation.JSON, &attribute.Animation.SHA256, &attribute.Animation.SourceName, &sourceFormat,
&attribute.Animation.Width, &attribute.Animation.Height, &attribute.Animation.FrameRate, &attribute.Animation.InPoint, &attribute.Animation.OutPoint,
&attribute.Document.ID, &attribute.Document.AccessHash, &attribute.Document.FileReference, &attribute.Document.Date,
&attribute.Document.MimeType, &attribute.Document.Size, &attribute.Document.DCID, &attrsJSON, &thumbsJSON); err != nil {
return nil, err
}
attribute.Animation.SourceFormat = domain.StarGiftAnimationFormat(sourceFormat)
if attribute.Document.Attributes, err = decodeDocumentAttributes(attrsJSON); err != nil {
return nil, err
}
if attribute.Document.Thumbs, err = decodePhotoSizes(thumbsJSON); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
rows, err := db.Query(ctx, `
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
text_color, rarity_permille, sort_order
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
if err != nil {
return nil, fmt.Errorf("list collectible backdrops: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollectibleAttribute, 0)
for rows.Next() {
attribute := domain.StarGiftCollectibleAttribute{Kind: domain.StarGiftCollectibleBackdrop}
if err := rows.Scan(&attribute.ID, &attribute.CollectibleRevisionID, &attribute.Name, &attribute.BackdropID,
&attribute.CenterColor, &attribute.EdgeColor, &attribute.PatternColor, &attribute.TextColor,
&attribute.RarityPermille, &attribute.SortOrder); err != nil {
return nil, err
}
out = append(out, attribute)
}
return out, rows.Err()
}
func (s *StarGiftStore) CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
table := "star_gift_collectible_models"
if kind == domain.StarGiftCollectiblePattern {
table = "star_gift_collectible_patterns"
} else if kind != domain.StarGiftCollectibleModel {
return nil, false, nil
}
var raw []byte
err := s.db.QueryRow(ctx, fmt.Sprintf(`
SELECT a.animation_json::text FROM %s a
JOIN star_gift_catalog c ON c.collectible_revision_id=a.collectible_revision_id
WHERE c.gift_id=$1 AND a.id=$2`, table), giftID, attributeID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get collectible animation: %w", err)
}
return raw, true, nil
}
func (s *StarGiftStore) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.slug=$1", strings.ToLower(strings.TrimSpace(slug)))
}
func (s *StarGiftStore) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
return s.uniqueByPredicate(ctx, "u.id=$1", uniqueGiftID)
}
func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
if len(uniqueGiftIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, uniqueStarGiftQuery("u.id=ANY($1::bigint[])"), uniqueGiftIDs)
if err != nil {
return nil, fmt.Errorf("list unique star gifts: %w", err)
}
defer rows.Close()
for rows.Next() {
unique, err := scanUniqueStarGift(rows)
if err != nil {
return nil, err
}
out[unique.ID] = unique
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unique star gifts: %w", err)
}
return out, nil
}
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
unique, err := scanUniqueStarGift(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UniqueStarGift{}, false, nil
}
return domain.UniqueStarGift{}, false, err
}
return unique, true, nil
}
func uniqueStarGiftQuery(predicate string) string {
return fmt.Sprintf(`
SELECT u.id, u.gift_id, u.collectible_revision_id, u.source_saved_gift_id, u.title, u.slug, u.num,
u.owner_peer_type, u.owner_peer_id, u.keep_original_details, u.created_at,
r.issued, r.supply_total, sg.from_user_id, sg.owner_peer_type, sg.owner_peer_id,
sg.gift_date, sg.message, sg.name_hidden,
m.id, m.name, m.rarity_permille, md.id, md.access_hash, md.file_reference, md.date,
md.mime_type, md.size, md.dc_id, md.attributes::text, md.thumbs::text,
p.id, p.name, p.rarity_permille, pd.id, pd.access_hash, pd.file_reference, pd.date,
pd.mime_type, pd.size, pd.dc_id, pd.attributes::text, pd.thumbs::text,
b.id, b.name, b.backdrop_id, b.center_color, b.edge_color, b.pattern_color, b.text_color, b.rarity_permille
FROM unique_star_gifts u
JOIN star_gift_collectible_revisions r ON r.id=u.collectible_revision_id
JOIN star_gift_collectible_models m ON m.id=u.model_attribute_id
JOIN documents md ON md.id=m.document_id
JOIN star_gift_collectible_patterns p ON p.id=u.pattern_attribute_id
JOIN documents pd ON pd.id=p.document_id
JOIN star_gift_collectible_backdrops b ON b.id=u.backdrop_attribute_id
JOIN peer_star_gifts sg ON sg.id=u.source_saved_gift_id
WHERE %s`, predicate)
}
func scanUniqueStarGift(row rowScanner) (domain.UniqueStarGift, error) {
var unique domain.UniqueStarGift
var ownerType, originalOwnerType string
unique.Model.Kind = domain.StarGiftCollectibleModel
unique.Pattern.Kind = domain.StarGiftCollectiblePattern
unique.Backdrop.Kind = domain.StarGiftCollectibleBackdrop
unique.Model.Document = &domain.Document{}
unique.Pattern.Document = &domain.Document{}
var modelAttrs, modelThumbs, patternAttrs, patternThumbs string
if err := row.Scan(&unique.ID, &unique.GiftID, &unique.CollectibleRevisionID, &unique.SourceSavedGiftID,
&unique.Title, &unique.Slug, &unique.Num, &ownerType, &unique.Owner.ID, &unique.KeepOriginalDetails,
&unique.CreatedAt, &unique.AvailabilityIssued, &unique.AvailabilityTotal,
&unique.OriginalFromUserID, &originalOwnerType, &unique.OriginalOwner.ID, &unique.OriginalDate,
&unique.OriginalMessage, &unique.OriginalNameHidden,
&unique.Model.ID, &unique.Model.Name, &unique.Model.RarityPermille,
&unique.Model.Document.ID, &unique.Model.Document.AccessHash, &unique.Model.Document.FileReference,
&unique.Model.Document.Date, &unique.Model.Document.MimeType, &unique.Model.Document.Size,
&unique.Model.Document.DCID, &modelAttrs, &modelThumbs,
&unique.Pattern.ID, &unique.Pattern.Name, &unique.Pattern.RarityPermille,
&unique.Pattern.Document.ID, &unique.Pattern.Document.AccessHash, &unique.Pattern.Document.FileReference,
&unique.Pattern.Document.Date, &unique.Pattern.Document.MimeType, &unique.Pattern.Document.Size,
&unique.Pattern.Document.DCID, &patternAttrs, &patternThumbs,
&unique.Backdrop.ID, &unique.Backdrop.Name, &unique.Backdrop.BackdropID, &unique.Backdrop.CenterColor,
&unique.Backdrop.EdgeColor, &unique.Backdrop.PatternColor, &unique.Backdrop.TextColor, &unique.Backdrop.RarityPermille); err != nil {
return domain.UniqueStarGift{}, fmt.Errorf("get unique star gift: %w", err)
}
unique.Owner.Type = domain.PeerType(ownerType)
unique.OriginalOwner.Type = domain.PeerType(originalOwnerType)
unique.Model.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Pattern.CollectibleRevisionID = unique.CollectibleRevisionID
unique.Backdrop.CollectibleRevisionID = unique.CollectibleRevisionID
var err error
if unique.Model.Document.Attributes, err = decodeDocumentAttributes(modelAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Model.Document.Thumbs, err = decodePhotoSizes(modelThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Attributes, err = decodeDocumentAttributes(patternAttrs); err != nil {
return domain.UniqueStarGift{}, err
}
if unique.Pattern.Document.Thumbs, err = decodePhotoSizes(patternThumbs); err != nil {
return domain.UniqueStarGift{}, err
}
return unique, nil
}
func (s *StarGiftStore) ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
rows, err := s.db.Query(ctx, `
SELECT c.collection_id, c.title, c.hash, c.sort_order, c.created_at, c.updated_at, i.saved_gift_id
FROM star_gift_collections c
LEFT JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2
ORDER BY c.sort_order, c.collection_id, i.sort_order, i.saved_gift_id`, string(owner.Type), owner.ID)
if err != nil {
return nil, fmt.Errorf("list star gift collections: %w", err)
}
defer rows.Close()
out := make([]domain.StarGiftCollection, 0)
index := make(map[int]int)
for rows.Next() {
var collection domain.StarGiftCollection
var giftID pgtype.Int8
if err := rows.Scan(&collection.CollectionID, &collection.Title, &collection.Hash, &collection.SortOrder,
&collection.CreatedAt, &collection.UpdatedAt, &giftID); err != nil {
return nil, err
}
position, ok := index[collection.CollectionID]
if !ok {
collection.Owner = owner
position = len(out)
index[collection.CollectionID] = position
out = append(out, collection)
}
if giftID.Valid {
out[position].GiftIDs = append(out[position].GiftIDs, giftID.Int64)
}
}
return out, rows.Err()
}
func (s *StarGiftStore) CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
title = strings.TrimSpace(title)
if !validPostgresStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
}
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "create star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2`, string(owner.Type), owner.ID).Scan(&count); err != nil {
return err
}
if count >= domain.MaxStarGiftCollectionsPerPeer {
return domain.ErrStarGiftCollectionsFull
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
result = domain.StarGiftCollection{Owner: owner, Title: title, GiftIDs: ids, SortOrder: count}
result.Hash = domain.StarGiftCollectionHash(title, ids)
if err := tx.QueryRow(ctx, `
INSERT INTO star_gift_collections(owner_peer_type, owner_peer_id, title, sort_order, hash)
VALUES ($1,$2,$3,$4,$5) RETURNING collection_id, created_at, updated_at`, string(owner.Type), owner.ID,
title, count, result.Hash).Scan(&result.CollectionID, &result.CreatedAt, &result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, result.CollectionID, ids)
})
return result, err
}
func (s *StarGiftStore) UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
var result domain.StarGiftCollection
err := withTx(ctx, s.db, "update star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
if err := tx.QueryRow(ctx, `
SELECT title, hash, sort_order, created_at, updated_at FROM star_gift_collections
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 FOR UPDATE`, string(owner.Type), owner.ID, collectionID).Scan(
&result.Title, &result.Hash, &result.SortOrder, &result.CreatedAt, &result.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarGiftCollectionNotFound
}
return err
}
result.Owner = owner
result.CollectionID = collectionID
rows, err := tx.Query(ctx, `SELECT saved_gift_id FROM star_gift_collection_items WHERE collection_id=$1 ORDER BY sort_order, saved_gift_id`, collectionID)
if err != nil {
return err
}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
result.GiftIDs = append(result.GiftIDs, id)
}
rows.Close()
if patch.Title != nil {
title := strings.TrimSpace(*patch.Title)
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
return domain.ErrStarGiftCollectibleInvalid
}
result.Title = title
}
deleted := make(map[int64]struct{}, len(patch.DeleteIDs))
for _, id := range patch.DeleteIDs {
deleted[id] = struct{}{}
}
next := make([]int64, 0, len(result.GiftIDs)+len(patch.AddIDs))
for _, id := range result.GiftIDs {
if _, ok := deleted[id]; !ok {
next = append(next, id)
}
}
add, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.AddIDs)
if err != nil {
return err
}
next = appendUniquePostgresIDs(next, add...)
if patch.Order != nil {
order, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, patch.Order)
if err != nil || !samePostgresIDSet(order, next) {
return domain.ErrStarGiftCollectibleInvalid
}
next = order
}
if len(next) > domain.MaxStarGiftCollectionItems {
return domain.ErrStarGiftCollectibleInvalid
}
result.GiftIDs = next
result.Hash = domain.StarGiftCollectionHash(result.Title, result.GiftIDs)
if err := tx.QueryRow(ctx, `
UPDATE star_gift_collections SET title=$4, hash=$5, updated_at=now()
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3 RETURNING updated_at`,
string(owner.Type), owner.ID, collectionID, result.Title, result.Hash).Scan(&result.UpdatedAt); err != nil {
return err
}
return replaceCollectionItems(ctx, tx, collectionID, result.GiftIDs)
})
return result, err
}
func (s *StarGiftStore) DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error) {
var changed bool
err := withTx(ctx, s.db, "delete star gift collection", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, collectionID)
if err != nil {
return err
}
changed = tag.RowsAffected() > 0
if changed {
_, err = tx.Exec(ctx, `
WITH ordered AS (
SELECT collection_id, row_number() OVER (ORDER BY sort_order, collection_id) - 1 AS next_order
FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2
)
UPDATE star_gift_collections c SET sort_order=o.next_order, updated_at=now()
FROM ordered o WHERE c.collection_id=o.collection_id`, string(owner.Type), owner.ID)
}
return err
})
return changed, err
}
func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error {
return withTx(ctx, s.db, "reorder star gift collections", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT collection_id FROM star_gift_collections WHERE owner_peer_type=$1 AND owner_peer_id=$2 FOR UPDATE`, string(owner.Type), owner.ID)
if err != nil {
return err
}
existing := make([]int, 0)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
existing = append(existing, id)
}
rows.Close()
if !samePostgresIntSet(existing, collectionIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
for order, id := range collectionIDs {
if _, err := tx.Exec(ctx, `UPDATE star_gift_collections SET sort_order=$4, updated_at=now() WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND collection_id=$3`, string(owner.Type), owner.ID, id, order); err != nil {
return err
}
}
return nil
})
}
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
}
ids, err := validatePostgresCollectionGiftIDs(ctx, tx, owner, savedGiftIDs)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
return err
}
}
return nil
})
}
func validatePostgresCollectionGiftIDs(ctx context.Context, db sqlcgen.DBTX, owner domain.Peer, ids []int64) ([]int64, error) {
ids = dedupePostgresIDs(ids)
if len(ids) > domain.MaxStarGiftCollectionItems {
return nil, domain.ErrStarGiftCollectibleInvalid
}
if len(ids) == 0 {
return []int64{}, nil
}
rows, err := db.Query(ctx, `
SELECT id FROM peer_star_gifts
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND NOT converted AND id=ANY($3::bigint[])
FOR UPDATE`, string(owner.Type), owner.ID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
found := make(map[int64]struct{}, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
found[id] = struct{}{}
}
if len(found) != len(ids) {
return nil, domain.ErrStarGiftNotFound
}
return ids, rows.Err()
}
// removeSavedGiftFromCollections runs under the owner advisory lock. It removes
// terminal gifts and updates every affected collection hash in bounded batches,
// so getStarGiftCollections cannot return NotModified for changed membership.
func removeSavedGiftFromCollections(ctx context.Context, tx pgx.Tx, owner domain.Peer, savedGiftID int64) error {
rows, err := tx.Query(ctx, `
SELECT c.collection_id, c.title
FROM star_gift_collections c
JOIN star_gift_collection_items i ON i.collection_id=c.collection_id
WHERE c.owner_peer_type=$1 AND c.owner_peer_id=$2 AND i.saved_gift_id=$3
ORDER BY c.collection_id
FOR UPDATE OF c`, string(owner.Type), owner.ID, savedGiftID)
if err != nil {
return fmt.Errorf("lock converted gift collections: %w", err)
}
titles := make(map[int]string)
ids := make([]int, 0)
for rows.Next() {
var id int
var title string
if err := rows.Scan(&id, &title); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
titles[id] = title
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if len(ids) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE saved_gift_id=$1`, savedGiftID); err != nil {
return fmt.Errorf("remove converted gift collection memberships: %w", err)
}
memberships := make(map[int][]int64, len(ids))
itemRows, err := tx.Query(ctx, `
SELECT collection_id, saved_gift_id
FROM star_gift_collection_items
WHERE collection_id=ANY($1::integer[])
ORDER BY collection_id, sort_order, saved_gift_id`, ids)
if err != nil {
return fmt.Errorf("list remaining collection memberships: %w", err)
}
for itemRows.Next() {
var collectionID int
var giftID int64
if err := itemRows.Scan(&collectionID, &giftID); err != nil {
itemRows.Close()
return err
}
memberships[collectionID] = append(memberships[collectionID], giftID)
}
if err := itemRows.Err(); err != nil {
itemRows.Close()
return err
}
itemRows.Close()
hashes := make([]int64, len(ids))
for i, collectionID := range ids {
hashes[i] = domain.StarGiftCollectionHash(titles[collectionID], memberships[collectionID])
}
if _, err := tx.Exec(ctx, `
UPDATE star_gift_collections c SET hash=x.hash, updated_at=now()
FROM unnest($1::integer[], $2::bigint[]) AS x(collection_id, hash)
WHERE c.collection_id=x.collection_id`, ids, hashes); err != nil {
return fmt.Errorf("refresh converted gift collection hashes: %w", err)
}
return nil
}
func replaceCollectionItems(ctx context.Context, tx pgx.Tx, collectionID int, ids []int64) error {
if _, err := tx.Exec(ctx, `DELETE FROM star_gift_collection_items WHERE collection_id=$1`, collectionID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_collection_items(collection_id, saved_gift_id, sort_order) VALUES ($1,$2,$3)`, collectionID, id, order); err != nil {
return err
}
}
return nil
}
func validPostgresStarGiftOwner(owner domain.Peer) bool {
return owner.ID > 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
}
func starGiftCollectionLockKey(owner domain.Peer) string {
return fmt.Sprintf("star_gift_collection:%s:%d", owner.Type, owner.ID)
}
func dedupePostgresIDs(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
return out
}
func appendUniquePostgresIDs(dst []int64, values ...int64) []int64 {
seen := make(map[int64]struct{}, len(dst)+len(values))
for _, id := range dst {
seen[id] = struct{}{}
}
for _, id := range values {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
dst = append(dst, id)
}
}
return dst
}
func samePostgresIDSet(a, b []int64) bool {
if len(a) != len(b) {
return false
}
a = append([]int64(nil), a...)
b = append([]int64(nil), b...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func samePostgresIntSet(a, b []int) bool {
if len(a) != len(b) {
return false
}
seen := make(map[int]struct{}, len(a))
for _, id := range a {
seen[id] = struct{}{}
}
for _, id := range b {
if _, ok := seen[id]; !ok {
return false
}
delete(seen, id)
}
return len(seen) == 0
}

View file

@ -0,0 +1,429 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1778"+suffix+"41", "CollectibleSender", "")
owner := createTestUser(t, ctx, users, "+1778"+suffix+"42", "CollectibleOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Comet", Stars: 50, ConvertStars: 25, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "gift.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "gift"),
Animation: collectibleTestAnimation("gift.tgs"),
Actor: "integration", CommandID: "catalog-" + suffix,
})
if err != nil {
t.Fatalf("create collectible catalog gift: %v", err)
}
poolRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "comet-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Aurora", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs"),
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+2, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs"),
}},
Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Midnight", BackdropID: 1,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityPermille: 1000,
}},
Actor: "integration", CommandID: "collectibles-" + suffix,
})
if err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if !poolRevision.Published || poolRevision.Issued != 0 || len(poolRevision.Models) != 1 || len(poolRevision.Patterns) != 1 || len(poolRevision.Backdrops) != 1 {
t.Fatalf("published pool = %+v", poolRevision)
}
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
if err != nil {
t.Fatalf("collectible availability: %v", err)
}
if got, ok := availability[entry.Gift.ID]; !ok || got.UpgradeStars != 100 || got.SupplyTotal != 10 || got.Issued != 0 {
t.Fatalf("collectible availability = %+v, want active published pool", availability)
}
if _, ok := availability[entry.Gift.ID+1]; ok {
t.Fatalf("unknown gift must not have collectible availability: %+v", availability)
}
if _, err := pool.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued WHERE id=$1`, poolRevision.ID); err == nil {
t.Fatal("published collectible revision accepted a non-advancing issuance update")
}
var guardedIssued int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&guardedIssued); err != nil || guardedIssued != 0 {
t.Fatalf("issued after rejected manual update = %d err %v, want 0", guardedIssued, err)
}
savedID, err := gifts.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700001, Date: 1700001000, ConvertStars: 25, Message: "original",
})
if err != nil {
t.Fatalf("create saved gift: %v", err)
}
stars := NewStarsStore(pool)
if _, _, err := stars.EnsureGrant(ctx, owner.ID, 1000, 1700001001); err != nil {
t.Fatalf("grant upgrade stars: %v", err)
}
messages := NewMessageStore(pool)
upgrades := NewStarGiftUpgradeStore(pool, messages)
req := domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700001},
KeepOriginalDetails: true, ChargeStars: 100, FormID: 991,
CommandKey: "paid-" + suffix, Date: 1700001002,
}
upgraded, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("upgrade star gift: %v", err)
}
if upgraded.Duplicate || upgraded.Unique.Num != 1 || upgraded.Unique.Slug != "comet-"+suffix+"-1" ||
upgraded.Unique.Model.Name != "Aurora" || upgraded.Unique.Pattern.Name != "Orbit" ||
upgraded.Unique.Backdrop.Name != "Midnight" || upgraded.Balance.Balance != 900 ||
upgraded.Saved.ID != savedID || upgraded.Saved.UniqueGiftID != upgraded.Unique.ID || upgraded.Saved.UpgradeMsgID <= 0 {
t.Fatalf("upgrade result = %+v", upgraded)
}
ownerMessage := upgraded.Send.RecipientMessage
if ownerMessage.OwnerUserID != owner.ID || ownerMessage.Pts <= 0 || ownerMessage.Media == nil ||
ownerMessage.Media.ServiceAction == nil || ownerMessage.Media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique ||
ownerMessage.Media.ServiceAction.StarGiftUnique == nil || ownerMessage.Media.ServiceAction.StarGiftUnique.Gift.ID != upgraded.Unique.ID {
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
}
var (
issued, uniqueCount, commandCount int
reason string
)
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE source_saved_gift_id=$1`, savedID).Scan(&uniqueCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_upgrade_commands WHERE source_saved_gift_id=$1`, savedID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil {
t.Fatal(err)
}
if issued != 1 || uniqueCount != 1 || commandCount != 1 || reason != string(domain.StarsReasonGiftUpgrade) {
t.Fatalf("durable aggregate issued=%d unique=%d command=%d reason=%q", issued, uniqueCount, commandCount, reason)
}
replayed, err := upgrades.UpgradeStarGift(ctx, req)
if err != nil {
t.Fatalf("replay upgrade: %v", err)
}
if !replayed.Duplicate || replayed.Unique.ID != upgraded.Unique.ID || replayed.Balance.Balance != 900 {
t.Fatalf("replayed upgrade = %+v", replayed)
}
conflictingReplay := req
conflictingReplay.KeepOriginalDetails = false
if _, err := upgrades.UpgradeStarGift(ctx, conflictingReplay); err == nil {
t.Fatal("same command key with a changed semantic payload must not replay")
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: req.Ref, ChargeStars: 100, FormID: 992,
CommandKey: "different-" + suffix, Date: 1700001003,
}); !errors.Is(err, domain.ErrStarGiftAlreadyUpgraded) {
t.Fatalf("second logical upgrade err = %v", err)
}
bal, err := stars.GetBalance(ctx, owner.ID)
if err != nil || bal.Balance != 900 {
t.Fatalf("balance after retries = %+v err %v", bal, err)
}
prepaidSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
// A later pool revision may raise the current price; the historical paid
// amount remains an entitlement instead of being compared to that price.
MsgID: 700002, Date: 1700001004, ConvertStars: 25, PrepaidUpgradeStars: 50,
})
if err != nil {
t.Fatalf("create prepaid saved gift: %v", err)
}
prepaid, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700002},
RequirePrepaid: true, CommandKey: "prepaid-" + suffix, Date: 1700001005,
})
if err != nil {
t.Fatalf("free prepaid upgrade: %v", err)
}
if prepaid.Saved.ID != prepaidSavedID || prepaid.Unique.Num != 2 || prepaid.Balance.Balance != 900 ||
prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique == nil ||
!prepaid.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique.PrepaidUpgrade {
t.Fatalf("prepaid upgrade = %+v", prepaid)
}
insufficientSavedID, err := gifts.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700003, Date: 1700001006, ConvertStars: 25,
})
if err != nil {
t.Fatalf("create insufficient saved gift: %v", err)
}
if _, err := stars.Debit(ctx, owner.ID, 850, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777001}, 1700001007, "paid reaction", ""); err != nil {
t.Fatalf("seed isolated paid reaction debit: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: owner.ID, Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003},
ChargeStars: 100, CommandKey: "insufficient-" + suffix, Date: 1700001008,
}); !errors.Is(err, domain.ErrStarsInsufficient) {
t.Fatalf("insufficient upgrade err = %v", err)
}
insufficientSaved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
if err != nil || !found || insufficientSaved.ID != insufficientSavedID || insufficientSaved.UniqueGiftID != 0 {
t.Fatalf("saved gift after rejected upgrade = %+v found %v err %v", insufficientSaved, found, err)
}
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, poolRevision.ID).Scan(&issued); err != nil || issued != 2 {
t.Fatalf("issued after rejected upgrade = %d err %v, want 2", issued, err)
}
if err := pool.QueryRow(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 ORDER BY id DESC LIMIT 1`, owner.ID).Scan(&reason); err != nil || reason != string(domain.StarsReasonReaction) {
t.Fatalf("paid reaction ledger reason after rejected upgrade = %q err %v", reason, err)
}
collection, err := gifts.CreateCollection(ctx, ownerPeer, "Favorites", []int64{savedID})
if err != nil {
t.Fatalf("create unique collection: %v", err)
}
filtered, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{Owner: ownerPeer, CollectionID: collection.CollectionID, Limit: 10})
if err != nil || filtered.Count != 1 || len(filtered.Gifts) != 1 || filtered.Gifts[0].UniqueGiftID != upgraded.Unique.ID {
t.Fatalf("collection filter = %+v err %v", filtered, err)
}
if err := gifts.SetPinned(ctx, ownerPeer, []int64{savedID}); err != nil {
t.Fatalf("pin unique gift: %v", err)
}
pinned, found, err := gifts.GetByRef(ctx, req.Ref)
if err != nil || !found || pinned.PinnedOrder != 1 || len(pinned.CollectionIDs) != 1 || pinned.CollectionIDs[0] != collection.CollectionID {
t.Fatalf("pinned saved gift = %+v found %v err %v", pinned, found, err)
}
concurrentOwner := createTestUser(t, ctx, users, "+1778"+suffix+"43", "ConcurrentOwner", "")
concurrentPeer := domain.Peer{Type: domain.PeerTypeUser, ID: concurrentOwner.ID}
if _, err := gifts.Create(ctx, domain.SavedStarGift{
Owner: concurrentPeer, FromUserID: sender.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID,
MsgID: 700004, Date: 1700001010, ConvertStars: 25,
}); err != nil {
t.Fatalf("create concurrent upgrade target: %v", err)
}
if _, _, err := stars.EnsureGrant(ctx, concurrentOwner.ID, 150, 1700001011); err != nil {
t.Fatalf("grant concurrent balance: %v", err)
}
type concurrentDebitResult struct {
kind string
err error
}
start := make(chan struct{})
results := make(chan concurrentDebitResult, 2)
go func() {
<-start
_, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: concurrentOwner.ID, Ref: domain.SavedStarGiftRef{Owner: concurrentPeer, MsgID: 700004},
ChargeStars: 100, FormID: 993, CommandKey: "concurrent-upgrade-" + suffix, Date: 1700001012,
})
results <- concurrentDebitResult{kind: "gift_upgrade", err: err}
}()
go func() {
<-start
_, err := stars.Debit(ctx, concurrentOwner.ID, 100, domain.StarsReasonReaction,
domain.Peer{Type: domain.PeerTypeChannel, ID: 777002}, 1700001012, "paid reaction", "")
results <- concurrentDebitResult{kind: "paid_reaction", err: err}
}()
close(start)
firstResult, secondResult := <-results, <-results
successes := 0
for _, result := range []concurrentDebitResult{firstResult, secondResult} {
if result.err == nil {
successes++
continue
}
if !errors.Is(result.err, domain.ErrStarsInsufficient) {
t.Fatalf("concurrent %s err = %v, want Stars insufficient for loser", result.kind, result.err)
}
}
if successes != 1 {
t.Fatalf("concurrent debit results = %+v / %+v, want exactly one success", firstResult, secondResult)
}
concurrentBalance, err := stars.GetBalance(ctx, concurrentOwner.ID)
if err != nil || concurrentBalance.Balance != 50 {
t.Fatalf("concurrent balance = %+v err %v, want 50", concurrentBalance, err)
}
reasonRows, err := pool.Query(ctx, `SELECT reason FROM stars_transactions WHERE user_id=$1 AND amount<0 ORDER BY id`, concurrentOwner.ID)
if err != nil {
t.Fatalf("list concurrent debit reasons: %v", err)
}
var debitReasons []string
for reasonRows.Next() {
var got string
if err := reasonRows.Scan(&got); err != nil {
reasonRows.Close()
t.Fatal(err)
}
debitReasons = append(debitReasons, got)
}
if err := reasonRows.Err(); err != nil {
reasonRows.Close()
t.Fatal(err)
}
reasonRows.Close()
if len(debitReasons) != 1 || (debitReasons[0] != string(domain.StarsReasonGiftUpgrade) && debitReasons[0] != string(domain.StarsReasonReaction)) {
t.Fatalf("concurrent debit reasons = %+v, want exactly one isolated business reason", debitReasons)
}
soldOutEntry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Nova", Stars: 25, ConvertStars: 10, Enabled: true,
Document: collectibleTestDocument(baseDocumentID+100, "nova.tgs"),
Blob: collectibleTestBlob(baseDocumentID+100, "nova"), Animation: collectibleTestAnimation("nova.tgs"),
Actor: "integration", CommandID: "soldout-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create sold-out catalog: %v", err)
}
soldOutRevision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: soldOutEntry.Gift.ID, UpgradeStars: 10, SupplyTotal: 1, SlugPrefix: "nova-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleModel, Name: "Nova", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+101, "nova-model.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+101, "nova-model"), Animation: collectibleTestAnimationPtr("nova-model.tgs"),
}},
Patterns: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectiblePattern, Name: "Ray", RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+102, "nova-pattern.tgs"),
Blob: collectibleTestBlobPtr(baseDocumentID+102, "nova-pattern"), Animation: collectibleTestAnimationPtr("nova-pattern.tgs"),
}},
Backdrops: []domain.StarGiftCollectibleAttribute{{
Kind: domain.StarGiftCollectibleBackdrop, Name: "Void", BackdropID: 2,
CenterColor: 0x101010, EdgeColor: 0x202020, PatternColor: 0x303030, TextColor: 0xffffff,
RarityPermille: 1000,
}},
Actor: "integration", CommandID: "soldout-pool-" + suffix,
})
if err != nil {
t.Fatalf("publish sold-out pool: %v", err)
}
soldOutOwner := createTestUser(t, ctx, users, "+1778"+suffix+"44", "SoldOutOwner", "")
soldOutPeer := domain.Peer{Type: domain.PeerTypeUser, ID: soldOutOwner.ID}
for index, msgID := range []int{700010, 700011} {
if _, err := gifts.Create(ctx, domain.SavedStarGift{
Owner: soldOutPeer, FromUserID: sender.ID, GiftID: soldOutEntry.Gift.ID, RevisionID: soldOutEntry.Gift.RevisionID,
MsgID: msgID, Date: 1700001020 + index, ConvertStars: 10,
}); err != nil {
t.Fatalf("create sold-out target %d: %v", msgID, err)
}
}
if _, _, err := stars.EnsureGrant(ctx, soldOutOwner.ID, 100, 1700001022); err != nil {
t.Fatalf("grant sold-out owner balance: %v", err)
}
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700010},
ChargeStars: 10, CommandKey: "soldout-first-" + suffix, Date: 1700001023,
}); err != nil {
t.Fatalf("fill collectible supply: %v", err)
}
balanceBeforeSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
if _, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
UserID: soldOutOwner.ID, Ref: domain.SavedStarGiftRef{Owner: soldOutPeer, MsgID: 700011},
ChargeStars: 10, CommandKey: "soldout-second-" + suffix, Date: 1700001024,
}); !errors.Is(err, domain.ErrStarGiftCollectibleSoldOut) {
t.Fatalf("sold-out upgrade err = %v", err)
}
balanceAfterSoldOut, _ := stars.GetBalance(ctx, soldOutOwner.ID)
var soldOutIssued int
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, soldOutRevision.ID).Scan(&soldOutIssued); err != nil || soldOutIssued != 1 || balanceAfterSoldOut.Balance != balanceBeforeSoldOut.Balance {
t.Fatalf("sold-out state issued=%d balance=%d->%d err=%v", soldOutIssued, balanceBeforeSoldOut.Balance, balanceAfterSoldOut.Balance, err)
}
ordinaryCollection, err := gifts.CreateCollection(ctx, ownerPeer, "Ordinary", []int64{insufficientSavedID})
if err != nil {
t.Fatalf("create ordinary collection: %v", err)
}
converted, err := gifts.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 700003})
if err != nil || !converted.Converted || converted.PinnedOrder != 0 || len(converted.CollectionIDs) != 0 {
t.Fatalf("convert collection member = %+v err %v", converted, err)
}
collections, err := gifts.ListCollections(ctx, ownerPeer)
if err != nil {
t.Fatalf("list collections after conversion: %v", err)
}
foundOrdinary := false
for _, got := range collections {
if got.CollectionID != ordinaryCollection.CollectionID {
continue
}
foundOrdinary = true
if len(got.GiftIDs) != 0 || got.Hash != domain.StarGiftCollectionHash(got.Title, nil) || got.Hash == ordinaryCollection.Hash {
t.Fatalf("ordinary collection after conversion = %+v", got)
}
}
if !foundOrdinary {
t.Fatal("ordinary collection disappeared after member conversion")
}
filteredAfterConvert, err := gifts.ListByOwnerFiltered(ctx, domain.SavedStarGiftFilter{
Owner: ownerPeer, CollectionID: ordinaryCollection.CollectionID, Limit: 10,
})
if err != nil || filteredAfterConvert.Count != 0 || len(filteredAfterConvert.Gifts) != 0 {
t.Fatalf("converted collection filter = %+v err %v, want empty", filteredAfterConvert, err)
}
}
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
TGS: []byte("test"), SHA256: make([]byte, 32), Width: 512, Height: 512, FrameRate: 30, OutPoint: 30,
}
}
func collectibleTestAnimationPtr(name string) *domain.StarGiftAnimation {
animation := collectibleTestAnimation(name)
return &animation
}
func collectibleTestDocument(id int64, name string) domain.Document {
return domain.Document{
ID: id, AccessHash: id + 100, FileReference: []byte("collectible-test"), Date: 1700001000,
MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: name},
},
}
}
func collectibleTestDocumentPtr(id int64, name string) *domain.Document {
document := collectibleTestDocument(id, name)
return &document
}
func collectibleTestBlob(id int64, suffix string) domain.FileBlob {
return domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", id), Backend: domain.MediaBackendLocalFS,
ObjectKey: "collectible-integration-" + suffix, Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker",
}
}
func collectibleTestBlobPtr(id int64, suffix string) *domain.FileBlob {
blob := collectibleTestBlob(id, suffix)
return &blob
}

View file

@ -3,13 +3,15 @@ package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
// TestStarGiftStorePostgres 回归迁移 0011用户收到礼物实例对真实 PG 的 CRUD
// 创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。
// TestStarGiftStorePostgres 回归迁移 0089目录不可变版本与用户收到礼物实例对真实 PG 的 CRUD
// 版本固定 / 创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。
func TestStarGiftStorePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -26,21 +28,72 @@ func TestStarGiftStorePostgres(t *testing.T) {
t.Fatalf("create sender: %v", err)
}
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
docID := time.Now().UnixNano() & 0x7fffffffffffffff
documentIDs := []int64{docID}
locationKeys := []string{"doc:" + fmt.Sprint(docID)}
entry, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true, Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
Actor: "test", CommandID: "test-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog gift: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM peer_star_gifts WHERE owner_peer_id IN ($1, $2)", owner.ID, int64(987654321))
tx, _ := pool.Begin(ctx)
if tx != nil {
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog WHERE gift_id=$1", entry.Gift.ID)
_, _ = tx.Exec(ctx, "DELETE FROM star_gift_catalog_revisions WHERE gift_id=$1", entry.Gift.ID)
_, _ = tx.Exec(ctx, "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])", locationKeys)
_, _ = tx.Exec(ctx, "DELETE FROM documents WHERE id = ANY($1::bigint[])", documentIDs)
_ = tx.Commit(ctx)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, from.ID})
})
// 创建三份礼物msg_id 递增)。
for i := 0; i < 3; i++ {
if _, err := st.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 100 + i,
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 100 + i,
Date: 1700000000 + i, ConvertStars: 50,
}); err != nil {
t.Fatalf("create gift #%d: %v", i, err)
}
}
// active revision 更新后,已收到的礼物必须继续固定到购买瞬间的 immutable revision。
docID2 := docID + 1
documentIDs = append(documentIDs, docID2)
locationKeys = append(locationKeys, "doc:"+fmt.Sprint(docID2))
updated, err := st.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
GiftID: entry.Gift.ID, Title: "Revision 2", Stars: 75, ConvertStars: 25, Enabled: true,
Document: domain.Document{
ID: docID2, AccessHash: docID2 + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{LocationKey: "doc:" + fmt.Sprint(docID2), Backend: domain.MediaBackendLocalFS, ObjectKey: "test-star-gift-v2", Size: 4, SHA256: make([]byte, 32), MimeType: "application/x-tgsticker"},
Animation: domain.StarGiftAnimation{JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`), SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512},
Actor: "test", CommandID: "test-star-gift-v2-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision 2: %v", err)
}
if updated.Revision != 2 || updated.Gift.RevisionID == entry.Gift.RevisionID {
t.Fatalf("revision 2 = %+v, want a new immutable revision", updated)
}
if updated.ReceivedCount != 3 {
t.Fatalf("revision 2 received count = %d, want all 3 historical instances", updated.ReceivedCount)
}
historical, found, err := st.CatalogRevision(ctx, entry.Gift.RevisionID)
if err != nil || !found || historical.Stars != 50 || historical.Sticker.ID != docID {
t.Fatalf("historical revision = %+v found %v err %v", historical, found, err)
}
// keyset 分页:每页 2末页省略游标。
page1, err := st.ListByOwner(ctx, ownerPeer, false, "", 2)
if err != nil {
@ -71,7 +124,7 @@ func TestStarGiftStorePostgres(t *testing.T) {
// GetByRef(user msg_id)。
g, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || g.GiftID != 8001 || g.ConvertStars != 50 {
if err != nil || !found || g.GiftID != entry.Gift.ID || g.RevisionID != entry.Gift.RevisionID || g.ConvertStars != 50 {
t.Fatalf("get = %+v found %v err %v", g, found, err)
}
@ -101,13 +154,13 @@ func TestStarGiftStorePostgres(t *testing.T) {
// 频道礼物用 saved_id 定位,和用户 msg_id 身份键隔离。
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 987654321}
if _, err := st.Create(ctx, domain.SavedStarGift{
Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 700,
Owner: ownerPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 700,
Date: 1700000100, ConvertStars: 50,
}); err != nil {
t.Fatalf("create user gift with same msg_id namespace: %v", err)
}
channelSavedID, err := st.Create(ctx, domain.SavedStarGift{
Owner: channelPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 0, SavedID: 0,
Owner: channelPeer, FromUserID: from.ID, GiftID: entry.Gift.ID, RevisionID: entry.Gift.RevisionID, MsgID: 0, SavedID: 0,
Date: 1700000101, ConvertStars: 50,
})
if err != nil {

View file

@ -0,0 +1,365 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math/big"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// StarGiftUpgradeStore is the PostgreSQL aggregate coordinator for collectible
// upgrades. It intentionally shares MessageStore's allocator and transaction
// machinery so Stars, issuance, the saved gift and durable updates commit once.
type StarGiftUpgradeStore struct {
db sqlcgen.DBTX
messages *MessageStore
}
func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore) *StarGiftUpgradeStore {
return &StarGiftUpgradeStore{db: db, messages: messages}
}
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
req.Ref.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) ||
req.ChargeStars < 0 || req.Date <= 0 || strings.TrimSpace(req.CommandKey) == "" || len(req.CommandKey) > 256 {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
if err != nil {
return domain.StarGiftUpgradeResult{}, err
}
if !found || saved.FromUserID <= 0 {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftNotFound
}
commandKey := strings.TrimSpace(req.CommandKey)
fingerprint := sha256.Sum256([]byte(fmt.Sprintf(
"telesrv:star-gift-upgrade:v1:%s:%d:%d:%t:%d:%t",
commandKey, saved.ID, req.ChargeStars, req.RequirePrepaid, req.FormID, req.KeepOriginalDetails,
)))
randomID := starGiftUpgradeRandomID(saved.FromUserID, req.UserID, commandKey)
placeholder := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{Upgrade: true, Saved: true},
},
}
messageReq := domain.SendPrivateTextRequest{
SenderUserID: saved.FromUserID,
RecipientUserID: req.UserID,
RandomID: randomID,
Media: placeholder,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
OriginUserID: req.UserID,
IdempotencyFingerprint: fingerprint[:],
}
var result domain.StarGiftUpgradeResult
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) error {
locked, err := lockSavedStarGiftForUpgrade(ctx, tx, req.Ref)
if err != nil {
return err
}
if locked.ID != saved.ID || locked.FromUserID != saved.FromUserID {
return domain.ErrStarGiftCollectibleInvalid
}
if locked.Converted {
return domain.ErrStarGiftAlreadyConverted
}
if locked.UniqueGiftID != 0 {
return domain.ErrStarGiftAlreadyUpgraded
}
revision, err := lockActiveCollectibleRevision(ctx, tx, locked.GiftID)
if err != nil {
return err
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
}
if req.RequirePrepaid {
// Prepayment is an entitlement captured at gift purchase time. A
// later published revision may change the current price, but must not
// retroactively invalidate that already-paid entitlement.
if req.ChargeStars != 0 || locked.PrepaidUpgradeStars <= 0 {
return domain.ErrStarGiftCollectibleUnavailable
}
} else if req.ChargeStars != revision.UpgradeStars {
return domain.ErrStarGiftCollectibleUnavailable
}
balance, err := debitStarGiftUpgrade(ctx, tx, req.UserID, req.ChargeStars, locked.Owner, req.Date)
if err != nil {
return err
}
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
if err != nil {
return err
}
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
if err != nil {
return err
}
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
if err != nil {
return err
}
num := revision.Issued + 1
var uniqueID int64
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
return fmt.Errorf("allocate unique star gift id: %w", err)
}
var title string
if err := tx.QueryRow(ctx, `SELECT title FROM star_gift_catalog_revisions WHERE id=$1`, locked.RevisionID).Scan(&title); err != nil {
return fmt.Errorf("load upgrade gift title: %w", err)
}
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
if _, err := tx.Exec(ctx, `
INSERT INTO unique_star_gifts
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
backdrop_attribute_id, keep_original_details)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
uniqueID, locked.GiftID, revision.ID, locked.ID, title, slug, num,
string(locked.Owner.Type), locked.Owner.ID, modelID, patternID, backdropID, req.KeepOriginalDetails); err != nil {
return fmt.Errorf("insert unique star gift: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
return fmt.Errorf("increment collectible issuance: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE peer_star_gifts
SET unique_gift_id=$2, prepaid_upgrade_stars=0, convert_stars=0
WHERE id=$1 AND unique_gift_id IS NULL AND NOT converted`, locked.ID, uniqueID); err != nil {
return fmt.Errorf("upgrade saved star gift: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO star_gift_upgrade_commands
(user_id, command_key, source_saved_gift_id, form_id, unique_gift_id, balance_after)
VALUES ($1,$2,$3,$4,$5,$6)`, req.UserID, commandKey, locked.ID, req.FormID, uniqueID, balance.Balance); err != nil {
return fmt.Errorf("insert star gift upgrade command: %w", err)
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("new unique star gift %d disappeared", uniqueID)
}
locked.UniqueGiftID = uniqueID
locked.PrepaidUpgradeStars = 0
locked.ConvertStars = 0
locked.Unique = &unique
result.Saved, result.Unique, result.Balance = locked, unique, balance
messageReq.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: func() int64 {
if locked.NameHidden {
return 0
}
return locked.FromUserID
}(), Peer: locked.Owner, Upgrade: true, Saved: !locked.Unsaved,
PrepaidUpgrade: req.RequirePrepaid,
},
},
}
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
ownerMessageID := sent.RecipientMessage.ID
if saved.FromUserID == req.UserID {
ownerMessageID = sent.SenderMessage.ID
}
if ownerMessageID <= 0 {
return fmt.Errorf("upgrade service message missing owner box")
}
tag, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET upgrade_msg_id=$2 WHERE id=$1 AND unique_gift_id=$3`, result.Saved.ID, ownerMessageID, result.Unique.ID)
if err != nil {
return fmt.Errorf("save star gift upgrade message id: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
}
result.Saved.UpgradeMsgID = ownerMessageID
return nil
},
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
return domain.StarGiftUpgradeResult{}, err
}
result.Send = sent
result.Duplicate = sent.Duplicate
if sent.Duplicate {
return s.loadUpgradeReplay(ctx, req, saved, sent)
}
return result, nil
}
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
where, args := savedStarGiftRefWhere(ref)
row := tx.QueryRow(ctx, `
SELECT p.id, p.owner_peer_type, p.owner_peer_id, p.from_user_id, p.gift_id, p.catalog_revision_id,
p.msg_id, p.saved_id, p.gift_date, p.name_hidden, p.unsaved, p.converted, p.convert_stars, p.prepaid_upgrade_stars,
p.message, COALESCE(p.unique_gift_id, 0), p.upgrade_msg_id, p.pinned_order,
COALESCE((SELECT array_agg(i.collection_id ORDER BY c.sort_order, i.collection_id)
FROM star_gift_collection_items i
JOIN star_gift_collections c ON c.collection_id=i.collection_id
WHERE i.saved_gift_id=p.id), ARRAY[]::integer[])
FROM peer_star_gifts p WHERE `+where+` FOR UPDATE`, args...)
saved, err := scanSavedStarGift(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
}
return saved, err
}
func lockActiveCollectibleRevision(ctx context.Context, tx pgx.Tx, giftID int64) (domain.StarGiftCollectibleRevision, error) {
var revision domain.StarGiftCollectibleRevision
var status string
err := tx.QueryRow(ctx, `
SELECT r.id, r.gift_id, r.upgrade_stars, r.supply_total, r.issued, r.slug_prefix, r.status
FROM star_gift_catalog c
JOIN star_gift_collectible_revisions r ON r.id=c.collectible_revision_id
WHERE c.gift_id=$1 FOR UPDATE OF r`, giftID).Scan(
&revision.ID, &revision.GiftID, &revision.UpgradeStars, &revision.SupplyTotal,
&revision.Issued, &revision.SlugPrefix, &status)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
}
if err != nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("lock active collectible revision: %w", err)
}
if status != "published" {
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftCollectibleUnavailable
}
return revision, nil
}
func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64, peer domain.Peer, date int) (domain.StarsBalance, error) {
result := domain.StarsBalance{UserID: userID}
var balance int64
err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, userID).Scan(&balance, &result.Granted)
if amount == 0 && errors.Is(err, pgx.ErrNoRows) {
return result, nil
}
if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) {
return domain.StarsBalance{}, domain.ErrStarsInsufficient
}
if err != nil {
return domain.StarsBalance{}, fmt.Errorf("lock stars balance for gift upgrade: %w", err)
}
if amount == 0 {
result.Balance = balance
return result, nil
}
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2, updated_at=now() WHERE user_id=$1 RETURNING balance`, userID, amount).Scan(&result.Balance); err != nil {
return domain.StarsBalance{}, fmt.Errorf("debit star gift upgrade: %w", err)
}
if err := insertStarsTxn(ctx, tx, userID, -amount, domain.StarsReasonGiftUpgrade, peer, date, "Star gift upgrade", ""); err != nil {
return domain.StarsBalance{}, err
}
return result, nil
}
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id, rarity_permille FROM %s WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, table), revisionID)
if err != nil {
return 0, fmt.Errorf("list collectible attributes for issuance: %w", err)
}
defer rows.Close()
type weightedID struct {
id int64
weight int
}
items := make([]weightedID, 0)
total := 0
for rows.Next() {
var item weightedID
if err := rows.Scan(&item.id, &item.weight); err != nil {
return 0, err
}
items = append(items, item)
total += item.weight
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(items) == 0 || total != 1000 {
return 0, domain.ErrStarGiftCollectibleInvalid
}
draw, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
if err != nil {
return 0, fmt.Errorf("draw collectible attribute: %w", err)
}
value := int(draw.Int64())
for _, item := range items {
if value < item.weight {
return item.id, nil
}
value -= item.weight
}
return 0, domain.ErrStarGiftCollectibleInvalid
}
func starGiftUpgradeRandomID(senderID, ownerID int64, commandKey string) int64 {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%s", senderID, ownerID, commandKey)))
id := int64(binary.LittleEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
if id == 0 {
id = 1
}
return id
}
func (s *StarGiftUpgradeStore) loadUpgradeReplay(ctx context.Context, req domain.StarGiftUpgradeRequest, original domain.SavedStarGift, sent domain.SendPrivateTextResult) (domain.StarGiftUpgradeResult, error) {
saved, found, err := NewStarGiftStore(s.db).GetByRef(ctx, req.Ref)
if err != nil || !found || saved.UniqueGiftID == 0 {
if err == nil {
err = domain.ErrStarGiftCollectibleInvalid
}
return domain.StarGiftUpgradeResult{}, err
}
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, saved.UniqueGiftID)
if err != nil || !found {
if err == nil {
err = domain.ErrStarGiftCollectibleInvalid
}
return domain.StarGiftUpgradeResult{}, err
}
var commandUniqueID int64
var balanceAfter int64
if err := s.db.QueryRow(ctx, `SELECT unique_gift_id, balance_after FROM star_gift_upgrade_commands WHERE user_id=$1 AND command_key=$2`, req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&commandUniqueID, &balanceAfter); err != nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("load star gift upgrade replay: %w", err)
}
if commandUniqueID != unique.ID || saved.ID != original.ID {
return domain.StarGiftUpgradeResult{}, domain.ErrStarGiftCollectibleInvalid
}
uniqueCopy := unique
saved.Unique = &uniqueCopy
return domain.StarGiftUpgradeResult{
Saved: saved, Unique: unique, Balance: domain.StarsBalance{UserID: req.UserID, Balance: balanceAfter},
Send: sent, Duplicate: true,
}, nil
}
var _ store.StarGiftUpgradeStore = (*StarGiftUpgradeStore)(nil)

View file

@ -6,15 +6,39 @@ import (
"telesrv/internal/domain"
)
// StarGiftStore 持久化 peer 收到的 Star 礼物实例peer_star_gifts。礼物目录是合成的
// 内存集合,不在此存储。
// StarGiftStore 持久化礼物目录、不可变版本和 peer 收到的礼物实例。
type StarGiftStore interface {
// Catalog 返回启用的当前目录快照,按 sort_order/gift_id 排序。
Catalog(ctx context.Context) ([]domain.StarGift, error)
// CatalogGift 只返回当前启用版本,供新购买校验。
CatalogGift(ctx context.Context, giftID int64) (domain.StarGift, bool, error)
// CatalogRevision 返回不可变历史版本,供已领取礼物投影。
CatalogRevision(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
// CreateCatalogRevision 创建新礼物或为既有礼物创建新版本,并原子切换当前版本。
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
SetCatalogEnabled(ctx context.Context, giftID int64, enabled bool) (bool, error)
SetCatalogSortOrder(ctx context.Context, giftID int64, sortOrder int) (bool, error)
// AnimationJSON 返回当前版本的规范化 Lottie JSON供管理后台安全预览。
AnimationJSON(ctx context.Context, giftID int64) ([]byte, bool, error)
// PublishCollectibleRevision validates and atomically publishes a new immutable attribute pool.
PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
// Create 写一条收到的礼物实例,返回行 id频道礼物未显式给 saved_id 时以该行 id 作为 saved_id。
Create(ctx context.Context, gift domain.SavedStarGift) (int64, error)
// ListByOwner 按 id DESC keyset 分页返回某 owner 未转换的礼物excludeUnsaved 时只返展示在资料的。
ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
ListByOwnerFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
// GetByRef 按协议引用取礼物实例:用户用 msg_id频道用 saved_id。
GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
// ResolveSavedIDs resolves an ordered batch of protocol references without
// per-gift round trips. Every ref must belong to owner and resolve to a live gift.
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
// CountByOwner 返回某 owner 展示在资料的礼物数(非转换、非隐藏),供 full.stargifts_count。
CountByOwner(ctx context.Context, owner domain.Peer) (int, error)
// SetUnsaved 切换礼物在资料的展示saveStarGift返回是否命中一行。
@ -22,4 +46,18 @@ type StarGiftStore interface {
// MarkConverted 幂等地把礼物标记为已转换convertStarGift返回该行供调用方据 ConvertStars
// 入账;已转换返回 domain.ErrStarGiftAlreadyConverted不存在返回 domain.ErrStarGiftNotFound。
MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error)
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
}
// StarGiftUpgradeStore owns the aggregate transaction spanning Stars, the
// collectible issuance state, the saved gift terminal state and durable
// private service-message updates.
type StarGiftUpgradeStore interface {
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
}