package ledger import ( "encoding/json" "errors" "fmt" "strings" "unicode/utf8" ) const ( vipPresentationDescriptionMaxRunes = 1000 vipPresentationPreviewMaxCount = 20 vipPresentationTextMaxRunes = 128 vipPresentationURLMaxRunes = 2048 ) // ParseVipBenefitPresentation 只解析 metadata_json.presentation 这一段稳定展示契约。 // metadata_json 的其它业务字段(例如 coin_amount、message)由对应 owner 保留处理。 func ParseVipBenefitPresentation(metadataJSON string) (*VipBenefitPresentation, error) { metadataJSON = strings.TrimSpace(metadataJSON) if metadataJSON == "" { return nil, nil } var metadata struct { Presentation *VipBenefitPresentation `json:"presentation"` } if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return nil, err } if metadata.Presentation == nil { return nil, nil } if err := ValidateVipBenefitPresentation(*metadata.Presentation); err != nil { return nil, err } return metadata.Presentation, nil } // MergeVipBenefitPresentation 将后台结构化输入归一到现有 JSON 列,避免为纯展示字段增加双写事实。 // presentation 为空对象表示显式清除;nil 表示沿用 metadata_json 中已有值。 func MergeVipBenefitPresentation(metadataJSON string, presentation *VipBenefitPresentation) (string, error) { metadataJSON = strings.TrimSpace(metadataJSON) if presentation == nil { if metadataJSON == "" { return "", nil } if !json.Valid([]byte(metadataJSON)) { return "", errors.New("metadata_json is invalid") } return metadataJSON, nil } if err := ValidateVipBenefitPresentation(*presentation); err != nil { return "", err } metadata := make(map[string]json.RawMessage) if metadataJSON != "" { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil || metadata == nil { return "", errors.New("metadata_json must be an object") } } if vipBenefitPresentationIsZero(*presentation) { delete(metadata, "presentation") } else { encoded, err := json.Marshal(presentation) if err != nil { return "", err } metadata["presentation"] = encoded } if len(metadata) == 0 { return "", nil } encoded, err := json.Marshal(metadata) return string(encoded), err } // ValidateVipBenefitPresentation 锁定 Flutter 可直接渲染的边界,防止后台把任意 JSON // 重新变成客户端按 benefit_code 猜类型的隐式协议。 func ValidateVipBenefitPresentation(presentation VipBenefitPresentation) error { presentation.Description = strings.TrimSpace(presentation.Description) if utf8.RuneCountInString(presentation.Description) > vipPresentationDescriptionMaxRunes { return errors.New("presentation description is too long") } if len(presentation.PreviewItems) > vipPresentationPreviewMaxCount { return errors.New("presentation has too many preview items") } seen := make(map[string]struct{}, len(presentation.PreviewItems)) for index, item := range presentation.PreviewItems { item.PreviewID = strings.TrimSpace(item.PreviewID) item.Title = strings.TrimSpace(item.Title) item.MediaType = strings.ToLower(strings.TrimSpace(item.MediaType)) item.CompositionType = strings.ToLower(strings.TrimSpace(item.CompositionType)) if item.PreviewID == "" || utf8.RuneCountInString(item.PreviewID) > vipPresentationTextMaxRunes { return fmt.Errorf("preview_items[%d].preview_id is invalid", index) } if _, exists := seen[item.PreviewID]; exists { return fmt.Errorf("preview_items[%d].preview_id is duplicated", index) } seen[item.PreviewID] = struct{}{} if utf8.RuneCountInString(item.Title) > vipPresentationTextMaxRunes { return fmt.Errorf("preview_items[%d].title is too long", index) } if item.MediaType != "image" && item.MediaType != "animation" { return fmt.Errorf("preview_items[%d].media_type is invalid", index) } switch item.CompositionType { case "none", "user_avatar_center", "user_avatar_waveform": default: return fmt.Errorf("preview_items[%d].composition_type is invalid", index) } if exceedsRuneLimit(item.AssetURL, vipPresentationURLMaxRunes) || exceedsRuneLimit(item.PreviewURL, vipPresentationURLMaxRunes) || exceedsRuneLimit(item.AnimationURL, vipPresentationURLMaxRunes) { return fmt.Errorf("preview_items[%d] url is too long", index) } if item.MediaType == "animation" && strings.TrimSpace(item.AnimationURL) == "" { return fmt.Errorf("preview_items[%d].animation_url is required", index) } if item.MediaType == "image" && strings.TrimSpace(item.PreviewURL) == "" && strings.TrimSpace(item.AssetURL) == "" { return fmt.Errorf("preview_items[%d] image url is required", index) } } if reward := presentation.NumericReward; reward != nil { reward.Label = strings.TrimSpace(reward.Label) reward.Unit = strings.TrimSpace(reward.Unit) reward.Period = strings.ToLower(strings.TrimSpace(reward.Period)) if reward.Value <= 0 || reward.Label == "" || reward.Unit == "" || utf8.RuneCountInString(reward.Label) > vipPresentationTextMaxRunes || utf8.RuneCountInString(reward.Unit) > vipPresentationTextMaxRunes { return errors.New("numeric_reward is invalid") } switch reward.Period { case "once", "daily", "monthly": default: return errors.New("numeric_reward.period is invalid") } } return nil } func vipBenefitPresentationIsZero(presentation VipBenefitPresentation) bool { return strings.TrimSpace(presentation.Description) == "" && len(presentation.PreviewItems) == 0 && presentation.NumericReward == nil } func exceedsRuneLimit(value string, limit int) bool { return utf8.RuneCountInString(strings.TrimSpace(value)) > limit }