批量重算
This commit is contained in:
parent
7f16956b7d
commit
3bb3f3364f
@ -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)
|
||||
|
||||
169
server/admin/internal/modules/resource/handler_test.go
Normal file
169
server/admin/internal/modules/resource/handler_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user