add media service
This commit is contained in:
176
cmd/main.go
176
cmd/main.go
@@ -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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Эндпоинт 1: ЗАГРУЗКА ПРИВАТНЫХ ФАЙЛОВ ===
|
go func() {
|
||||||
http.HandleFunc("/upload", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
logger.Info("gRPC listening on :%s", cfg.GRPC.Port)
|
||||||
r.ParseMultipartForm(50 << 20) // Лимит 50 МБ
|
if err := grpcServer.Serve(grpcListener); err != nil {
|
||||||
|
logger.Fatal("gRPC serve error: %v", err)
|
||||||
file, header, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Файл не найден", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer file.Close()
|
}()
|
||||||
|
|
||||||
mode := r.FormValue("mode")
|
// 5. Ожидание сигнала остановки
|
||||||
if mode == "" {
|
waitForShutdown(func() {
|
||||||
mode = "chat"
|
logger.Warn("🛑 Graceful shutdown started...")
|
||||||
}
|
grpcServer.GracefulStop()
|
||||||
|
mediaStorage.Close()
|
||||||
fileBytes, err := io.ReadAll(file)
|
logger.Info("✅ Shutdown complete")
|
||||||
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 {
|
func waitForShutdown(cleanup func()) {
|
||||||
slog.Error("Ошибка работы HTTP сервера", "err", err)
|
quit := make(chan os.Signal, 1)
|
||||||
}
|
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
56
go.mod
@@ -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
112
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 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=
|
||||||
|
|||||||
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
|
|
||||||
}
|
|
||||||
53
pkg/logger/logger.go
Normal file
53
pkg/logger/logger.go
Normal 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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user