1
This commit is contained in:
15
internal/file_manager/errors.go
Normal file
15
internal/file_manager/errors.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package filemanager
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidFileType = errors.New("invalid file type")
|
||||
ErrParameterNotFound = errors.New("parameter required, but not found")
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
ErrFileAlreadyExists = errors.New("file already exists")
|
||||
ErrFailedUploadFile = errors.New("failed to upload file to S3")
|
||||
ErrFailedToCacheUrl = errors.New("failed to cache URL")
|
||||
ErrMsgReadFile = errors.New("failed to read file")
|
||||
ErrMsgGetPresignedURL = errors.New("failed to get presigned URL")
|
||||
)
|
127
internal/file_manager/local_storage/storage.go
Normal file
127
internal/file_manager/local_storage/storage.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
filemanager "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/file_manager"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func New(basePath string) *Storage {
|
||||
return &Storage{
|
||||
basePath: basePath,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) GetFilePaths(
|
||||
ctx context.Context,
|
||||
fileType filemanager.FileType,
|
||||
parameters filemanager.ParameterTable,
|
||||
) ([]string, error) {
|
||||
var dirPath string
|
||||
|
||||
fileNames := make([]string, 0)
|
||||
|
||||
switch fileType {
|
||||
case filemanager.AvatarFileType:
|
||||
userId, ok := parameters[filemanager.UserIdParam]
|
||||
if !ok {
|
||||
return nil, filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
dirPath = filepath.Join(s.basePath, userId.(string), "avatars")
|
||||
|
||||
case filemanager.CVFileType:
|
||||
submId, ok := parameters[filemanager.SubmissionIdParam]
|
||||
if !ok {
|
||||
return nil, filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
dirPath = filepath.Join(s.basePath, "CVs", submId.(string))
|
||||
|
||||
default:
|
||||
return nil, filemanager.ErrInvalidFileType
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
||||
return nil, filemanager.ErrFileNotFound
|
||||
}
|
||||
|
||||
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileNames = append(fileNames, path)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(fileNames) == 0 {
|
||||
return nil, filemanager.ErrFileNotFound
|
||||
}
|
||||
|
||||
return fileNames, nil
|
||||
}
|
||||
|
||||
func (s *Storage) SaveFile(
|
||||
ctx context.Context,
|
||||
fileType filemanager.FileType,
|
||||
file multipart.File,
|
||||
fileHeader *multipart.FileHeader,
|
||||
parameters filemanager.ParameterTable,
|
||||
) error {
|
||||
var dirPath string
|
||||
|
||||
switch fileType {
|
||||
case filemanager.AvatarFileType:
|
||||
userId, ok := parameters[filemanager.UserIdParam]
|
||||
if !ok {
|
||||
return filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
dirPath = filepath.Join(s.basePath, userId.(string), "avatars")
|
||||
|
||||
case filemanager.CVFileType:
|
||||
submId, ok := parameters[filemanager.SubmissionIdParam]
|
||||
if !ok {
|
||||
return filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
dirPath = filepath.Join(s.basePath, "CVs", submId.(string))
|
||||
|
||||
default:
|
||||
return filemanager.ErrInvalidFileType
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dirPath, fileHeader.Filename)
|
||||
|
||||
dstFile, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
39
internal/file_manager/manager.go
Normal file
39
internal/file_manager/manager.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime/multipart"
|
||||
)
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
AvatarFileType FileType = "avatar"
|
||||
CVFileType FileType = "cv"
|
||||
)
|
||||
|
||||
type ParameterType string
|
||||
|
||||
const (
|
||||
UserIdParam ParameterType = "user_id"
|
||||
VacancyIdParam ParameterType = "vacancy_id"
|
||||
SubmissionIdParam ParameterType = "submission_id"
|
||||
)
|
||||
|
||||
type ParameterTable map[ParameterType]any
|
||||
|
||||
type UserFileManager interface {
|
||||
GetFilePaths(
|
||||
ctx context.Context,
|
||||
fileType FileType,
|
||||
parameters ParameterTable,
|
||||
) ([]string, error)
|
||||
|
||||
SaveFile(
|
||||
ctx context.Context,
|
||||
fileType FileType,
|
||||
file multipart.File,
|
||||
fileHeader *multipart.FileHeader,
|
||||
parameters ParameterTable,
|
||||
) error
|
||||
}
|
156
internal/file_manager/s3_storage/storage.go
Normal file
156
internal/file_manager/s3_storage/storage.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package s3_storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
|
||||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
|
||||
filemanager "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/file_manager"
|
||||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/object_storage"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
objectStorage object_storage.Client
|
||||
cacheClient cache.Client
|
||||
}
|
||||
|
||||
func NewS3Storage(objectStorage object_storage.Client, cacheClient cache.Client) *S3Storage {
|
||||
return &S3Storage{
|
||||
objectStorage: objectStorage,
|
||||
cacheClient: cacheClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *S3Storage) GetFilePaths(
|
||||
ctx context.Context,
|
||||
fileType filemanager.FileType,
|
||||
parameters filemanager.ParameterTable,
|
||||
) ([]string, error) {
|
||||
objectKey, category, err := s.buildObjectKey(fileType, parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Сначала пытаемся получить URL из кэша
|
||||
cachedURL, err := s.getCachedURL(ctx, objectKey, category)
|
||||
if err == nil && cachedURL != "" {
|
||||
return []string{cachedURL}, nil
|
||||
}
|
||||
|
||||
// Получаем линк из s3
|
||||
url, err := s.objectStorage.GetPresignedLink(ctx, objectKey, category,
|
||||
object_storage.LinkOptions{
|
||||
TTL: ptr.Duration(constants.DefaultFileTTL),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, object_storage.ErrObjectNotFound) {
|
||||
return nil, filemanager.ErrFileNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s: %w", filemanager.ErrMsgGetPresignedURL, err)
|
||||
}
|
||||
|
||||
// Кэшируем URL
|
||||
if err := s.cacheURL(ctx, objectKey, category, url); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", filemanager.ErrFailedToCacheUrl, err)
|
||||
}
|
||||
|
||||
return []string{url}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) SaveFile(
|
||||
ctx context.Context,
|
||||
fileType filemanager.FileType,
|
||||
file multipart.File,
|
||||
fileHeader *multipart.FileHeader,
|
||||
parameters filemanager.ParameterTable,
|
||||
) error {
|
||||
objectKey, category, err := s.buildObjectKey(fileType, parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(file); err != nil {
|
||||
return fmt.Errorf("%s: %w", filemanager.ErrMsgReadFile, err)
|
||||
}
|
||||
|
||||
// Определяем тип контента
|
||||
contentType := s.getContentType(fileHeader.Filename)
|
||||
|
||||
// Загружаем файл в S3
|
||||
err = s.objectStorage.PutNewObject(ctx, objectKey, category,
|
||||
bytes.NewReader(buf.Bytes()),
|
||||
object_storage.PutOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
// Обрабатываем случай существующего файла
|
||||
if errors.Is(err, object_storage.ErrObjectAlreadyExists) {
|
||||
return filemanager.ErrFileAlreadyExists
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s: %w", filemanager.ErrFailedUploadFile, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Создаем ключ объекта S3 на основе типа файла и параметров
|
||||
func (s *S3Storage) buildObjectKey(fileType filemanager.FileType, parameters filemanager.ParameterTable) (string, object_storage.Category, error) {
|
||||
switch fileType {
|
||||
case filemanager.AvatarFileType:
|
||||
userID, ok := parameters[filemanager.UserIdParam]
|
||||
if !ok {
|
||||
return "", "", filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
return fmt.Sprintf(objectKeyAvatarTemplate, userID), object_storage.LogoCategory, nil
|
||||
|
||||
case filemanager.CVFileType:
|
||||
submissionID, ok := parameters[filemanager.SubmissionIdParam]
|
||||
if !ok {
|
||||
return "", "", filemanager.ErrParameterNotFound
|
||||
}
|
||||
|
||||
return fmt.Sprintf(objectKeyCVTemplate, submissionID), object_storage.DocumentCategory, nil
|
||||
|
||||
default:
|
||||
return "", "", filemanager.ErrInvalidFileType
|
||||
}
|
||||
}
|
||||
|
||||
// getContentType определяет тип контента по расширению
|
||||
func (s *S3Storage) getContentType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
if ct, ok := ContentTypes[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
|
||||
return defaultContentType
|
||||
}
|
||||
|
||||
func (s *S3Storage) getCachedURL(ctx context.Context, objectKey string, category object_storage.Category) (string, error) {
|
||||
cacheKey := fmt.Sprintf(cacheKeyTemplate, category, objectKey)
|
||||
return s.cacheClient.Get(ctx, cacheKey, cache.DocumentsValueType)
|
||||
}
|
||||
|
||||
func (s *S3Storage) cacheURL(ctx context.Context, objectKey string, category object_storage.Category, url string) error {
|
||||
cacheKey := fmt.Sprintf(cacheKeyTemplate, category, objectKey)
|
||||
cacheDuration := constants.DefaultFileTTL - time.Hour
|
||||
|
||||
return s.cacheClient.Set(ctx, cacheKey, cache.DocumentsValueType, url, cacheDuration)
|
||||
}
|
22
internal/file_manager/s3_storage/types.go
Normal file
22
internal/file_manager/s3_storage/types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package s3_storage
|
||||
|
||||
var ContentTypes = map[string]string{
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".rtf": "application/rtf",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
// Константы для ключей
|
||||
const (
|
||||
objectKeyAvatarTemplate = "%s_avatar"
|
||||
objectKeyCVTemplate = "%s_cv"
|
||||
cacheKeyTemplate = "%s_%s"
|
||||
defaultContentType = "application/octet-stream"
|
||||
)
|
Reference in New Issue
Block a user