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

0
.env Normal file
View File

View File

@@ -1,135 +1,73 @@
package main package main
import ( import (
"bytes" "context"
"fmt" "fmt"
"io" "net"
"log/slog" "os"
"net/http" "os/signal"
"path/filepath" "syscall"
"time" "time"
"lendry-erp/media/internal/processor" "lendry-erp/media/internal/application/usecases"
"lendry-erp/media/internal/storage" "lendry-erp/media/internal/config"
infrastructureGrpc "lendry-erp/media/internal/infrastructure/grpc"
"lendry-erp/media/internal/infrastructure/images"
"lendry-erp/media/internal/infrastructure/storage"
"lendry-erp/media/pkg/logger"
) )
// InternalAuthMiddleware защищает Go-сервис.
// Он пропускает только те запросы, в которых API Gateway заботливо положил X-User-Id
func InternalAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userId := r.Header.Get("X-User-Id")
isModerator := r.Header.Get("X-Is-Admin") == "true"
if userId == "" {
slog.Warn("Блокировка несанкционированного доступа (отсутствует X-User-Id)", "ip", r.RemoteAddr)
http.Error(w, `{"error": "Unauthorized: API Gateway only"}`, http.StatusUnauthorized)
return
}
slog.Debug("Запрос авторизован Gateway", "user_id", userId, "moderator", isModerator)
next.ServeHTTP(w, r)
}
}
func main() { func main() {
// Подключение к MinIO cfg := config.Load()
minioStore, err := storage.NewMinioStorage("minio:9000", "admin", "admin12345", "erp-media")
logger.Init(cfg.Logging.Level)
logger.Info("🚀 Starting media-service (gRPC only) in %s mode", cfg.App.Env)
// 1. Инициализация хранилища
mediaStorage, err := storage.NewS3Storage(cfg)
if err != nil { if err != nil {
panic(err) logger.Fatal("failed to init S3 storage: %v", err)
} }
logger.Info("✅ S3 storage connected (bucket: %s)", cfg.Storage.Bucket)
// === Эндпоинт 1: ЗАГРУЗКА ПРИВАТНЫХ ФАЙЛОВ === // 2. Инициализация UseCases (Бизнес-логика)
http.HandleFunc("/upload", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) { imgProcessor := images.NewImageProcessor()
r.ParseMultipartForm(50 << 20) // Лимит 50 МБ uploadUC := usecases.NewUploadUseCase(mediaStorage, imgProcessor)
presignUC := usecases.NewPresignUseCase(mediaStorage)
file, header, err := r.FormFile("file") // 3. Создание gRPC сервера
grpcServer := infrastructureGrpc.NewServer(uploadUC, presignUC)
// 4. Запуск прослушивания порта
grpcListener, err := net.Listen("tcp", fmt.Sprintf(":%s", cfg.GRPC.Port))
if err != nil { if err != nil {
http.Error(w, "Файл не найден", http.StatusBadRequest) logger.Fatal("failed to listen gRPC: %v", err)
return
}
defer file.Close()
mode := r.FormValue("mode")
if mode == "" {
mode = "chat"
} }
fileBytes, err := io.ReadAll(file) go func() {
if err != nil { logger.Info("gRPC listening on :%s", cfg.GRPC.Port)
http.Error(w, "Ошибка чтения файла", http.StatusInternalServerError) if err := grpcServer.Serve(grpcListener); err != nil {
return logger.Fatal("gRPC serve error: %v", err)
} }
}()
finalBytes, contentType, err := processor.ProcessImage(fileBytes, mode) // 5. Ожидание сигнала остановки
if err != nil { waitForShutdown(func() {
http.Error(w, "Ошибка обработки изображения", http.StatusInternalServerError) logger.Warn("🛑 Graceful shutdown started...")
return grpcServer.GracefulStop()
} mediaStorage.Close()
logger.Info("✅ Shutdown complete")
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".jpg"
}
objectName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
fileName, err := minioStore.Upload(r.Context(), objectName, bytes.NewReader(finalBytes), int64(len(finalBytes)), contentType)
if err != nil {
http.Error(w, "Ошибка загрузки в MinIO", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success": true, "fileName": "%s", "size": %d}`, fileName, len(finalBytes))
}))
// === Эндпоинт 2: ПОЛУЧЕНИЕ ПРИВАТНОЙ ССЫЛКИ ===
http.HandleFunc("/media/url", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
fileName := r.URL.Query().Get("file")
if fileName == "" {
http.Error(w, "Укажите имя файла", http.StatusBadRequest)
return
}
// ВАЖНО: Раз запрос дошел сюда, значит API Gateway УЖЕ проверил
// JWT токен и принадлежность пользователя к чату (через gRPC).
// Никакие проверки БД в Go больше не нужны. Мы доверяем Gateway!
expiry := time.Minute * 2 // Приватные ссылки живут 2 минуты
presignedURL, err := minioStore.GeneratePresignedURL(r.Context(), fileName, expiry)
if err != nil {
http.Error(w, "Ошибка генерации ссылки", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url": "%s"}`, presignedURL)
}))
// === Эндпоинт 3: ПОЛУЧЕНИЕ ПУБЛИЧНОЙ ССЫЛКИ (Аватарки, Баннеры) ===
// Обрати внимание: этот эндпоинт НЕ обернут в InternalAuthMiddleware
http.HandleFunc("/media/public/url", func(w http.ResponseWriter, r *http.Request) {
fileName := r.URL.Query().Get("file")
if fileName == "" {
http.Error(w, "Укажите имя файла", http.StatusBadRequest)
return
}
// Публичные ссылки можно делать более долгоживущими (например, на 24 часа),
// чтобы браузер мог их эффективно кэшировать
expiry := time.Hour * 24
presignedURL, err := minioStore.GeneratePresignedURL(r.Context(), fileName, expiry)
if err != nil {
http.Error(w, "Ошибка генерации ссылки", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url": "%s"}`, presignedURL)
}) })
}
slog.Info("Media Service запущен на внутреннем порту :8081") func waitForShutdown(cleanup func()) {
if err := http.ListenAndServe(":8081", nil); err != nil { quit := make(chan os.Signal, 1)
slog.Error("Ошибка работы HTTP сервера", "err", err) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
} <-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cleanup()
<-ctx.Done()
} }

56
go.mod
View File

@@ -3,25 +3,39 @@ module lendry-erp/media
go 1.26.1 go 1.26.1
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/disintegration/imaging v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/dustin/go-humanize v1.0.1 // indirect google.golang.org/grpc v1.81.0
github.com/go-ini/ini v1.67.0 // indirect )
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect require (
github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/minio/md5-simd v1.1.2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
github.com/minio/minio-go/v7 v7.1.0 // indirect google.golang.org/protobuf v1.36.11 // indirect
github.com/philhofer/fwd v1.2.0 // indirect )
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect require (
github.com/zeebo/xxh3 v1.1.0 // indirect git.lendry.ru/lendry-erp/contracts.git v1.0.34-0.20260508132432-d32f0f2c777d
go.yaml.in/yaml/v3 v3.0.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7
golang.org/x/crypto v0.46.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
golang.org/x/net v0.48.0 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18
golang.org/x/sys v0.39.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
golang.org/x/text v0.32.0 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/google/uuid v1.6.0
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
) )

112
go.sum
View File

@@ -1,45 +1,85 @@
git.lendry.ru/lendry-erp/contracts.git v1.0.33 h1:dHbkdCJdyhMXAy+yACTwG2pwNUThk820vWpdnlmp03s=
git.lendry.ru/lendry-erp/contracts.git v1.0.33/go.mod h1:V1nGHutS23FlHcCgVHBYKF6da3jTsZbFgGALC9B4e3g=
git.lendry.ru/lendry-erp/contracts.git v1.0.34-0.20260508132432-d32f0f2c777d h1:wUnAuLg4cJIZouBo5UpQy105Y9V/b1oq1bdHKgJ3g18=
git.lendry.ru/lendry-erp/contracts.git v1.0.34-0.20260508132432-d32f0f2c777d/go.mod h1:V1nGHutS23FlHcCgVHBYKF6da3jTsZbFgGALC9B4e3g=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 h1:9XFUd2lkr7VrbE4Qtrhm7AtNhGgZeGFI5QLZtQIflj8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18/go.mod h1:trImuKdWelQIJALvyGj6sKolJ1W8t628JOoTdDGVL9Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

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
}

53
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,53 @@
package logger
import (
"log"
"os"
"strings"
)
var level = "info"
func Init(lvl string) {
level = strings.ToLower(lvl)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Printf("[LOGGER] initialized with level: %s", level)
}
func Info(format string, v ...any) {
if levelAllowed("info") {
log.Printf("[INFO] "+format, v...)
}
}
func Warn(format string, v ...any) {
if levelAllowed("warn") {
log.Printf("[WARN] "+format, v...)
}
}
func Error(format string, v ...any) {
if levelAllowed("error") {
log.Printf("[ERROR] "+format, v...)
}
}
func Fatal(format string, v ...any) {
log.Printf("[FATAL] "+format, v...)
os.Exit(1)
}
// helpers
func levelAllowed(l string) bool {
levels := map[string]int{
"debug": 1,
"info": 2,
"warn": 3,
"error": 4,
"fatal": 5,
}
return levels[strings.ToLower(level)] <= levels[l]
}