add media service

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

View File

@@ -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()
}