spark-server/internal/module/moment/entity/content.go
2026-09-18 17:51:42 +08:00

164 lines
3.9 KiB
Go

package entity
import (
"bytes"
"encoding/json"
"fmt"
"strings"
uploadentity "spark-server/internal/module/upload/entity"
)
const maxContentBytes = 32 << 10
// Content is the persisted JSON for images/video. Empty object for text-only posts.
type Content struct {
Images []*Image `json:"images,omitempty"`
Video *Video `json:"video,omitempty"`
}
// Image matches chat image JSON field names so clients reuse the same decoder.
type Image struct {
Key string `json:"key"`
Mime string `json:"mime,omitempty"`
Width int `json:"w,omitempty"`
Height int `json:"h,omitempty"`
Size int64 `json:"size,omitempty"`
ThumbKey string `json:"thumb_key,omitempty"`
OrigKey string `json:"orig_key,omitempty"`
}
// Video matches chat video JSON field names.
type Video struct {
Key string `json:"key"`
Mime string `json:"mime,omitempty"`
Width int `json:"w,omitempty"`
Height int `json:"h,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
Size int64 `json:"size,omitempty"`
CoverKey string `json:"cover_key,omitempty"`
}
func (c *Content) Marshal() ([]byte, error) {
if c == nil {
c = &Content{}
}
if err := c.Validate(); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(c); err != nil {
return nil, fmt.Errorf("moment content: %w", err)
}
b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})
if len(b) > maxContentBytes {
return nil, fmt.Errorf("moment content: too large")
}
return b, nil
}
func UnmarshalContent(raw []byte) (*Content, error) {
if len(raw) == 0 {
return &Content{}, nil
}
var c Content
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("moment content: %w", err)
}
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
func (c *Content) Validate() error {
if c == nil {
return nil
}
if len(c.Images) > 0 && c.Video != nil {
return fmt.Errorf("moment content: image and video")
}
if len(c.Images) > MaxImages {
return fmt.Errorf("moment content: too many images")
}
for _, img := range c.Images {
if err := img.validate(); err != nil {
return err
}
}
if c.Video != nil {
return c.Video.validate()
}
return nil
}
func (c *Content) Kind() Kind {
if c != nil && c.Video != nil {
return KindVideo
}
if c != nil && len(c.Images) > 0 {
return KindImage
}
return KindText
}
func (c *Content) ObjectKeys() []string {
if c == nil {
return nil
}
out := make([]string, 0, 12)
for _, img := range c.Images {
out = appendKeys(out, img.Key, img.ThumbKey, img.OrigKey)
}
if c.Video != nil {
out = appendKeys(out, c.Video.Key, c.Video.CoverKey)
}
return out
}
func (img *Image) validate() error {
if img == nil || !momentKey(img.Key, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: image key")
}
if img.ThumbKey != "" && !momentKey(img.ThumbKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: thumb")
}
if img.OrigKey != "" && !momentKey(img.OrigKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: orig")
}
return nil
}
func (v *Video) validate() error {
if v == nil || !momentKey(v.Key, uploadentity.PrefixMomentVideo) {
return fmt.Errorf("moment content: video key")
}
if v.DurationMS <= 0 || v.DurationMS > MaxVideoMS {
return fmt.Errorf("moment content: duration")
}
if v.CoverKey != "" && !momentKey(v.CoverKey, uploadentity.PrefixMomentImage) {
return fmt.Errorf("moment content: cover")
}
return nil
}
func momentKey(key, prefix string) bool {
key = strings.TrimSpace(key)
return uploadentity.ValidObjectKey(key) && strings.HasPrefix(key, prefix)
}
func appendKeys(dst []string, keys ...string) []string {
for _, k := range keys {
if k != "" {
dst = append(dst, k)
}
}
return dst
}
func ValidCoverKey(key string) bool {
return momentKey(key, uploadentity.PrefixMomentCover)
}