feat(kol): add avatar upload to KolProfileService
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
kolAvatarMaxSizeBytes = 2 * 1024 * 1024
|
||||
kolAvatarMaxDimension = 2048
|
||||
)
|
||||
|
||||
var kolAvatarAllowedMIMEs = map[string]struct{}{
|
||||
"image/png": {},
|
||||
"image/jpeg": {},
|
||||
"image/gif": {},
|
||||
"image/webp": {},
|
||||
}
|
||||
|
||||
type KolAvatarUploadResponse struct {
|
||||
URL string `json:"url"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
func (s *KolProfileService) UploadAvatar(ctx context.Context, actor auth.Actor, filename string, content []byte) (*KolAvatarUploadResponse, error) {
|
||||
if s.objectStorage == nil {
|
||||
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
profile, err := s.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.objectStorage.Validate(); err != nil {
|
||||
if errors.Is(err, objectstorage.ErrNotConfigured) {
|
||||
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
return nil, response.ErrInternal(50064, "kol_avatar_storage_validation_failed", err.Error())
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file is required")
|
||||
}
|
||||
if len(content) > kolAvatarMaxSizeBytes {
|
||||
return nil, response.ErrBadRequest(40005, "kol_avatar_too_large", "avatar size cannot exceed 2 MB")
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(content)
|
||||
if _, ok := kolAvatarAllowedMIMEs[contentType]; !ok {
|
||||
return nil, response.ErrBadRequest(40006, "kol_avatar_type_not_supported", "only PNG, JPG, GIF, or WebP is supported")
|
||||
}
|
||||
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(content))
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded")
|
||||
}
|
||||
if cfg.Width > kolAvatarMaxDimension || cfg.Height > kolAvatarMaxDimension {
|
||||
return nil, response.ErrBadRequest(40007, "kol_avatar_dimensions_too_large", "avatar dimensions exceed the maximum allowed size")
|
||||
}
|
||||
|
||||
storedContent := content
|
||||
storedContentType := contentType
|
||||
if contentType != "image/webp" {
|
||||
decoded, _, decodeErr := image.Decode(bytes.NewReader(content))
|
||||
if decodeErr != nil {
|
||||
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if encodeErr := webp.Encode(&buf, decoded, &webp.Options{Quality: float32(webpEncodeQuality)}); encodeErr != nil {
|
||||
return nil, response.ErrInternal(50065, "kol_avatar_encode_failed", "failed to encode avatar")
|
||||
}
|
||||
storedContent = buf.Bytes()
|
||||
storedContentType = "image/webp"
|
||||
}
|
||||
|
||||
objectKey := buildKolAvatarObjectKey(actor.TenantID, profile.ID)
|
||||
if err := s.objectStorage.PutBytes(ctx, objectKey, storedContent, storedContentType); err != nil {
|
||||
if errors.Is(err, objectstorage.ErrNotConfigured) {
|
||||
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
return nil, response.ErrInternal(50066, "kol_avatar_upload_failed", "failed to upload avatar")
|
||||
}
|
||||
|
||||
_ = filename
|
||||
return &KolAvatarUploadResponse{
|
||||
URL: "",
|
||||
ObjectKey: objectKey,
|
||||
ContentType: storedContentType,
|
||||
SizeBytes: int64(len(storedContent)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildKolAvatarObjectKey(tenantID, profileID int64) string {
|
||||
return fmt.Sprintf("tenants/%d/kol-avatars/%d/%d.webp", tenantID, profileID, time.Now().UTC().UnixNano())
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
@@ -12,7 +13,8 @@ import (
|
||||
var ErrNotActiveKol = response.ErrForbidden(40341, "kol_not_active", "user is not an active KOL")
|
||||
|
||||
type KolProfileService struct {
|
||||
repo repository.KolProfileRepository
|
||||
repo repository.KolProfileRepository
|
||||
objectStorage objectstorage.Client
|
||||
}
|
||||
|
||||
type UpdateKolProfileServiceInput struct {
|
||||
@@ -38,6 +40,11 @@ func NewKolProfileService(repo repository.KolProfileRepository) *KolProfileServi
|
||||
return &KolProfileService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *KolProfileService) WithObjectStorage(client objectstorage.Client) *KolProfileService {
|
||||
s.objectStorage = client
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) {
|
||||
profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user