add media service

This commit is contained in:
Дмитрий
2026-05-08 17:36:48 +03:00
parent 1a7251976d
commit 72c57f0de3
16 changed files with 757 additions and 291 deletions

View File

@@ -0,0 +1,21 @@
package dto
type UploadMediaRequest struct {
OriginalName string
Mode string
Data []byte
}
type UploadMediaResponse struct {
FileName string
Size int64
}
type GetUrlRequest struct {
FileName string
IsPublic bool
}
type GetUrlResponse struct {
URL string
}

View File

@@ -0,0 +1,32 @@
package usecases
import (
"context"
"time"
"lendry-erp/media/internal/application/dto"
"lendry-erp/media/internal/infrastructure/storage"
)
type PresignUseCase struct {
storage storage.Storage
}
func NewPresignUseCase(s storage.Storage) *PresignUseCase {
return &PresignUseCase{storage: s}
}
func (u *PresignUseCase) Execute(ctx context.Context, input dto.GetUrlRequest) (*dto.GetUrlResponse, error) {
// Приватные файлы (из чатов) сгорают быстро, публичные (аватарки) живут 24 часа
expiry := time.Minute * 2
if input.IsPublic {
expiry = time.Hour * 24
}
url, err := u.storage.GetPresignedURL(ctx, input.FileName, expiry, "GET")
if err != nil {
return nil, err
}
return &dto.GetUrlResponse{URL: url}, nil
}

View File

@@ -0,0 +1,50 @@
package usecases
import (
"bytes"
"context"
"fmt"
"path/filepath"
"time"
"lendry-erp/media/internal/application/dto"
"lendry-erp/media/internal/infrastructure/images"
"lendry-erp/media/internal/infrastructure/storage"
)
type UploadUseCase struct {
storage storage.Storage
processor images.Processor
}
func NewUploadUseCase(s storage.Storage, p images.Processor) *UploadUseCase {
return &UploadUseCase{
storage: s,
processor: p,
}
}
func (u *UploadUseCase) Execute(ctx context.Context, input dto.UploadMediaRequest) (*dto.UploadMediaResponse, error) {
// 1. Сжатие и обработка изображения
finalBytes, contentType, err := u.processor.Process(input.Data, input.Mode)
if err != nil {
return nil, fmt.Errorf("process image: %w", err)
}
// 2. Генерация уникального имени файла
ext := filepath.Ext(input.OriginalName)
if ext == "" {
ext = ".jpg"
}
objectName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
// 3. Загрузка потока байтов в MinIO
if err := u.storage.UploadStream(ctx, objectName, bytes.NewReader(finalBytes), contentType); err != nil {
return nil, fmt.Errorf("upload to storage: %w", err)
}
return &dto.UploadMediaResponse{
FileName: objectName,
Size: int64(len(finalBytes)),
}, nil
}

41
internal/config/config.go Normal file
View File

@@ -0,0 +1,41 @@
package config
import (
"os"
"strings"
)
type Config struct {
App struct {
Env string
}
GRPC struct {
Port string
}
Storage struct {
Bucket string
Region string
Endpoint string
AccessKey string
SecretKey string
}
Logging struct {
Level string
}
}
func Load() *Config {
var cfg Config
get := func(key string) string { return strings.TrimSpace(os.Getenv(key)) }
cfg.App.Env = get("APP_ENV")
cfg.GRPC.Port = get("GRPC_PORT")
cfg.Storage.Bucket = get("S3_BUCKET")
cfg.Storage.Region = get("S3_REGION")
cfg.Storage.Endpoint = get("S3_ENDPOINT")
cfg.Storage.AccessKey = get("S3_ACCESS_KEY")
cfg.Storage.SecretKey = get("S3_SECRET_KEY")
cfg.Logging.Level = get("LOG_LEVEL")
return &cfg
}

View File

