礼物布局

This commit is contained in:
zhx 2026-06-09 13:14:25 +08:00
parent 5d2c39f8db
commit 7f16956b7d
4 changed files with 353 additions and 82 deletions

View File

@ -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 {

View File

@ -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
}`
}

View File

@ -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

View File

@ -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")
}
}