From 72c57f0de3f0b4caa4cf87cc84e9393852456217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 8 May 2026 17:36:48 +0300 Subject: [PATCH] add media service --- .env | 0 cmd/main.go | 176 ++++++------------ go.mod | 56 +++--- go.sum | 112 +++++++---- internal/application/dto/media.go | 21 +++ internal/application/usecases/presign.go | 32 ++++ internal/application/usecases/upload.go | 50 +++++ internal/config/config.go | 41 ++++ internal/infrastructure/grpc/interceptor.go | 59 ++++++ internal/infrastructure/grpc/server.go | 31 ++++ internal/infrastructure/images/processor.go | 50 +++++ internal/infrastructure/storage/s3.go | 196 ++++++++++++++++++++ internal/interfaces/grpc/media_handler.go | 56 ++++++ internal/processor/image.go | 48 ----- internal/storage/minio.go | 67 ------- pkg/logger/logger.go | 53 ++++++ 16 files changed, 757 insertions(+), 291 deletions(-) create mode 100644 .env create mode 100644 internal/application/dto/media.go create mode 100644 internal/application/usecases/presign.go create mode 100644 internal/application/usecases/upload.go create mode 100644 internal/config/config.go create mode 100644 internal/infrastructure/grpc/interceptor.go create mode 100644 internal/infrastructure/grpc/server.go create mode 100644 internal/infrastructure/images/processor.go create mode 100644 internal/infrastructure/storage/s3.go create mode 100644 internal/interfaces/grpc/media_handler.go delete mode 100644 internal/processor/image.go delete mode 100644 internal/storage/minio.go create mode 100644 pkg/logger/logger.go diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/cmd/main.go b/cmd/main.go index eaf4087..0a1a439 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,135 +1,73 @@ package main import ( - "bytes" + "context" "fmt" - "io" - "log/slog" - "net/http" - "path/filepath" + "net" + "os" + "os/signal" + "syscall" "time" - "lendry-erp/media/internal/processor" - "lendry-erp/media/internal/storage" + "lendry-erp/media/internal/application/usecases" + "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" +func main() { + cfg := config.Load() - if userId == "" { - slog.Warn("Блокировка несанкционированного доступа (отсутствует X-User-Id)", "ip", r.RemoteAddr) - http.Error(w, `{"error": "Unauthorized: API Gateway only"}`, http.StatusUnauthorized) - return - } + logger.Init(cfg.Logging.Level) + logger.Info("🚀 Starting media-service (gRPC only) in %s mode", cfg.App.Env) - slog.Debug("Запрос авторизован Gateway", "user_id", userId, "moderator", isModerator) - next.ServeHTTP(w, r) + // 1. Инициализация хранилища + mediaStorage, err := storage.NewS3Storage(cfg) + if err != nil { + logger.Fatal("failed to init S3 storage: %v", err) } + logger.Info("✅ S3 storage connected (bucket: %s)", cfg.Storage.Bucket) + + // 2. Инициализация UseCases (Бизнес-логика) + imgProcessor := images.NewImageProcessor() + uploadUC := usecases.NewUploadUseCase(mediaStorage, imgProcessor) + presignUC := usecases.NewPresignUseCase(mediaStorage) + + // 3. Создание gRPC сервера + grpcServer := infrastructureGrpc.NewServer(uploadUC, presignUC) + + // 4. Запуск прослушивания порта + grpcListener, err := net.Listen("tcp", fmt.Sprintf(":%s", cfg.GRPC.Port)) + if err != nil { + logger.Fatal("failed to listen gRPC: %v", err) + } + + go func() { + logger.Info("gRPC listening on :%s", cfg.GRPC.Port) + if err := grpcServer.Serve(grpcListener); err != nil { + logger.Fatal("gRPC serve error: %v", err) + } + }() + + // 5. Ожидание сигнала остановки + waitForShutdown(func() { + logger.Warn("🛑 Graceful shutdown started...") + grpcServer.GracefulStop() + mediaStorage.Close() + logger.Info("✅ Shutdown complete") + }) } -func main() { - // Подключение к MinIO - minioStore, err := storage.NewMinioStorage("minio:9000", "admin", "admin12345", "erp-media") - if err != nil { - panic(err) - } +func waitForShutdown(cleanup func()) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit - // === Эндпоинт 1: ЗАГРУЗКА ПРИВАТНЫХ ФАЙЛОВ === - http.HandleFunc("/upload", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - r.ParseMultipartForm(50 << 20) // Лимит 50 МБ + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - file, header, err := r.FormFile("file") - if err != nil { - http.Error(w, "Файл не найден", http.StatusBadRequest) - return - } - defer file.Close() - - mode := r.FormValue("mode") - if mode == "" { - mode = "chat" - } - - fileBytes, err := io.ReadAll(file) - if err != nil { - http.Error(w, "Ошибка чтения файла", http.StatusInternalServerError) - return - } - - finalBytes, contentType, err := processor.ProcessImage(fileBytes, mode) - if err != nil { - http.Error(w, "Ошибка обработки изображения", http.StatusInternalServerError) - return - } - - 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") - if err := http.ListenAndServe(":8081", nil); err != nil { - slog.Error("Ошибка работы HTTP сервера", "err", err) - } + cleanup() + <-ctx.Done() } \ No newline at end of file diff --git a/go.mod b/go.mod index 96c3683..30a0d0b 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,39 @@ module lendry-erp/media go 1.26.1 require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/disintegration/imaging v1.6.2 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - 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 - github.com/klauspost/cpuid/v2 v2.2.11 // indirect - github.com/klauspost/crc32 v1.3.0 // indirect - github.com/minio/crc64nvme v1.1.1 // indirect - github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.1.0 // 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 - github.com/zeebo/xxh3 v1.1.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 + google.golang.org/grpc v1.81.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +require ( + git.lendry.ru/lendry-erp/contracts.git v1.0.34-0.20260508132432-d32f0f2c777d + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // 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 ) diff --git a/go.sum b/go.sum index 131b053..1346a9f 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= -github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= -github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= -github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= -github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= -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= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= 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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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= diff --git a/internal/application/dto/media.go b/internal/application/dto/media.go new file mode 100644 index 0000000..c3f9e4b --- /dev/null +++ b/internal/application/dto/media.go @@ -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 +} \ No newline at end of file diff --git a/internal/application/usecases/presign.go b/internal/application/usecases/presign.go new file mode 100644 index 0000000..ff374cc --- /dev/null +++ b/internal/application/usecases/presign.go @@ -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 +} \ No newline at end of file diff --git a/internal/application/usecases/upload.go b/internal/application/usecases/upload.go new file mode 100644 index 0000000..7450e8d --- /dev/null +++ b/internal/application/usecases/upload.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9cb60b4 --- /dev/null +++ b/internal/config/config.go @@ -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 +} \ No newline at end of file diff --git a/internal/infrastructure/grpc/interceptor.go b/internal/infrastructure/grpc/interceptor.go new file mode 100644 index 0000000..d1d22bb --- /dev/null +++ b/internal/infrastructure/grpc/interceptor.go @@ -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) +} \ No newline at end of file diff --git a/internal/infrastructure/grpc/server.go b/internal/infrastructure/grpc/server.go new file mode 100644 index 0000000..7dcea5a --- /dev/null +++ b/internal/infrastructure/grpc/server.go @@ -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 +} \ No newline at end of file diff --git a/internal/infrastructure/images/processor.go b/internal/infrastructure/images/processor.go new file mode 100644 index 0000000..150c2a1 --- /dev/null +++ b/internal/infrastructure/images/processor.go @@ -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 +} \ No newline at end of file diff --git a/internal/infrastructure/storage/s3.go b/internal/infrastructure/storage/s3.go new file mode 100644 index 0000000..ab95df6 --- /dev/null +++ b/internal/infrastructure/storage/s3.go @@ -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 +} \ No newline at end of file diff --git a/internal/interfaces/grpc/media_handler.go b/internal/interfaces/grpc/media_handler.go new file mode 100644 index 0000000..105dc5f --- /dev/null +++ b/internal/interfaces/grpc/media_handler.go @@ -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 +} \ No newline at end of file diff --git a/internal/processor/image.go b/internal/processor/image.go deleted file mode 100644 index 97577a5..0000000 --- a/internal/processor/image.go +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/internal/storage/minio.go b/internal/storage/minio.go deleted file mode 100644 index a19d5fb..0000000 --- a/internal/storage/minio.go +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..2660cce --- /dev/null +++ b/pkg/logger/logger.go @@ -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] +} \ No newline at end of file