2026-06-26 16:20:58 +08:00

1131 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package resource
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
const dayMillis int64 = 24 * 60 * 60 * 1000
const (
resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
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"
)
const (
badgeFormStrip = "strip"
badgeFormTile = "tile"
)
const (
badgeKindNormal = "normal"
badgeKindLevel = "level"
)
const (
avatarFrameKindNormal = "normal"
avatarFrameKindLevel = "level"
)
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
emojiPackPricingPaid = "paid"
)
type resourceRequest struct {
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Amount int64 `json:"amount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind"`
BadgeForm string `json:"badgeForm"`
BadgeKind string `json:"badgeKind"`
LevelTrack string `json:"levelTrack"`
MetadataJSON string `json:"metadataJson"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
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"`
PricingType string `json:"pricingType"`
CoverURL string `json:"coverUrl"`
AnimationURL string `json:"animationUrl"`
RegionIDs []int64 `json:"regionIds"`
SortOrder int32 `json:"sortOrder"`
}
type emojiPackMetadataPayload struct {
Category string `json:"category"`
PricingType string `json:"pricing_type"`
RegionIDs []int64 `json:"region_ids,omitempty"`
RegionScope string `json:"region_scope"`
}
type badgeMetadataPayload struct {
BadgeForm string `json:"badge_form"`
BadgeKind string `json:"badge_kind,omitempty"`
LevelTrack string `json:"level_track,omitempty"`
DefaultSlot string `json:"default_slot,omitempty"`
}
type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"`
AvatarFrameKind string `json:"avatar_frame_kind,omitempty"`
LevelTrack string `json:"level_track,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"`
}
type profileCardLayoutMetadataPayload struct {
SourceWidth int64 `json:"source_width"`
SourceHeight int64 `json:"source_height"`
ColorContentWidth int64 `json:"color_content_width"`
ContentTop int64 `json:"content_top"`
ContentBottom int64 `json:"content_bottom"`
ContentHeight int64 `json:"content_height"`
ContentTopRatio float64 `json:"content_top_ratio"`
ContentHeightRatio float64 `json:"content_height_ratio"`
DetectVersion int64 `json:"detect_version"`
}
type resourceGroupRequest struct {
GroupCode string `json:"groupCode"`
Name string `json:"name"`
Status string `json:"status"`
Description string `json:"description"`
SortOrder int32 `json:"sortOrder"`
Items []resourceGroupItemRequest `json:"items"`
}
type resourceGroupItemRequest struct {
ItemType string `json:"itemType"`
ResourceID int64 `json:"resourceId"`
AssetType string `json:"assetType"`
Amount int64 `json:"amount"`
DurationDays int64 `json:"durationDays"`
SortOrder int32 `json:"sortOrder"`
}
type giftRequest struct {
GiftID string `json:"giftId"`
ResourceID int64 `json:"resourceId"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sortOrder"`
PresentationJSON string `json:"presentationJson"`
PriceVersion string `json:"priceVersion"`
GiftTypeCode string `json:"giftTypeCode"`
CPRelationType string `json:"cpRelationType"`
ChargeAssetType string `json:"chargeAssetType"`
CoinPrice int64 `json:"coinPrice"`
HeatValue int64 `json:"heatValue"` // 兼容旧后台请求;新后台不再展示或提交热度。
EffectiveAtMS int64 `json:"effectiveAtMs"`
EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"`
EffectTypes []string `json:"effectTypes"`
RegionIDs []int64 `json:"regionIds"`
}
type giftTypeRequest struct {
DisplayName string `json:"displayName"`
TabName string `json:"tabName"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
}
type giftTypesRequest struct {
Items []giftTypeItemRequest `json:"items"`
}
type giftTypeItemRequest struct {
TabKey string `json:"tabKey"`
DisplayName string `json:"displayName"`
TabName string `json:"tabName"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
}
type grantResourceRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
ResourceID int64 `json:"resourceId"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"durationMs"`
Reason string `json:"reason"`
}
type grantGroupRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
GroupID int64 `json:"groupId"`
Reason string `json:"reason"`
}
type resourceShopItemsRequest struct {
Items []resourceShopItemRequest `json:"items"`
}
type resourceShopItemRequest struct {
ShopItemID int64 `json:"shopItemId"`
ResourceID int64 `json:"resourceId"`
Status string `json:"status"`
DurationDays int32 `json:"durationDays"`
EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"`
SortOrder int32 `json:"sortOrder"`
}
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
PriceType: priceType,
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
}
func parseGrantTargetUserID(value any) (int64, error) {
// Admin 前端会先把短号解析成内部 user_id内部 user_id 已超过 JS 安全整数,
// 所以新请求用字符串传递,避免浏览器把 318705991371722752 这类 ID 四舍五入。
switch typed := value.(type) {
case string:
userID, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil || userID <= 0 {
return 0, fmt.Errorf("target_user_id is invalid")
}
return userID, nil
case json.Number:
userID, err := typed.Int64()
if err != nil || userID <= 0 {
return 0, fmt.Errorf("target_user_id is invalid")
}
return userID, nil
case float64:
// 兼容旧 admin-platform 对短号或小整数 user_id 传 JSON number 的请求;
// 长 ID 必须走字符串,否则到这里前精度已经不可恢复。
userID := int64(typed)
if float64(userID) != typed || userID <= 0 {
return 0, fmt.Errorf("target_user_id is invalid")
}
return userID, nil
case int64:
if typed <= 0 {
return 0, fmt.Errorf("target_user_id is invalid")
}
return typed, nil
case int:
if typed <= 0 {
return 0, fmt.Errorf("target_user_id is invalid")
}
return int64(typed), nil
default:
return 0, fmt.Errorf("target_user_id is required")
}
}
func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
PriceType: priceType,
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
}
func (r emojiPackRequest) createProto(c *gin.Context) (*walletv1.CreateResourceRequest, error) {
name := strings.TrimSpace(r.Name)
coverURL := strings.TrimSpace(r.CoverURL)
animationURL := strings.TrimSpace(r.AnimationURL)
if name == "" || coverURL == "" || animationURL == "" {
return nil, fmt.Errorf("表情包参数不完整")
}
category := normalizeEmojiPackCategory(r.Category)
if utf8.RuneCountInString(category) > 64 {
return nil, fmt.Errorf("表情包分类不能超过 64 个字符")
}
metadataJSON, err := emojiPackMetadataJSON(r.RegionIDs, category, r.PricingType)
if err != nil {
return nil, err
}
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceCode: fmt.Sprintf("emoji_pack_%d", time.Now().UTC().UnixNano()),
ResourceType: resourceTypeEmojiPack,
Name: name,
Status: "active",
Grantable: false,
ManagerGrantEnabled: boolPointer(false),
GrantStrategy: "increase_quantity",
UsageScopes: []string{resourceTypeEmojiPack},
AssetUrl: coverURL,
PreviewUrl: coverURL,
AnimationUrl: animationURL,
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}, nil
}
func (r resourceGroupRequest) createProto(c *gin.Context) *walletv1.CreateResourceGroupRequest {
return &walletv1.CreateResourceGroupRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GroupCode: strings.TrimSpace(r.GroupCode),
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Description: strings.TrimSpace(r.Description),
SortOrder: r.SortOrder,
Items: groupItemInputs(r.Items),
OperatorUserId: actorID(c),
}
}
func (r resourceGroupRequest) updateProto(c *gin.Context, groupID int64) *walletv1.UpdateResourceGroupRequest {
return &walletv1.UpdateResourceGroupRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GroupId: groupID,
GroupCode: strings.TrimSpace(r.GroupCode),
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Description: strings.TrimSpace(r.Description),
SortOrder: r.SortOrder,
Items: groupItemInputs(r.Items),
OperatorUserId: actorID(c),
}
}
func (r giftRequest) createProto(c *gin.Context) *walletv1.CreateGiftConfigRequest {
return &walletv1.CreateGiftConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GiftId: strings.TrimSpace(r.GiftID),
ResourceId: r.ResourceID,
Status: strings.TrimSpace(r.Status),
Name: strings.TrimSpace(r.Name),
SortOrder: r.SortOrder,
PresentationJson: strings.TrimSpace(r.PresentationJSON),
PriceVersion: strings.TrimSpace(r.PriceVersion),
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
CpRelationType: r.cpRelationType(),
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
CoinPrice: r.CoinPrice,
GiftPointAmount: 0,
HeatValue: r.CoinPrice,
EffectiveAtMs: r.EffectiveAtMS,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
EffectTypes: r.EffectTypes,
OperatorUserId: actorID(c),
RegionIds: r.RegionIDs,
}
}
func (r giftRequest) updateProto(c *gin.Context, giftID string) *walletv1.UpdateGiftConfigRequest {
return &walletv1.UpdateGiftConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GiftId: strings.TrimSpace(giftID),
ResourceId: r.ResourceID,
Status: strings.TrimSpace(r.Status),
Name: strings.TrimSpace(r.Name),
SortOrder: r.SortOrder,
PresentationJson: strings.TrimSpace(r.PresentationJSON),
PriceVersion: strings.TrimSpace(r.PriceVersion),
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
CpRelationType: r.cpRelationType(),
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
CoinPrice: r.CoinPrice,
GiftPointAmount: 0,
HeatValue: r.CoinPrice,
EffectiveAtMs: r.EffectiveAtMS,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
EffectTypes: r.EffectTypes,
OperatorUserId: actorID(c),
RegionIds: r.RegionIDs,
}
}
func (r giftRequest) cpRelationType() string {
if strings.TrimSpace(r.GiftTypeCode) != "cp" {
return ""
}
// CP 关系类型最终由 wallet-service 写回 presentation_json兼容旧前端只提交 JSON 的请求,避免保存时把兄弟/姐妹关系清掉。
if value := strings.TrimSpace(r.CPRelationType); value != "" {
return value
}
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(r.PresentationJSON)), &payload); err != nil {
return ""
}
if value, ok := payload["cp_relation_type"].(string); ok {
return strings.TrimSpace(value)
}
if value, ok := payload["cpRelationType"].(string); ok {
return strings.TrimSpace(value)
}
return ""
}
func (r giftTypeRequest) upsertProto(c *gin.Context, typeCode string) *walletv1.UpsertGiftTypeConfigRequest {
return &walletv1.UpsertGiftTypeConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
TypeCode: strings.TrimSpace(typeCode),
Name: strings.TrimSpace(r.DisplayName),
TabKey: strings.TrimSpace(r.TabName),
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
}
func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTypeConfigRequest {
return giftTypeRequest{
DisplayName: r.DisplayName,
TabName: r.TabName,
Status: r.Status,
SortOrder: r.SortOrder,
}.upsertProto(c, r.TabKey)
}
func (r resourceShopItemsRequest) upsertProto(c *gin.Context) *walletv1.UpsertResourceShopItemsRequest {
return &walletv1.UpsertResourceShopItemsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Items: resourceShopItemInputs(r.Items),
OperatorUserId: actorID(c),
}
}
func managerGrantEnabledOrDefault(value *bool) *bool {
enabled := false
if value != nil {
enabled = *value
}
return &enabled
}
func groupItemInputs(items []resourceGroupItemRequest) []*walletv1.ResourceGroupItemInput {
out := make([]*walletv1.ResourceGroupItemInput, 0, len(items))
for _, item := range items {
itemType := strings.ToLower(strings.TrimSpace(item.ItemType))
if itemType == "wallet_asset" {
out = append(out, &walletv1.ResourceGroupItemInput{
ItemType: itemType,
WalletAssetType: strings.TrimSpace(item.AssetType),
WalletAssetAmount: item.Amount,
Quantity: 1,
SortOrder: item.SortOrder,
})
continue
}
out = append(out, &walletv1.ResourceGroupItemInput{
ItemType: "resource",
ResourceId: item.ResourceID,
Quantity: 1,
DurationMs: item.DurationDays * dayMillis,
SortOrder: item.SortOrder,
})
}
return out
}
func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.ResourceShopItemInput {
out := make([]*walletv1.ResourceShopItemInput, 0, len(items))
for _, item := range items {
out = append(out, &walletv1.ResourceShopItemInput{
ShopItemId: item.ShopItemID,
ResourceId: item.ResourceID,
Status: strings.TrimSpace(item.Status),
DurationDays: item.DurationDays,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
SortOrder: item.SortOrder,
})
}
return out
}
func normalizeResourceType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func resourceWalletAsset(resourceType string, amount int64) (string, int64) {
if resourceType == resourceTypeCoin {
return "COIN", amount
}
return "", 0
}
func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) {
priceType = strings.ToLower(strings.TrimSpace(priceType))
if priceType == resourcePriceTypeFree {
return resourcePriceTypeFree, 0, 0
}
return resourcePriceTypeCoin, coinPrice, 0
}
func validateResourcePricing(req resourceRequest) error {
priceType := strings.ToLower(strings.TrimSpace(req.PriceType))
switch priceType {
case resourcePriceTypeCoin:
if req.CoinPrice <= 0 {
return fmt.Errorf("请输入资源价格")
}
case resourcePriceTypeFree:
return nil
default:
return fmt.Errorf("请选择资源价格类型")
}
return nil
}
func validateResourceBadgeForm(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeBadge {
return nil
}
if normalizeBadgeForm(req.BadgeForm) == "" {
return fmt.Errorf("请选择徽章属性")
}
badgeKind := normalizeBadgeKind(req.BadgeKind)
if badgeKind == "" {
return fmt.Errorf("请选择徽章类型")
}
if badgeKind == badgeKindLevel && !validLevelTrack(req.LevelTrack) {
return fmt.Errorf("请选择等级徽章所属等级")
}
return nil
}
func validateResourceAvatarFrameKind(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeAvatarFrame {
return nil
}
avatarFrameKind := normalizeAvatarFrameKind(req.AvatarFrameKind)
if avatarFrameKind == "" {
return fmt.Errorf("请选择头像框类型")
}
if avatarFrameKind == avatarFrameKindLevel && !validLevelTrack(req.LevelTrack) {
return fmt.Errorf("请选择等级头像框所属等级")
}
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:
return "wallet_credit"
case resourceTypeAvatarFrame, "profile_card", "vehicle", "chat_bubble":
return "extend_expiry"
case "badge":
return "set_active_flag"
default:
return "increase_quantity"
}
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string {
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
}
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil {
return "{}"
}
if rawPayload == nil {
rawPayload = &resourceMetadataPayload{}
}
payload := resourceMetadataPayload{}
if resourceType == resourceTypeProfileCard {
payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout)
}
if resourceType == resourceTypeAvatarFrame {
kind := normalizeAvatarFrameKind(avatarFrameKind)
payload.AvatarFrameKind = kind
if kind == avatarFrameKindLevel {
// 等级头像框会被等级奖励任务按轨道过滤并发放;普通头像框不写 level_track避免商店、经理赠送等入口误判。
payload.LevelTrack = normalizeLevelTrack(levelTrack)
}
}
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 {
payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm), BadgeKind: normalizeBadgeKind(badgeKind)}
switch payload.BadgeForm {
case badgeFormStrip:
payload.DefaultSlot = "profile_strip"
case badgeFormTile:
payload.DefaultSlot = "honor_wall"
default:
return "{}"
}
if payload.BadgeKind == badgeKindLevel {
payload.LevelTrack = normalizeLevelTrack(levelTrack)
if payload.LevelTrack == "" {
return "{}"
}
}
body, err := json.Marshal(payload)
if err != nil {
return "{}"
}
return string(body)
}
func profileCardMetadataJSON(metadataJSON string) string {
raw := strings.TrimSpace(metadataJSON)
if raw == "" {
return "{}"
}
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "{}"
}
layout := sanitizeProfileCardLayoutMetadata(payload.ProfileCardLayout)
if layout == nil {
return "{}"
}
// 资料卡上传解析只允许写入布局字段;不要把后台临时调试数据透给 app。
body, err := json.Marshal(profileCardMetadataPayload{ProfileCardLayout: layout})
if err != nil {
return "{}"
}
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.AvatarFrameKind == "" && payload.LevelTrack == "" && 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
}
if layout.SourceWidth <= 0 || layout.SourceHeight <= 0 {
return nil
}
if layout.ContentTop < 0 || layout.ContentBottom < layout.ContentTop || layout.ContentBottom >= layout.SourceHeight {
return nil
}
contentHeight := layout.ContentBottom - layout.ContentTop + 1
if layout.ContentHeight != contentHeight {
return nil
}
colorContentWidth := layout.ColorContentWidth
if colorContentWidth <= 0 || colorContentWidth > layout.SourceWidth {
colorContentWidth = layout.SourceWidth
}
// 比例字段由整数边界重新计算,避免前端浮点误差或脏数据进入客户端展示协议。
return &profileCardLayoutMetadataPayload{
SourceWidth: layout.SourceWidth,
SourceHeight: layout.SourceHeight,
ColorContentWidth: colorContentWidth,
ContentTop: layout.ContentTop,
ContentBottom: layout.ContentBottom,
ContentHeight: contentHeight,
ContentTopRatio: profileCardRatio(layout.ContentTop, layout.SourceHeight),
ContentHeightRatio: profileCardRatio(contentHeight, layout.SourceHeight),
DetectVersion: 1,
}
}
func profileCardRatio(value int64, total int64) float64 {
if total <= 0 {
return 0
}
return float64(value) / float64(total)
}
func normalizeBadgeForm(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case badgeFormStrip, "long":
return badgeFormStrip
case badgeFormTile, "short":
return badgeFormTile
default:
return ""
}
}
func normalizeBadgeKind(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", badgeKindNormal:
return badgeKindNormal
case badgeKindLevel:
return badgeKindLevel
default:
return ""
}
}
func normalizeAvatarFrameKind(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", avatarFrameKindNormal:
return avatarFrameKindNormal
case avatarFrameKindLevel:
return avatarFrameKindLevel
default:
return ""
}
}
func normalizeLevelTrack(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if validLevelTrack(value) {
return value
}
return ""
}
func validLevelTrack(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "wealth", "game", "charm":
return true
default:
return false
}
}
func badgeFormFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeBadgeForm(payload.BadgeForm)
}
func badgeKindFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return badgeKindNormal
}
return normalizeBadgeKind(payload.BadgeKind)
}
func badgeLevelTrackFromMetadata(metadataJSON string) string {
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeLevelTrack(payload.LevelTrack)
}
func avatarFrameKindFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return avatarFrameKindNormal
}
kind := normalizeAvatarFrameKind(payload.AvatarFrameKind)
if kind == "" {
return avatarFrameKindNormal
}
return kind
}
func avatarFrameLevelTrackFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeLevelTrack(payload.LevelTrack)
}
func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
return badgeFormFromMetadata(metadataJSON)
}
func badgeKindForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
kind := badgeKindFromMetadata(metadataJSON)
if kind == "" {
return badgeKindNormal
}
return kind
}
func badgeLevelTrackForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
}
return badgeLevelTrackFromMetadata(metadataJSON)
}
func avatarFrameKindForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameKindFromMetadata(metadataJSON)
}
func avatarFrameLevelTrackForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameLevelTrackFromMetadata(metadataJSON)
}
func levelTrackForResource(resourceType string, metadataJSON string) string {
switch normalizeResourceType(resourceType) {
case resourceTypeBadge:
return badgeLevelTrackFromMetadata(metadataJSON)
case resourceTypeAvatarFrame:
return avatarFrameLevelTrackFromMetadata(metadataJSON)
default:
return ""
}
}
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{
Category: normalizeEmojiPackCategory(category),
PricingType: normalizeEmojiPackPricingType(pricingType),
RegionScope: "all",
}
if utf8.RuneCountInString(metadata.Category) > 64 {
return "", fmt.Errorf("表情包分类不能超过 64 个字符")
}
if len(normalizedRegionIDs) > 0 {
metadata.RegionScope = "limited"
metadata.RegionIDs = normalizedRegionIDs
}
body, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("表情包配置不正确")
}
return string(body), nil
}
func emojiPackMetadataFromJSON(metadataJSON string) emojiPackMetadataPayload {
payload := emojiPackMetadataPayload{
Category: defaultEmojiPackCategory,
PricingType: emojiPackPricingFree,
RegionScope: "all",
}
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
return payload
}
payload.Category = normalizeEmojiPackCategory(payload.Category)
payload.PricingType = normalizeEmojiPackPricingType(payload.PricingType)
payload.RegionIDs = normalizeRegionIDs(payload.RegionIDs)
if len(payload.RegionIDs) > 0 {
payload.RegionScope = "limited"
} else {
payload.RegionScope = "all"
}
return payload
}
func normalizeEmojiPackCategory(value string) string {
category := strings.TrimSpace(value)
if category == "" {
return defaultEmojiPackCategory
}
return category
}
func normalizeEmojiPackPricingType(value string) string {
if strings.ToLower(strings.TrimSpace(value)) == emojiPackPricingPaid {
return emojiPackPricingPaid
}
return emojiPackPricingFree
}
func normalizeRegionIDs(regionIDs []int64) []int64 {
out := make([]int64, 0, len(regionIDs))
seen := make(map[int64]struct{}, len(regionIDs))
for _, regionID := range regionIDs {
if regionID == 0 {
return nil
}
if regionID < 0 {
continue
}
if _, ok := seen[regionID]; ok {
continue
}
seen[regionID] = struct{}{}
out = append(out, regionID)
}
return out
}
func boolPointer(value bool) *bool {
return &value
}