From 1393b25419b58020bf352e1951f922ea2fb8ff5b Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Apr 2026 14:05:09 +0800 Subject: [PATCH] feat(kol): add avatar upload to KolProfileService --- .../internal/tenant/app/kol_profile_avatar.go | 110 ++++++++++++++++++ .../tenant/app/kol_profile_service.go | 9 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 server/internal/tenant/app/kol_profile_avatar.go diff --git a/server/internal/tenant/app/kol_profile_avatar.go b/server/internal/tenant/app/kol_profile_avatar.go new file mode 100644 index 0000000..55ce218 --- /dev/null +++ b/server/internal/tenant/app/kol_profile_avatar.go @@ -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()) +} diff --git a/server/internal/tenant/app/kol_profile_service.go b/server/internal/tenant/app/kol_profile_service.go index d39b11f..911cdef 100644 --- a/server/internal/tenant/app/kol_profile_service.go +++ b/server/internal/tenant/app/kol_profile_service.go @@ -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 {