diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 323bc86e..f06a284e 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -95,6 +95,10 @@ func (h *Handler) CreateResource(c *gin.Context) { response.BadRequest(c, err.Error()) return } + if err := validateResourceMetadata(req); err != nil { + response.BadRequest(c, err.Error()) + return + } resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) @@ -203,6 +207,10 @@ func (h *Handler) UpdateResource(c *gin.Context) { response.BadRequest(c, err.Error()) return } + if err := validateResourceMetadata(req); err != nil { + response.BadRequest(c, err.Error()) + return + } protoReq := req.updateProto(c, resourceID) resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq) if err != nil { diff --git a/server/admin/internal/modules/resource/operator_test.go b/server/admin/internal/modules/resource/operator_test.go index a60a2c9b..5464fd7b 100644 --- a/server/admin/internal/modules/resource/operator_test.go +++ b/server/admin/internal/modules/resource/operator_test.go @@ -2,6 +2,7 @@ package resource import ( "encoding/json" + "strconv" "strings" "testing" ) @@ -150,6 +151,131 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) { } } +func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) { + for _, resourceType := range []string{"gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", "mic_seat_animation"} { + metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "") + payload := resourceMetadataPayload{} + if err := json.Unmarshal([]byte(metadata), &payload); err != nil { + t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err) + } + if payload.AnimationFormat != resourceAnimationFormatMP4 || payload.MP4AlphaLayout == nil { + t.Fatalf("%s mp4 metadata should be kept: %s", resourceType, metadata) + } + if payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutAlphaLeftRGBRight || payload.MP4AlphaLayout.RGBFrame[0] != 750 { + t.Fatalf("%s mp4 layout mismatch: %+v", resourceType, payload.MP4AlphaLayout) + } + } +} + +func TestResourceMetadataKeepsNormalMP4Layout(t *testing.T) { + metadata := resourceMetadataJSON("gift", "", `{ + "animation_format": "mp4", + "mp4_alpha_layout": { + "alpha_layout": "normal", + "video_w": 750, + "video_h": 1334, + "rgb_frame": [1, 2, 3, 4], + "alpha_frame": null, + "confirmed": true, + "detect_version": 1 + } + }`, "", "") + payload := resourceMetadataPayload{} + if err := json.Unmarshal([]byte(metadata), &payload); err != nil { + t.Fatalf("normal mp4 metadata json mismatch: %v", err) + } + if payload.MP4AlphaLayout == nil || payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutNormal { + t.Fatalf("normal mp4 metadata should be kept: %s", metadata) + } + if payload.MP4AlphaLayout.AlphaFrame != nil || payload.MP4AlphaLayout.RGBFrame[2] != 750 || payload.MP4AlphaLayout.RGBFrame[3] != 1334 { + t.Fatalf("normal mp4 layout should be normalized to full frame: %+v", payload.MP4AlphaLayout) + } +} + +func TestProfileCardResourceMetadataKeepsLayoutAndMP4AlphaLayout(t *testing.T) { + metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{ + "profile_card_layout": { + "source_width": 1136, + "source_height": 1680, + "color_content_width": 750, + "content_top": 117, + "content_bottom": 1666, + "content_height": 1550, + "content_top_ratio": 0, + "content_height_ratio": 0, + "detect_version": 99 + }, + "mp4_alpha_layout": { + "alpha_layout": "rgb_left_alpha_right", + "video_w": 1472, + "video_h": 1328, + "rgb_frame": [0, 0, 736, 1328], + "alpha_frame": [736, 0, 736, 1328], + "confirmed": true, + "detect_version": 1 + } + }`, "", "") + payload := resourceMetadataPayload{} + if err := json.Unmarshal([]byte(metadata), &payload); err != nil { + t.Fatalf("profile card mp4 metadata json mismatch: %v", err) + } + if payload.ProfileCardLayout == nil || payload.ProfileCardLayout.ContentTop != 117 { + t.Fatalf("profile card layout should be kept: %+v", payload.ProfileCardLayout) + } + if payload.MP4AlphaLayout == nil || payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutRGBLeftAlphaRight { + t.Fatalf("profile card mp4 layout should be kept: %+v", payload.MP4AlphaLayout) + } +} + +func TestValidateResourceMetadataRejectsInvalidMP4AlphaLayout(t *testing.T) { + cases := []struct { + name string + req resourceRequest + }{ + { + name: "unconfirmed", + req: resourceRequest{ + ResourceType: "gift", + MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", false), + }, + }, + { + name: "bad rect", + req: resourceRequest{ + ResourceType: "gift", + MetadataJSON: `{ + "mp4_alpha_layout": { + "alpha_layout": "alpha_left_rgb_right", + "video_w": 1500, + "video_h": 1334, + "rgb_frame": [750, 0, 800, 1334], + "alpha_frame": [0, 0, 750, 1334], + "confirmed": true, + "detect_version": 1 + } + }`, + }, + }, + { + name: "unsupported type", + req: resourceRequest{ + ResourceType: resourceTypeBadge, + MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := validateResourceMetadata(tc.req); err == nil { + t.Fatalf("invalid mp4 metadata should be rejected") + } + }) + } + if err := validateResourceMetadata(resourceRequest{ResourceType: "gift", MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true)}); err != nil { + t.Fatalf("valid mp4 metadata rejected: %v", err) + } +} + func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) { metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{ "profile_card_layout": { @@ -182,3 +308,19 @@ func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) { t.Fatalf("non-badge resource should not require badge form: %v", err) } } + +func mp4AlphaLayoutMetadataJSON(alphaLayout string, confirmed bool) string { + return `{ + "animation_format": "mp4", + "mp4_alpha_layout": { + "alpha_layout": "` + alphaLayout + `", + "video_w": 1500, + "video_h": 1334, + "rgb_frame": [750, 0, 750, 1334], + "alpha_frame": [0, 0, 750, 1334], + "confirmed": ` + strconv.FormatBool(confirmed) + `, + "detect_version": 1 + }, + "debug": true + }` +} diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index fa896263..f54d7f91 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -24,6 +24,20 @@ const ( resourceTypeProfileCard = "profile_card" ) +const ( + resourceAnimationFormatMP4 = "mp4" +) + +const ( + mp4AlphaLayoutNormal = "normal" + mp4AlphaLayoutAlphaLeftRGBRight = "alpha_left_rgb_right" + mp4AlphaLayoutRGBLeftAlphaRight = "rgb_left_alpha_right" + mp4AlphaLayoutAlphaTopRGBBottom = "alpha_top_rgb_bottom" + mp4AlphaLayoutRGBTopAlphaBottom = "rgb_top_alpha_bottom" + mp4AlphaLayoutCustom = "custom" + mp4AlphaLayoutDetectVersion int64 = 1 +) + const ( resourcePriceTypeCoin = "coin" resourcePriceTypeFree = "free" @@ -88,6 +102,23 @@ type badgeMetadataPayload struct { DefaultSlot string `json:"default_slot,omitempty"` } +type resourceMetadataPayload struct { + AnimationFormat string `json:"animation_format,omitempty"` + MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"` + ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"` +} + +type mp4AlphaLayoutMetadataPayload struct { + AlphaLayout string `json:"alpha_layout"` + VideoWidth int64 `json:"video_w"` + VideoHeight int64 `json:"video_h"` + RGBFrame []int64 `json:"rgb_frame"` + AlphaFrame []int64 `json:"alpha_frame"` + Confirmed bool `json:"confirmed"` + Confidence float64 `json:"confidence,omitempty"` + DetectVersion int64 `json:"detect_version"` +} + type profileCardMetadataPayload struct { ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"` } @@ -559,6 +590,39 @@ func validateResourceBadgeForm(req resourceRequest) error { return nil } +func validateResourceMetadata(req resourceRequest) error { + resourceType := normalizeResourceType(req.ResourceType) + payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON) + if err != nil { + return fmt.Errorf("资源元数据格式不正确") + } + if payload == nil { + return nil + } + if rawFormat, ok := fields["animation_format"]; ok && len(rawFormat) > 0 { + animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat)) + if animationFormat != "" && animationFormat != resourceAnimationFormatMP4 { + return fmt.Errorf("资源动效格式不支持") + } + } + if _, ok := fields["mp4_alpha_layout"]; !ok { + return nil + } + if !resourceAllowsAnimationMetadata(resourceType) { + return fmt.Errorf("当前资源类型不支持 MP4 透明布局") + } + layout, err := sanitizeMP4AlphaLayoutMetadata(payload.MP4AlphaLayout) + if err != nil { + return err + } + // MP4 透明布局是客户端合成协议,后台只能保存人工确认后的显式矩形, + // 否则 Flutter 会重新落回不稳定的自动识别路径,黑场或弱 mask 素材仍会展示错误。 + if !layout.Confirmed { + return fmt.Errorf("请确认 MP4 透明布局") + } + return nil +} + func resourceGrantStrategy(resourceType string) string { switch resourceType { case resourceTypeCoin: @@ -573,14 +637,25 @@ func resourceGrantStrategy(resourceType string) string { } func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string) string { - switch resourceType { - case resourceTypeBadge: + if resourceType == resourceTypeBadge { return badgeMetadataJSON(badgeForm, badgeKind, levelTrack) - case resourceTypeProfileCard: - return profileCardMetadataJSON(metadataJSON) - default: + } + + rawPayload, _, err := parseResourceMetadataPayload(metadataJSON) + if err != nil || rawPayload == nil { return "{}" } + payload := resourceMetadataPayload{} + if resourceType == resourceTypeProfileCard { + payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout) + } + if resourceAllowsAnimationMetadata(resourceType) { + if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed { + payload.AnimationFormat = resourceAnimationFormatMP4 + payload.MP4AlphaLayout = layout + } + } + return marshalResourceMetadataPayload(payload) } func badgeMetadataJSON(badgeForm string, badgeKind string, levelTrack string) string { @@ -611,7 +686,7 @@ func profileCardMetadataJSON(metadataJSON string) string { if raw == "" { return "{}" } - payload := profileCardMetadataPayload{} + payload := resourceMetadataPayload{} if err := json.Unmarshal([]byte(raw), &payload); err != nil { return "{}" } @@ -627,6 +702,128 @@ func profileCardMetadataJSON(metadataJSON string) string { return string(body) } +func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload, map[string]json.RawMessage, error) { + raw := strings.TrimSpace(metadataJSON) + if raw == "" { + return nil, nil, nil + } + fields := map[string]json.RawMessage{} + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return nil, nil, err + } + payload := resourceMetadataPayload{} + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, nil, err + } + return &payload, fields, nil +} + +func marshalResourceMetadataPayload(payload resourceMetadataPayload) string { + if payload.AnimationFormat == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil { + return "{}" + } + body, err := json.Marshal(payload) + if err != nil { + return "{}" + } + return string(body) +} + +func sanitizeMP4AlphaLayoutMetadata(layout *mp4AlphaLayoutMetadataPayload) (*mp4AlphaLayoutMetadataPayload, error) { + if layout == nil { + return nil, fmt.Errorf("MP4 透明布局参数不完整") + } + videoWidth := layout.VideoWidth + videoHeight := layout.VideoHeight + if videoWidth <= 0 || videoHeight <= 0 { + return nil, fmt.Errorf("MP4 视频宽高不正确") + } + alphaLayout := normalizeMP4AlphaLayout(layout.AlphaLayout) + if alphaLayout == "" { + return nil, fmt.Errorf("MP4 透明布局类型不支持") + } + detectVersion := layout.DetectVersion + if detectVersion <= 0 { + detectVersion = mp4AlphaLayoutDetectVersion + } + confidence := layout.Confidence + if confidence < 0 { + confidence = 0 + } + if confidence > 1 { + confidence = 1 + } + + if alphaLayout == mp4AlphaLayoutNormal { + // normal 也是显式布局:RGB 使用完整视频帧,alpha_frame 固定为 null, + // 这样 native 层能明确跳过 split-alpha 猜测,普通 MP4 不再被误判。 + return &mp4AlphaLayoutMetadataPayload{ + AlphaLayout: mp4AlphaLayoutNormal, + VideoWidth: videoWidth, + VideoHeight: videoHeight, + RGBFrame: []int64{0, 0, videoWidth, videoHeight}, + AlphaFrame: nil, + Confirmed: layout.Confirmed, + Confidence: confidence, + DetectVersion: detectVersion, + }, nil + } + if !validMP4Frame(layout.RGBFrame, videoWidth, videoHeight) || !validMP4Frame(layout.AlphaFrame, videoWidth, videoHeight) { + return nil, fmt.Errorf("MP4 透明布局矩形不正确") + } + // 透明 MP4 的 RGB 与 alpha 矩形直接影响客户端 shader 取样,后端只做边界归一化, + // 不把字段重算成左右或上下默认值,避免覆盖前端人工确认过的 custom 细节。 + return &mp4AlphaLayoutMetadataPayload{ + AlphaLayout: alphaLayout, + VideoWidth: videoWidth, + VideoHeight: videoHeight, + RGBFrame: append([]int64(nil), layout.RGBFrame[:4]...), + AlphaFrame: append([]int64(nil), layout.AlphaFrame[:4]...), + Confirmed: layout.Confirmed, + Confidence: confidence, + DetectVersion: detectVersion, + }, nil +} + +func validMP4Frame(frame []int64, videoWidth int64, videoHeight int64) bool { + if len(frame) < 4 { + return false + } + left, top, width, height := frame[0], frame[1], frame[2], frame[3] + if left < 0 || top < 0 || width <= 0 || height <= 0 { + return false + } + return left+width <= videoWidth && top+height <= videoHeight +} + +func normalizeMP4AlphaLayout(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case mp4AlphaLayoutNormal: + return mp4AlphaLayoutNormal + case mp4AlphaLayoutAlphaLeftRGBRight: + return mp4AlphaLayoutAlphaLeftRGBRight + case mp4AlphaLayoutRGBLeftAlphaRight: + return mp4AlphaLayoutRGBLeftAlphaRight + case mp4AlphaLayoutAlphaTopRGBBottom: + return mp4AlphaLayoutAlphaTopRGBBottom + case mp4AlphaLayoutRGBTopAlphaBottom: + return mp4AlphaLayoutRGBTopAlphaBottom + case mp4AlphaLayoutCustom: + return mp4AlphaLayoutCustom + default: + return "" + } +} + +func resourceAllowsAnimationMetadata(resourceType string) bool { + switch resourceType { + case "gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard: + return true + default: + return strings.HasPrefix(resourceType, "mic_seat_") + } +} + func sanitizeProfileCardLayoutMetadata(layout *profileCardLayoutMetadataPayload) *profileCardLayoutMetadataPayload { if layout == nil { return nil diff --git a/services/wallet-service/internal/storage/mysql/cp_breakup_fee_test.go b/services/wallet-service/internal/storage/mysql/cp_breakup_fee_test.go deleted file mode 100644 index 1dadeabf..00000000 --- a/services/wallet-service/internal/storage/mysql/cp_breakup_fee_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package mysql_test - -import ( - "context" - "testing" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" - "hyapp/services/wallet-service/internal/testutil/mysqltest" -) - -func TestDebitCPBreakupFeeDebitsCoinAndReplaysIdempotently(t *testing.T) { - repo := mysqltest.NewRepository(t) - ctx := appcode.WithContext(context.Background(), appcode.Default) - repo.SetBalance(7001, 1000) - - command := ledger.CPBreakupFeeCommand{ - AppCode: appcode.Default, - CommandID: "cp_break_fee_1", - UserID: 7001, - RelationshipID: "cp_rel:7001:7002", - RelationType: "cp", - Amount: 88, - } - first, err := repo.DebitCPBreakupFee(ctx, command) - if err != nil { - t.Fatalf("debit cp breakup fee failed: %v", err) - } - if first.CoinSpent != 88 || first.CoinBalanceAfter != 912 || first.Balance.AvailableAmount != 912 { - t.Fatalf("receipt mismatch: %+v", first) - } - if repo.CountRows("wallet_transactions", "command_id = ?", command.CommandID) != 1 { - t.Fatalf("expected one wallet transaction") - } - if repo.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID) != 1 { - t.Fatalf("expected one wallet entry") - } - - second, err := repo.DebitCPBreakupFee(ctx, command) - if err != nil { - t.Fatalf("debit cp breakup fee replay failed: %v", err) - } - if second.TransactionID != first.TransactionID || second.CoinBalanceAfter != first.CoinBalanceAfter { - t.Fatalf("idempotent replay mismatch: first=%+v second=%+v", first, second) - } - if repo.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID) != 1 { - t.Fatalf("idempotent replay must not create another entry") - } - - command.Amount = 89 - if _, err := repo.DebitCPBreakupFee(ctx, command); xerr.CodeOf(err) != xerr.LedgerConflict { - t.Fatalf("same command with different amount must conflict, got %v", err) - } -} - -func TestDebitCPBreakupFeeRejectsInsufficientBalance(t *testing.T) { - repo := mysqltest.NewRepository(t) - ctx := appcode.WithContext(context.Background(), appcode.Default) - repo.SetBalance(7101, 10) - - _, err := repo.DebitCPBreakupFee(ctx, ledger.CPBreakupFeeCommand{ - AppCode: appcode.Default, - CommandID: "cp_break_fee_insufficient", - UserID: 7101, - RelationshipID: "cp_rel:7101:7102", - RelationType: "sister", - Amount: 20, - }) - if xerr.CodeOf(err) != xerr.InsufficientBalance { - t.Fatalf("insufficient balance must be rejected, got %v", err) - } - if repo.CountRows("wallet_transactions", "command_id = ?", "cp_break_fee_insufficient") != 0 { - t.Fatalf("insufficient balance must not create transaction") - } -}