feat: sync collectible star gifts
This commit is contained in:
parent
47fcf0ea41
commit
5ecf4e912d
64 changed files with 7559 additions and 403 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue