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