spark-server/internal/module/group/logic/avatar.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

138 lines
3.5 KiB
Go

package logic
import (
"bytes"
"context"
"image"
_ "image/gif" // member avatars
"image/jpeg"
_ "image/png" // member avatars
"strconv"
"strings"
"spark-server/internal/module/group/entity"
uploadentity "spark-server/internal/module/upload/entity"
userentity "spark-server/internal/module/user/entity"
)
const collageJPEG = "image/jpeg"
func (s *Service) CanSetAvatar(ctx context.Context, userID, groupID int64) error {
if _, err := s.loadActive(ctx, groupID); err != nil {
return err
}
_, err := s.requireManage(ctx, groupID, userID)
return err
}
func validGroupAvatar(groupID int64, key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
if groupID <= 0 || !uploadentity.ValidObjectKey(key) {
return false
}
return strings.HasPrefix(key, uploadentity.PrefixGroup+strconv.FormatInt(groupID, 10)+"/")
}
func (s *Service) stampAvatar(ctx context.Context, g *entity.Group, ownerID int64, invitees []int64) {
if s.objects == nil || g == nil || g.ID <= 0 {
return
}
raw, err := encodeCollage(s.memberFaces(ctx, ownerID, invitees))
if err == nil && s.putAvatar(ctx, g, raw, collageJPEG, "jpg") {
return
}
s.stampFallbackAvatar(ctx, g)
}
func (s *Service) stampFallbackAvatar(ctx context.Context, g *entity.Group) {
raw, err := userentity.LetterAvatarPNG(g.Name)
if err == nil && s.putAvatar(ctx, g, raw, "image/png", "png") {
return
}
raw, err = encodeCollage([]image.Image{entity.PlaceholderFace()})
if err != nil {
return
}
s.putAvatar(ctx, g, raw, collageJPEG, "jpg")
}
func (s *Service) putAvatar(ctx context.Context, g *entity.Group, raw []byte, mime, ext string) bool {
if g == nil || len(raw) == 0 {
return false
}
key := uploadentity.PrefixGroup + strconv.FormatInt(g.ID, 10) + "/default." + ext
if objectID, err := s.ids.NextID(); err == nil {
key = uploadentity.BuildObjectKey(uploadentity.PurposeGroupAvatar, g.ID, objectID, "", ext)
}
if err := s.objects.Put(ctx, key, raw, mime); err != nil {
return false
}
g.Avatar = key
if err := s.repo.UpdateProfile(ctx, g); err != nil {
g.Avatar = ""
return false
}
return true
}
func (s *Service) memberFaces(ctx context.Context, ownerID int64, invitees []int64) []image.Image {
uids := append([]int64{ownerID}, invitees...)
if len(uids) > entity.MaxCollageFaces {
uids = uids[:entity.MaxCollageFaces]
}
out := make([]image.Image, 0, len(uids))
for _, uid := range uids {
out = append(out, s.faceOf(ctx, uid))
}
return out
}
func (s *Service) faceOf(ctx context.Context, uid int64) image.Image {
u, err := s.users.Get(ctx, uid)
if err == nil && u != nil {
if img, ok := decodeAvatar(s.avatarBytes(ctx, u.Avatar)); ok {
return img
}
if raw, err := userentity.LetterAvatarPNG(u.Nickname); err == nil {
if img, ok := decodeAvatar(raw); ok {
return img
}
}
}
return entity.PlaceholderFace()
}
func (s *Service) avatarBytes(ctx context.Context, key string) []byte {
key = strings.TrimSpace(key)
if !uploadentity.ValidObjectKey(key) {
return nil
}
raw, err := s.objects.Get(ctx, key)
if err != nil {
return nil
}
return raw
}
func decodeAvatar(raw []byte) (image.Image, bool) {
if len(raw) == 0 {
return nil, false
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, false
}
return img, true
}
func encodeCollage(faces []image.Image) ([]byte, error) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, entity.Collage(faces), &jpeg.Options{Quality: 85}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}