@@ -0,0 +1,59 @@
package grpc
import (
"context"
"time"
"lendry-erp/media/pkg/logger"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
// RequestLoggerInterceptor логирует время выполнения каждого gRPC запроса
func RequestLoggerInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
status := "✅"
if err != nil {
status = "❌"
}
logger.Info("%s %s %v", status, info.FullMethod, time.Since(start))
return resp, err
}
// TraceIDInterceptor добавляет уникальный ID для отслеживания запроса
func TraceIDInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
md = metadata.New(nil)
}
ids := md.Get("x-trace-id")
var traceID string
if len(ids) == 0 {
traceID = uuid.New().String()
md.Set("x-trace-id", traceID)
} else {
traceID = ids[0]
}
ctx = metadata.NewIncomingContext(ctx, md)
ctx = context.WithValue(ctx, "traceID", traceID)
return handler(ctx, req)
}

View File

@@ -0,0 +1,31 @@
package grpc
import (
pb "git.lendry.ru/lendry-erp/contracts.git/gen/go/media"
"lendry-erp/media/internal/application/usecases"
handler "lendry-erp/media/internal/interfaces/grpc"
"google.golang.org/grpc"
)
// NewServer создает и настраивает gRPC сервер с нужными перехватчиками и лимитами
func NewServer(uploadUC *usecases.UploadUseCase, presignUC *usecases.PresignUseCase) *grpc.Server {
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
RequestLoggerInterceptor,
TraceIDInterceptor,
),
// Увеличиваем лимит размера пакета до 50 МБ для больших файлов
grpc.MaxRecvMsgSize(50*1024*1024),
)
// Создаем обработчик
h := handler.NewMediaHandler(uploadUC, presignUC)
// Регистрируем наш сервис (здесь используется функция из media_grpc.pb.go)
pb.RegisterMediaServiceServer(server, h)
return server
}

View File

@@ -0,0 +1,50 @@
package images
import (
"bytes"
"image"
"image/jpeg"
_ "image/png"
"github.com/disintegration/imaging"
)
type Processor interface {
Process(input []byte, mode string) ([]byte, string, error)
}
type ImageProcessor struct{}
func NewImageProcessor() *ImageProcessor {
return &ImageProcessor{}
}
func (p *ImageProcessor) Process(fileBytes []byte, mode string) ([]byte, string, error) {
if mode == "raw" {
return fileBytes, "application/octet-stream", nil
}
img, _, err := image.Decode(bytes.NewReader(fileBytes))
if err != nil {
// Если это не картинка (например, документ или видео), просто возвращаем исходные байты
return fileBytes, "application/octet-stream", nil
}
var processedImg image.Image
switch mode {
case "avatar":
processedImg = imaging.Fill(img, 500, 500, imaging.Center, imaging.Lanczos)
case "chat":
processedImg = imaging.Fit(img, 1280, 1280, imaging.Lanczos)
default:
processedImg = img
}
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, processedImg, &jpeg.Options{Quality: 80})
if err != nil {
return nil, "", err
}
return buf.Bytes(), "image/jpeg", nil
}

View File

