feat: sync collectible star gifts

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

View file

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

View file

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