146 lines
5.5 KiB
Go
146 lines
5.5 KiB
Go
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
|
||
}
|