@@ -0,0 +1,196 @@
package storage
import (
"context"
"fmt"
"io"
"lendry-erp/media/internal/config"
"lendry-erp/media/pkg/logger"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsConfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
type S3Storage struct {
client *s3.Client
uploader *manager.Uploader
downloader *manager.Downloader
bucket string
cfg *config.Config
presigner *s3.PresignClient
}
func NewS3Storage(c *config.Config) (*S3Storage, error) {
var loadOpts []func(*awsConfig.LoadOptions) error
if c.Storage.Region != "" {
loadOpts = append(loadOpts, awsConfig.WithRegion(c.Storage.Region))
}
if c.Storage.AccessKey != "" && c.Storage.SecretKey != "" {
loadOpts = append(loadOpts, awsConfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(c.Storage.AccessKey, c.Storage.SecretKey, ""),
))
}
awsCfg, err := awsConfig.LoadDefaultConfig(context.Background(), loadOpts...)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
var clientOpts []func(*s3.Options)
if strings.TrimSpace(c.Storage.Endpoint) != "" {
ep := c.Storage.Endpoint
clientOpts = append(clientOpts, func(o *s3.Options) {
o.UsePathStyle = true
o.BaseEndpoint = aws.String(ep)
})
}
client := s3.NewFromConfig(awsCfg, clientOpts...)
uploader := manager.NewUploader(client)
downloader := manager.NewDownloader(client)
presigner := s3.NewPresignClient(client)
s := &S3Storage{
client: client,
uploader: uploader,
downloader: downloader,
bucket: c.Storage.Bucket,
cfg: c,
presigner: presigner,
}
ctx := context.Background()
_, headErr := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
if headErr != nil {
_, createErr := s.client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(s.bucket),
CreateBucketConfiguration: &s3Types.CreateBucketConfiguration{
LocationConstraint: s3Types.BucketLocationConstraint(aws.ToString(&c.Storage.Region)),
},
})
if createErr != nil {
return nil, fmt.Errorf("create bucket: %w (head err: %v)", createErr, headErr)
}
logger.Info("🪣 Created S3 bucket: %s", s.bucket)
}
logger.Info("✅ Connected to S3 bucket: %s (region=%s)", s.bucket, c.Storage.Region)
return s, nil
}
func (s *S3Storage) UploadStream(
ctx context.Context,
key string,
reader io.Reader,
contentType string,
) error {
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
})
if err != nil {
return fmt.Errorf("s3 upload: %w", err)
}
return nil
}
func (s *S3Storage) GetStream(
ctx context.Context,
key string,
) (io.ReadCloser, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("s3 get: %w", err)
}
contentType := ""
if out.ContentType != nil {
contentType = *out.ContentType
}
return out.Body, contentType, nil
}
func (s *S3Storage) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("s3 delete: %w", err)
}
return nil
}
func (s *S3Storage) GetPublicURL(key string) string {
// Этот метод можно оставить на случай, если вы решите сделать
// бакет публичным в обход временных ссылок (Presigned URLs)
host := "http://localhost:9000" // Замените на хост MinIO для клиента, если нужно
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "http://" + host
}
return fmt.Sprintf("%s/%s/%s", strings.TrimRight(host, "/"), s.bucket, key)
}
func (s *S3Storage) GetPresignedURL(ctx context.Context, key string, expire time.Duration, method string) (string, error) {
switch strings.ToUpper(method) {
case "GET":
ps, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expire))
if err != nil {
return "", fmt.Errorf("presign GET: %w", err)
}
return ps.URL, nil
case "PUT":
ps, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expire))
if err != nil {
return "", fmt.Errorf("presign PUT: %w", err)
}
return ps.URL, nil
default:
return "", fmt.Errorf("unsupported method for presign: %s", method)
}
}
func (s *S3Storage) Close() error {
// AWS SDK v2 не требует явного закрытия клиента,
// но мы реализуем метод для соответствия интерфейсу
return nil
}
// =====================================================================
// ИНТЕРФЕЙС ХРАНИЛИЩА
// =====================================================================
// Storage определяет контракт для работы с файлами.
// Благодаря ему UseCase'ы (например, UploadUseCase) не привязаны жестко к S3
// и их можно легко тестировать с помощью моков.
type Storage interface {
UploadStream(ctx context.Context, key string, reader io.Reader, contentType string) error
GetStream(ctx context.Context, key string) (io.ReadCloser, string, error)
Delete(ctx context.Context, key string) error
GetPresignedURL(ctx context.Context, key string, expire time.Duration, method string) (string, error)
Close() error
}

View File

