feat: sync collectible star gifts
This commit is contained in:
parent
47fcf0ea41
commit
5ecf4e912d
64 changed files with 7559 additions and 403 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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。 |
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-CSxFgF7v.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-CSxFgF7v.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-Q8RNNOYL.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
7
cmd/telesrv-admin/web/package-lock.json
generated
7
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 JSON;Lottie 会规范化并压缩成 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": "平台",
|
||||
|
|
|
|||
4
cmd/telesrv-admin/web/src/lottie-web.d.ts
vendored
Normal file
4
cmd/telesrv-admin/web/src/lottie-web.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "lottie-web/build/player/lottie_light_canvas" {
|
||||
import lottie from "lottie-web";
|
||||
export default lottie;
|
||||
}
|
||||
236
cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx
Normal file
236
cmd/telesrv-admin/web/src/pages/GiftCollectiblesModal.tsx
Normal 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);
|
||||
}
|
||||
237
cmd/telesrv-admin/web/src/pages/GiftsPage.tsx
Normal file
237
cmd/telesrv-admin/web/src/pages/GiftsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 创建后注入。
|
||||
|
|
|
|||
17
deploy/migrations/0089_star_gift_catalog.down.sql
Normal file
17
deploy/migrations/0089_star_gift_catalog.down.sql
Normal 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;
|
||||
81
deploy/migrations/0089_star_gift_catalog.up.sql
Normal file
81
deploy/migrations/0089_star_gift_catalog.up.sql
Normal 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();
|
||||
31
deploy/migrations/0090_star_gift_collectibles.down.sql
Normal file
31
deploy/migrations/0090_star_gift_collectibles.down.sql
Normal 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;
|
||||
238
deploy/migrations/0090_star_gift_collectibles.up.sql
Normal file
238
deploy/migrations/0090_star_gift_collectibles.up.sql
Normal 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();
|
||||
|
|
@ -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;
|
||||
15
deploy/migrations/0091_star_gift_prepaid_upgrade.up.sql
Normal file
15
deploy/migrations/0091_star_gift_prepaid_upgrade.up.sql
Normal 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;
|
||||
$$;
|
||||
21
deploy/migrations/0092_collectible_issuance_guard.down.sql
Normal file
21
deploy/migrations/0092_collectible_issuance_guard.down.sql
Normal 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;
|
||||
$$;
|
||||
28
deploy/migrations/0092_collectible_issuance_guard.up.sql
Normal file
28
deploy/migrations/0092_collectible_issuance_guard.up.sql
Normal 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;
|
||||
$$;
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 调用总超时。 |
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ package admin
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -14,14 +16,18 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
ActionRevokeSessions = "account.revoke_sessions"
|
||||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = stars(v1 全额转换,视作用新购 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
|
||||
}
|
||||
190
internal/app/stargifts/animation.go
Normal file
190
internal/app/stargifts/animation.go
Normal 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
|
||||
}
|
||||
93
internal/app/stargifts/animation_test.go
Normal file
93
internal/app/stargifts/animation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
store store.StarGiftStore
|
||||
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 返回目录 hash(getStarGifts 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 +621,42 @@ 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 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
Converted bool `json:"converted,omitempty"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
Converted bool `json:"converted,omitempty"`
|
||||
CanUpgrade bool `json:"can_upgrade,omitempty"`
|
||||
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
|
||||
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 快照)。
|
||||
|
|
|
|||
|
|
@ -246,19 +246,23 @@ type MessageFilter struct {
|
|||
|
||||
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
||||
type SendPrivateTextRequest struct {
|
||||
SenderUserID int64
|
||||
RecipientUserID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
SenderUserID int64
|
||||
RecipientUserID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
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 请求指纹,避免链接预览、骰子结果、上传媒体
|
||||
|
|
|
|||
|
|
@ -3,36 +3,255 @@ package domain
|
|||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Star gift(payments.sendStarsForm + inputInvoiceStarGift)领域模型。目录是从已 seed 的
|
||||
// animated_emoji 合成的静态集合(StarGift);peer 收到的礼物实例落 peer_star_gifts(SavedStarGift)。
|
||||
// Star gift(payments.sendStarsForm + inputInvoiceStarGift)领域模型。目录和不可变版本
|
||||
// 持久化在 star_gift_catalog(_revisions);peer 收到的礼物实例落 peer_star_gifts。
|
||||
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
|
||||
|
||||
// StarGift 是一个可购买礼物目录项(合成、非用户持有)。
|
||||
// StarGift 是一个可购买礼物目录项。RevisionID 标识不可变的标题/价格/动画快照。
|
||||
type StarGift struct {
|
||||
ID int64
|
||||
Stars int64 // 购买价(Stars)
|
||||
ConvertStars int64 // 收礼人可转换回的 Stars(v1 = Stars,全额)
|
||||
Title string // 可选标题
|
||||
Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃)
|
||||
ID int64
|
||||
RevisionID int64
|
||||
Stars int64 // 购买价(Stars)
|
||||
ConvertStars int64 // 收礼人可转换回的 Stars
|
||||
UpgradeStars int64 // 升级为唯一礼物所需 Stars;0 表示当前不可升级
|
||||
UpgradeTotal int // 当前已发布属性池允许发行的唯一礼物总量
|
||||
UpgradeIssued int // 当前已发行数量
|
||||
Title string // 可选标题
|
||||
Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃)
|
||||
}
|
||||
|
||||
// SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。
|
||||
type SavedStarGift struct {
|
||||
ID int64
|
||||
Owner Peer // 收礼 peer(user/channel)
|
||||
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
|
||||
GiftID int64 // → StarGift.ID
|
||||
RevisionID int64 // → star_gift_catalog_revisions.id,历史查询必须按此版本投影
|
||||
MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0
|
||||
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0
|
||||
Date int // 收到时刻 Unix 秒
|
||||
NameHidden bool // 送礼人请求隐藏姓名
|
||||
Unsaved bool // 未展示在个人资料(saveStarGift 切换)
|
||||
Converted bool // 已转换回 Stars(终态,从列表排除)
|
||||
ConvertStars int64 // 转换可退回的 Stars
|
||||
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
|
||||
Message string // 附言(可选)
|
||||
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
|
||||
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id
|
||||
PinnedOrder int // >0 表示资料页置顶顺序
|
||||
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
|
||||
Unique *UniqueStarGift
|
||||
}
|
||||
|
||||
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
|
||||
type StarGiftCollectibleAttributeKind string
|
||||
|
||||
const (
|
||||
StarGiftCollectibleModel StarGiftCollectibleAttributeKind = "model"
|
||||
StarGiftCollectiblePattern StarGiftCollectibleAttributeKind = "pattern"
|
||||
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
|
||||
)
|
||||
|
||||
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
|
||||
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
|
||||
type StarGiftCollectibleAttribute struct {
|
||||
ID int64
|
||||
CollectibleRevisionID int64
|
||||
Kind StarGiftCollectibleAttributeKind
|
||||
Name string
|
||||
Document *Document
|
||||
BackdropID int
|
||||
CenterColor int
|
||||
EdgeColor int
|
||||
PatternColor int
|
||||
TextColor int
|
||||
RarityPermille int
|
||||
SortOrder int
|
||||
Animation *StarGiftAnimation
|
||||
Blob *FileBlob
|
||||
}
|
||||
|
||||
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
|
||||
type StarGiftCollectibleRevision struct {
|
||||
ID int64
|
||||
Owner Peer // 收礼 peer(user/channel)
|
||||
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
|
||||
GiftID int64 // → StarGift.ID
|
||||
MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0
|
||||
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0
|
||||
Date int // 收到时刻 Unix 秒
|
||||
NameHidden bool // 送礼人请求隐藏姓名
|
||||
Unsaved bool // 未展示在个人资料(saveStarGift 切换)
|
||||
Converted bool // 已转换回 Stars(终态,从列表排除)
|
||||
ConvertStars int64 // 转换可退回的 Stars
|
||||
Message string // 附言(可选)
|
||||
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)。
|
||||
|
|
@ -79,20 +330,138 @@ var (
|
|||
// ErrStarGiftNotFound 表示找不到该已收到礼物实例。
|
||||
ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
|
||||
// ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars(不可重复转换)。
|
||||
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -20,21 +20,22 @@ type StarsBalance struct {
|
|||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
type StarsTransaction struct {
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
Reason StarsTransactionReason
|
||||
Title string // 可选,投影到 tg.StarsTransaction.Title
|
||||
Description string // 可选,投影到 tg.StarsTransaction.Description
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):余额查询、贷记/借记、流水分页。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
235
internal/rpc/payments_star_gift_collections.go
Normal file
235
internal/rpc/payments_star_gift_collections.go
Normal 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()
|
||||
}
|
||||
}
|
||||
278
internal/rpc/payments_star_gift_unique.go
Normal file
278
internal/rpc/payments_star_gift_unique.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,23 +294,25 @@ 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
|
||||
}
|
||||
// 3. 记账:收礼人收到一份礼物实例(msg_id = 收礼人侧消息 id)。
|
||||
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
|
||||
FromUserID: senderID,
|
||||
GiftID: gift.ID,
|
||||
MsgID: send.RecipientMessage.ID,
|
||||
Date: send.RecipientMessage.Date,
|
||||
NameHidden: hideName,
|
||||
Unsaved: false,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Message: message,
|
||||
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
|
||||
}
|
||||
|
|
@ -294,21 +330,25 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
|
|||
action := domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
GiftID: gift.ID,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Title: gift.Title,
|
||||
Sticker: &sticker,
|
||||
Message: message,
|
||||
FromUserID: senderID,
|
||||
NameHidden: hideName,
|
||||
Saved: true,
|
||||
GiftID: gift.ID,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Title: gift.Title,
|
||||
Sticker: &sticker,
|
||||
Message: message,
|
||||
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
|
||||
|
|
@ -347,16 +387,19 @@ func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int6
|
|||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
GiftID: gift.ID,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Title: gift.Title,
|
||||
Sticker: &sticker,
|
||||
Message: message,
|
||||
FromUserID: senderID,
|
||||
PeerUserID: recipientID,
|
||||
NameHidden: hideName,
|
||||
Saved: true,
|
||||
GiftID: gift.ID,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Title: gift.Title,
|
||||
Sticker: &sticker,
|
||||
Message: message,
|
||||
FromUserID: senderID,
|
||||
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 = ©
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
out[g.RevisionID] = gift
|
||||
}
|
||||
return out
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 saga:catalog → 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()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -10,14 +11,256 @@ import (
|
|||
|
||||
// StarGiftStore 是 store.StarGiftStore 的内存实现。
|
||||
type StarGiftStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
gifts []domain.SavedStarGift // 追加序
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_markup(bot 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
781
internal/store/postgres/star_gift_collectibles.go
Normal file
781
internal/store/postgres/star_gift_collectibles.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
365
internal/store/postgres/star_gift_upgrade.go
Normal file
365
internal/store/postgres/star_gift_upgrade.go
Normal 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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue