spark-server/internal/module/message/entity/content.go
zhr ae9a71034d feat: implement Spark IM V3 modular monolith
Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:11 +08:00

299 lines
6.2 KiB
Go

package entity
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
// Content is the in-memory message body. Persist only Marshal() bytes into messages.content.
type Content struct {
Type Type `json:"t"`
Text *Text `json:"text,omitempty"`
Image *Image `json:"image,omitempty"`
Voice *Voice `json:"voice,omitempty"`
Video *Video `json:"video,omitempty"`
Emoji *Emoji `json:"emoji,omitempty"`
GIF *GIF `json:"gif,omitempty"`
File *File `json:"file,omitempty"`
Location *Location `json:"location,omitempty"`
System *System `json:"system,omitempty"`
}
func NewText(body string) *Content {
return &Content{Type: TypeText, Text: &Text{Body: body}}
}
func NewImage(img *Image) *Content {
return &Content{Type: TypeImage, Image: img}
}
func NewVoice(v *Voice) *Content {
return &Content{Type: TypeVoice, Voice: v}
}
func NewVideo(v *Video) *Content {
return &Content{Type: TypeVideo, Video: v}
}
func NewEmoji(e *Emoji) *Content {
return &Content{Type: TypeEmoji, Emoji: e}
}
func NewGIF(g *GIF) *Content {
return &Content{Type: TypeGIF, GIF: g}
}
func NewFile(f *File) *Content {
return &Content{Type: TypeFile, File: f}
}
func NewLocation(loc *Location) *Content {
return &Content{Type: TypeLocation, Location: loc}
}
func NewSystem(s *System) *Content {
return &Content{Type: TypeSystem, System: s}
}
// Marshal serializes content to JSON bytes for the BLOB column. It never returns a Go string.
func (c *Content) Marshal() ([]byte, error) {
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("content: %w", err)
}
b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})
if len(b) > MaxContentBytes {
return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
}
return b, nil
}
// UnmarshalContent decodes stored BLOB bytes. Rejects a plain UTF-8 string that is not JSON.
func UnmarshalContent(raw []byte) (*Content, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("content: empty")
}
if len(raw) > MaxContentBytes {
return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
}
var c Content
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("content: %w", err)
}
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
func (c *Content) Validate() error {
if c == nil {
return fmt.Errorf("content: empty")
}
if !c.Type.Valid() {
return fmt.Errorf("content: unknown type %d", c.Type)
}
got, n := c.populated()
if n != 1 {
return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n)
}
if got != c.Type {
return fmt.Errorf("content: type %d does not match payload %d", c.Type, got)
}
switch c.Type {
case TypeText:
return validateText(c.Text)
case TypeImage:
return validateMediaKey("image", c.Image.Key)
case TypeVoice:
return validateVoice(c.Voice)
case TypeVideo:
return validateMediaKey("video", c.Video.Key)
case TypeEmoji:
return validateEmoji(c.Emoji)
case TypeGIF:
return validateMediaKey("gif", c.GIF.Key)
case TypeFile:
return validateFile(c.File)
case TypeLocation:
return validateLocation(c.Location)
case TypeSystem:
return validateSystem(c.System)
default:
return fmt.Errorf("content: unknown type %d", c.Type)
}
}
func (c *Content) populated() (Type, int) {
n := 0
var t Type
if c.Text != nil {
n++
t = TypeText
}
if c.Image != nil {
n++
t = TypeImage
}
if c.Voice != nil {
n++
t = TypeVoice
}
if c.Video != nil {
n++
t = TypeVideo
}
if c.Emoji != nil {
n++
t = TypeEmoji
}
if c.GIF != nil {
n++
t = TypeGIF
}
if c.File != nil {
n++
t = TypeFile
}
if c.Location != nil {
n++
t = TypeLocation
}
if c.System != nil {
n++
t = TypeSystem
}
return t, n
}
func validateText(t *Text) error {
body := strings.TrimSpace(t.Body)
if body == "" {
return fmt.Errorf("content: text empty")
}
if !utf8.ValidString(t.Body) {
return fmt.Errorf("content: text not utf-8")
}
return nil
}
func validateMediaKey(kind, key string) error {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("content: %s key empty", kind)
}
return nil
}
func validateVoice(v *Voice) error {
if err := validateMediaKey("voice", v.Key); err != nil {
return err
}
if v.DurationMS < 0 {
return fmt.Errorf("content: voice duration")
}
return nil
}
func validateEmoji(e *Emoji) error {
if strings.TrimSpace(e.Pack) == "" || strings.TrimSpace(e.ID) == "" {
return fmt.Errorf("content: emoji pack/id empty")
}
return nil
}
func validateFile(f *File) error {
if err := validateMediaKey("file", f.Key); err != nil {
return err
}
if strings.TrimSpace(f.Name) == "" {
return fmt.Errorf("content: file name empty")
}
if f.Size < 0 {
return fmt.Errorf("content: file size")
}
return nil
}
func validateLocation(l *Location) error {
if l.Lat < -90 || l.Lat > 90 || l.Lng < -180 || l.Lng > 180 {
return fmt.Errorf("content: location range")
}
return nil
}
func validateSystem(s *System) error {
if strings.TrimSpace(s.Kind) == "" {
return fmt.Errorf("content: system kind empty")
}
return nil
}
// ObjectKeys returns media object keys in this payload (image/voice/video/gif/file).
func (c *Content) ObjectKeys() []string {
if c == nil {
return nil
}
var keys []string
add := func(k string) {
k = strings.TrimSpace(k)
if k != "" {
keys = append(keys, k)
}
}
switch {
case c.Image != nil:
add(c.Image.Key)
add(c.Image.ThumbKey)
case c.Voice != nil:
add(c.Voice.Key)
case c.Video != nil:
add(c.Video.Key)
add(c.Video.CoverKey)
case c.GIF != nil:
add(c.GIF.Key)
case c.File != nil:
add(c.File.Key)
}
return keys
}
// ReplaceObjectKeys rewrites keys after CopyObject into perm/fav/.
func (c *Content) ReplaceObjectKeys(mapping map[string]string) {
if c == nil || len(mapping) == 0 {
return
}
repl := func(s *string) {
if s == nil {
return
}
if n, ok := mapping[*s]; ok {
*s = n
}
}
if c.Image != nil {
repl(&c.Image.Key)
repl(&c.Image.ThumbKey)
}
if c.Voice != nil {
repl(&c.Voice.Key)
}
if c.Video != nil {
repl(&c.Video.Key)
repl(&c.Video.CoverKey)
}
if c.GIF != nil {
repl(&c.GIF.Key)
}
if c.File != nil {
repl(&c.File.Key)
}
}