@@ -0,0 +1,56 @@
package grpc
import (
"context"
// Укажите правильный путь до сгенерированного go кода из ваших контрактов
"lendry-erp/media/internal/application/dto"
"lendry-erp/media/internal/application/usecases"
pb "git.lendry.ru/lendry-erp/contracts.git/gen/go/media"
)
type MediaHandler struct {
pb.UnimplementedMediaServiceServer
uploadUC *usecases.UploadUseCase
presignUC *usecases.PresignUseCase
}
func NewMediaHandler(u *usecases.UploadUseCase, p *usecases.PresignUseCase) *MediaHandler {
return &MediaHandler{
uploadUC: u,
presignUC: p,
}
}
func (h *MediaHandler) Upload(ctx context.Context, req *pb.UploadRequest) (*pb.UploadResponse, error) {
res, err := h.uploadUC.Execute(ctx, dto.UploadMediaRequest{
OriginalName: req.FileName,
Mode: req.Mode,
Data: req.Data,
})
if err != nil {
return nil, err
}
return &pb.UploadResponse{
FileName: res.FileName,
Size: res.Size,
}, nil
}
func (h *MediaHandler) GetPresignedUrl(ctx context.Context, req *pb.GetPresignedUrlRequest) (*pb.GetPresignedUrlResponse, error) {
res, err := h.presignUC.Execute(ctx, dto.GetUrlRequest{
FileName: req.FileName,
IsPublic: req.IsPublic,
})
if err != nil {
return nil, err
}
return &pb.GetPresignedUrlResponse{
Url: res.URL,
}, nil
}

View File

@@ -1,48 +0,0 @@
package processor
import (
"bytes"
"image"
"image/jpeg"
_ "image/png" // Для поддержки декодирования PNG
"github.com/disintegration/imaging"
)
// ProcessImage обрабатывает картинку в зависимости от типа загрузки
// mode может быть: "avatar", "chat", "raw"
func ProcessImage(fileBytes []byte, mode string) ([]byte, string, error) {
// Если пользователь отправил "как файл" (без сжатия)
if mode == "raw" {
return fileBytes, "image/jpeg", nil // В идеале тут нужно определять mime-type по байтам
}
// Декодируем исходную картинку
img, _, err := image.Decode(bytes.NewReader(fileBytes))
if err != nil {
return nil, "", err
}
var processedImg image.Image
switch mode {
case "avatar":
// Telegram делает аватарки квадратными (например, 500x500)
processedImg = imaging.Fill(img, 500, 500, imaging.Center, imaging.Lanczos)
case "chat":
// Ограничиваем максимальный размер для чата (например, 1280px по большей стороне),
// сохраняя пропорции
processedImg = imaging.Fit(img, 1280, 1280, imaging.Lanczos)
default:
processedImg = img
}
// Кодируем результат в сжатый JPEG (качество 80 - отличный баланс размера и качества)
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, processedImg, &jpeg.Options{Quality: 80})
if err != nil {
return nil, "", err
}
return buf.Bytes(), "image/jpeg", nil
}

View File

@@ -1,67 +0,0 @@
package storage
import (
"context"
"io"
"log/slog"
"net/url"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type MinioStorage struct {
client *minio.Client
bucket string
}
func NewMinioStorage(endpoint, accessKey, secretKey, bucket string) (*MinioStorage, error) {
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: false, // Для локальной разработки (без HTTPS)
})
if err != nil {
return nil, err
}
ctx := context.Background()
exists, err := client.BucketExists(ctx, bucket)
if err == nil && !exists {
err = client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{})
if err != nil {
return nil, err
}
slog.Info("Бакет создан", "bucket", bucket)
// Бакет остается ПРИВАТНЫМ (нет публичного BucketPolicy).
}
return &MinioStorage{
client: client,
bucket: bucket,
}, nil
}
func (s *MinioStorage) Upload(ctx context.Context, objectName string, reader io.Reader, size int64, contentType string) (string, error) {
info, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return "", err
}
// Возвращаем ТОЛЬКО имя файла (info.Key).
// Полный путь клиенту не нужен, он сам не сможет по нему перейти.
return info.Key, nil
}
func (s *MinioStorage) GeneratePresignedURL(ctx context.Context, objectName string, expiry time.Duration) (string, error) {
reqParams := make(url.Values)
presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, objectName, expiry, reqParams)
if err != nil {
return "", err
}
return presignedURL.String(), nil
}