add media service
This commit is contained in:
21
internal/application/dto/media.go
Normal file
21
internal/application/dto/media.go
Normal 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
|
||||
}
|
||||
32
internal/application/usecases/presign.go
Normal file
32
internal/application/usecases/presign.go
Normal 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
|
||||
}
|
||||
50
internal/application/usecases/upload.go
Normal file
50
internal/application/usecases/upload.go
Normal 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
41
internal/config/config.go
Normal 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
|
||||
}
|
||||
59
internal/infrastructure/grpc/interceptor.go
Normal file
59
internal/infrastructure/grpc/interceptor.go
Normal 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)
|
||||
}
|
||||
31
internal/infrastructure/grpc/server.go
Normal file
31
internal/infrastructure/grpc/server.go
Normal 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
|
||||
}
|
||||
50
internal/infrastructure/images/processor.go
Normal file
50
internal/infrastructure/images/processor.go
Normal 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
|
||||
}
|
||||
196
internal/infrastructure/storage/s3.go
Normal file
196
internal/infrastructure/storage/s3.go
Normal 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
|
||||
}
|
||||
56
internal/interfaces/grpc/media_handler.go
Normal file
56
internal/interfaces/grpc/media_handler.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user