From 3bb3f3364fd99910974e136fba896e0574ae45f3 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 9 Jun 2026 13:41:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E9=87=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/resource/handler.go | 178 ++++++++++++++++++ .../internal/modules/resource/handler_test.go | 169 +++++++++++++++++ .../internal/modules/resource/request.go | 9 + .../admin/internal/modules/resource/routes.go | 1 + 4 files changed, 357 insertions(+) create mode 100644 server/admin/internal/modules/resource/handler_test.go diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index f06a284e..dfe9d604 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -222,6 +222,71 @@ func (h *Handler) UpdateResource(c *gin.Context) { response.OK(c, resource) } +func (h *Handler) UpdateMP4ResourceLayouts(c *gin.Context) { + var req resourceMP4LayoutBatchRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "MP4 布局参数不正确") + return + } + items, err := normalizeMP4LayoutBatchItems(req.Items) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + ctx, cancel := h.walletRequestContext(c) + defer cancel() + type preparedMP4LayoutUpdate struct { + resource *walletv1.Resource + metadataJSON string + } + prepared := make([]preparedMP4LayoutUpdate, 0, len(items)) + for _, item := range items { + resp, err := h.wallet.GetResource(ctx, &walletv1.GetResourceRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ResourceId: item.ResourceID, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + current := resp.GetResource() + if current.GetResourceId() == 0 { + response.BadRequest(c, "资源不存在") + return + } + // 批量接口只更新真实 MP4 动效资源;非 MP4 行按产品要求自动跳过, + // 这样前端全选资源后提交到后端,也不会把图片、SVGA、PAG 的 metadata 写坏。 + if !resourceAnimationSourceIsMP4(current) { + continue + } + metadataJSON, err := mp4LayoutMetadataForResource(current, item.MetadataJSON) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + prepared = append(prepared, preparedMP4LayoutUpdate{resource: current, metadataJSON: metadataJSON}) + } + + updated := make([]resourceDTO, 0, len(prepared)) + updatedIDs := make([]string, 0, len(prepared)) + for _, item := range prepared { + // wallet-service 只有全量资源更新 RPC,所以这里用当前资源还原所有业务字段, + // 只替换 metadata_json,避免批量重算 MP4 时顺手覆盖价格、状态、资产或经理赠送配置。 + resp, err := h.wallet.UpdateResource(ctx, updateResourceMP4MetadataProto(c, item.resource, item.metadataJSON)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + resource := resourceFromProto(resp.GetResource()) + updated = append(updated, resource) + updatedIDs = append(updatedIDs, fmt.Sprintf("%d", resource.ResourceID)) + } + h.auditLog(c, "update-resource-mp4-layouts", "resources", "batch", "success", strings.Join(updatedIDs, ",")) + response.OK(c, updated) +} + func (h *Handler) EnableResource(c *gin.Context) { h.setResourceStatus(c, "active") } @@ -732,6 +797,119 @@ func actorID(c *gin.Context) int64 { return int64(shared.ActorFromContext(c).UserID) } +func normalizeMP4LayoutBatchItems(items []resourceMP4LayoutUpdateRequest) ([]resourceMP4LayoutUpdateRequest, error) { + const maxMP4LayoutBatchSize = 50 + if len(items) == 0 { + return nil, fmt.Errorf("请选择要更新的 MP4 资源") + } + if len(items) > maxMP4LayoutBatchSize { + return nil, fmt.Errorf("单次最多更新 %d 个 MP4 资源", maxMP4LayoutBatchSize) + } + seen := map[int64]struct{}{} + normalized := make([]resourceMP4LayoutUpdateRequest, 0, len(items)) + for _, item := range items { + if item.ResourceID <= 0 { + return nil, fmt.Errorf("资源 ID 不正确") + } + if _, ok := seen[item.ResourceID]; ok { + continue + } + seen[item.ResourceID] = struct{}{} + normalized = append(normalized, resourceMP4LayoutUpdateRequest{ + ResourceID: item.ResourceID, + MetadataJSON: strings.TrimSpace(item.MetadataJSON), + }) + } + if len(normalized) == 0 { + return nil, fmt.Errorf("请选择要更新的 MP4 资源") + } + return normalized, nil +} + +func resourceAnimationSourceIsMP4(item *walletv1.Resource) bool { + if item == nil { + return false + } + return resourceSourceIsMP4(item.GetAnimationUrl()) || resourceSourceIsMP4(item.GetAssetUrl()) +} + +func resourceSourceIsMP4(value string) bool { + source := strings.TrimSpace(value) + if source == "" { + return false + } + if before, _, ok := strings.Cut(source, "?"); ok { + source = before + } + if before, _, ok := strings.Cut(source, "#"); ok { + source = before + } + return strings.HasSuffix(strings.ToLower(source), ".mp4") +} + +func mp4LayoutMetadataForResource(current *walletv1.Resource, metadataJSON string) (string, error) { + incoming, fields, err := parseResourceMetadataPayload(metadataJSON) + if err != nil { + return "", fmt.Errorf("资源元数据格式不正确") + } + if incoming == nil { + return "", fmt.Errorf("MP4 透明布局参数不完整") + } + if _, ok := fields["mp4_alpha_layout"]; !ok { + return "", fmt.Errorf("MP4 透明布局参数不完整") + } + if err := validateResourceMetadata(resourceRequest{ + ResourceType: current.GetResourceType(), + MetadataJSON: metadataJSON, + }); err != nil { + return "", err + } + layout, err := sanitizeMP4AlphaLayoutMetadata(incoming.MP4AlphaLayout) + if err != nil { + return "", err + } + // 这个接口是“只更新 MP4 布局”的窄接口,所以其它 metadata 不信任前端请求; + // 资料卡布局从当前资源保留,MP4 布局使用本次重算结果,最终再走统一 marshal 过滤空字段。 + merged := resourceMetadataPayload{ + AnimationFormat: resourceAnimationFormatMP4, + MP4AlphaLayout: layout, + } + if current.GetResourceType() == resourceTypeProfileCard { + currentPayload, _, _ := parseResourceMetadataPayload(current.GetMetadataJson()) + if currentPayload != nil { + merged.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(currentPayload.ProfileCardLayout) + } + } + return marshalResourceMetadataPayload(merged), nil +} + +func updateResourceMP4MetadataProto(c *gin.Context, current *walletv1.Resource, metadataJSON string) *walletv1.UpdateResourceRequest { + managerGrantEnabled := current.GetManagerGrantEnabled() + return &walletv1.UpdateResourceRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ResourceId: current.GetResourceId(), + ResourceCode: current.GetResourceCode(), + ResourceType: current.GetResourceType(), + Name: current.GetName(), + Status: current.GetStatus(), + Grantable: current.GetGrantable(), + GrantStrategy: current.GetGrantStrategy(), + WalletAssetType: current.GetWalletAssetType(), + WalletAssetAmount: current.GetWalletAssetAmount(), + UsageScopes: append([]string(nil), current.GetUsageScopes()...), + AssetUrl: current.GetAssetUrl(), + PreviewUrl: current.GetPreviewUrl(), + AnimationUrl: current.GetAnimationUrl(), + MetadataJson: metadataJSON, + SortOrder: current.GetSortOrder(), + OperatorUserId: actorID(c), + ManagerGrantEnabled: &managerGrantEnabled, + PriceType: current.GetPriceType(), + CoinPrice: current.GetCoinPrice(), + } +} + func parseID(c *gin.Context, name string) (int64, bool) { raw := strings.TrimSpace(c.Param(name)) value, err := strconv.ParseInt(raw, 10, 64) diff --git a/server/admin/internal/modules/resource/handler_test.go b/server/admin/internal/modules/resource/handler_test.go new file mode 100644 index 00000000..7d674ecc --- /dev/null +++ b/server/admin/internal/modules/resource/handler_test.go @@ -0,0 +1,169 @@ +package resource + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/integration/walletclient" + "hyapp-admin-server/internal/middleware" + walletv1 "hyapp.local/api/proto/wallet/v1" + + "github.com/gin-gonic/gin" +) + +func TestUpdateMP4ResourceLayoutsPreservesResourceFields(t *testing.T) { + wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ + 11: { + AppCode: "lalu", + ResourceId: 11, + ResourceCode: "profile_card_star", + ResourceType: resourceTypeProfileCard, + Name: "星光资料卡", + Status: "active", + Grantable: true, + GrantStrategy: "extend_expiry", + WalletAssetType: "RESOURCE", + WalletAssetAmount: 1, + UsageScopes: []string{resourceTypeProfileCard}, + AssetUrl: "https://cdn.example.test/profile_card.png", + PreviewUrl: "https://cdn.example.test/profile_card_cover.png", + AnimationUrl: "https://cdn.example.test/profile_card.mp4?token=1", + MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"detect_version":1}}`, + SortOrder: 9, + ManagerGrantEnabled: true, + PriceType: resourcePriceTypeCoin, + CoinPrice: 100, + }, + }} + router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil)) + metadataJSON := `{ + "animation_format":"mp4", + "mp4_alpha_layout":{ + "alpha_layout":"alpha_left_rgb_right", + "video_w":1500, + "video_h":1334, + "rgb_frame":[750,0,750,1334], + "alpha_frame":[0,0,750,1334], + "confirmed":true, + "detect_version":1 + }, + "debug":true + }` + body := fmt.Sprintf(`{"items":[{"resourceId":11,"metadataJson":%q}]}`, metadataJSON) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("batch mp4 layout update status mismatch: %d %s", recorder.Code, recorder.Body.String()) + } + if len(wallet.updates) != 1 { + t.Fatalf("expected one wallet update, got %d", len(wallet.updates)) + } + update := wallet.updates[0] + if update.GetResourceId() != 11 || update.GetName() != "星光资料卡" || update.GetStatus() != "active" || update.GetCoinPrice() != 100 { + t.Fatalf("resource fields should be preserved: %+v", update) + } + if update.ManagerGrantEnabled == nil || !update.GetManagerGrantEnabled() || update.GetOperatorUserId() != 7 { + t.Fatalf("operator and manager grant should be preserved: %+v", update) + } + if !strings.Contains(update.GetMetadataJson(), `"profile_card_layout"`) || !strings.Contains(update.GetMetadataJson(), `"mp4_alpha_layout"`) { + t.Fatalf("profile card and mp4 metadata should both be kept: %s", update.GetMetadataJson()) + } + if strings.Contains(update.GetMetadataJson(), "debug") { + t.Fatalf("debug metadata should be dropped: %s", update.GetMetadataJson()) + } +} + +func TestUpdateMP4ResourceLayoutsIgnoresNonMP4Resource(t *testing.T) { + wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ + 12: { + AppCode: "lalu", + ResourceId: 12, + ResourceCode: "gift_rose", + ResourceType: "gift", + Name: "玫瑰", + Status: "active", + AnimationUrl: "https://cdn.example.test/gift.svga", + MetadataJson: "{}", + GrantStrategy: "increase_quantity", + PriceType: resourcePriceTypeCoin, + CoinPrice: 10, + }, + }} + router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil)) + body := fmt.Sprintf(`{"items":[{"resourceId":12,"metadataJson":%q}]}`, mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true)) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("non-mp4 resource should be ignored, got %d %s", recorder.Code, recorder.Body.String()) + } + if len(wallet.updates) != 0 { + t.Fatalf("non-mp4 resource should not update wallet") + } +} + +func newResourceHandlerTestRouter(handler *Handler) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) + c.Set(middleware.ContextRequestID, "resource-handler-test") + c.Set(middleware.ContextUserID, uint(7)) + c.Set(middleware.ContextUsername, "tester") + c.Next() + }) + router.PUT("/admin/resources/mp4-layouts", handler.UpdateMP4ResourceLayouts) + return router +} + +type mockResourceWallet struct { + walletclient.Client + resources map[int64]*walletv1.Resource + updates []*walletv1.UpdateResourceRequest +} + +func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { + resource := m.resources[req.GetResourceId()] + if resource == nil { + return nil, fmt.Errorf("resource not found") + } + return &walletv1.GetResourceResponse{Resource: resource}, nil +} + +func (m *mockResourceWallet) UpdateResource(ctx context.Context, req *walletv1.UpdateResourceRequest) (*walletv1.ResourceResponse, error) { + m.updates = append(m.updates, req) + return &walletv1.ResourceResponse{Resource: &walletv1.Resource{ + AppCode: req.GetAppCode(), + ResourceId: req.GetResourceId(), + ResourceCode: req.GetResourceCode(), + ResourceType: req.GetResourceType(), + Name: req.GetName(), + Status: req.GetStatus(), + Grantable: req.GetGrantable(), + GrantStrategy: req.GetGrantStrategy(), + WalletAssetType: req.GetWalletAssetType(), + WalletAssetAmount: req.GetWalletAssetAmount(), + UsageScopes: req.GetUsageScopes(), + AssetUrl: req.GetAssetUrl(), + PreviewUrl: req.GetPreviewUrl(), + AnimationUrl: req.GetAnimationUrl(), + MetadataJson: req.GetMetadataJson(), + SortOrder: req.GetSortOrder(), + ManagerGrantEnabled: req.GetManagerGrantEnabled(), + PriceType: req.GetPriceType(), + CoinPrice: req.GetCoinPrice(), + }}, nil +} diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index f54d7f91..d31d0362 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -78,6 +78,15 @@ type resourceRequest struct { SortOrder int32 `json:"sortOrder"` } +type resourceMP4LayoutBatchRequest struct { + Items []resourceMP4LayoutUpdateRequest `json:"items"` +} + +type resourceMP4LayoutUpdateRequest struct { + ResourceID int64 `json:"resourceId"` + MetadataJSON string `json:"metadataJson"` +} + type emojiPackRequest struct { Name string `json:"name"` Category string `json:"category"` diff --git a/server/admin/internal/modules/resource/routes.go b/server/admin/internal/modules/resource/routes.go index 2a38cb70..7799377a 100644 --- a/server/admin/internal/modules/resource/routes.go +++ b/server/admin/internal/modules/resource/routes.go @@ -13,6 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/resources", middleware.RequirePermission("resource:view"), h.ListResources) protected.POST("/admin/resources", middleware.RequirePermission("resource:create"), h.CreateResource) + protected.PUT("/admin/resources/mp4-layouts", middleware.RequirePermission("resource:update"), h.UpdateMP4ResourceLayouts) protected.GET("/admin/resources/:resource_id", middleware.RequirePermission("resource:view"), h.GetResource) protected.PUT("/admin/resources/:resource_id", middleware.RequirePermission("resource:update"), h.UpdateResource) protected.POST("/admin/resources/:resource_id/enable", middleware.RequirePermission("resource:update"), h.EnableResource)