package service import ( "context" "errors" "fmt" "io" "path/filepath" "strings" "tailly_back_v2/internal/domain" "tailly_back_v2/internal/repository" "tailly_back_v2/internal/utils" "tailly_back_v2/pkg/S3" "tailly_back_v2/pkg/moderation" "time" "github.com/99designs/gqlgen/graphql" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3/s3manager" ) // Интерфейс сервиса постов type PostService interface { Create(ctx context.Context, authorID int, title string, content graphql.Upload) (*domain.Post, error) GetByID(ctx context.Context, id int) (*domain.Post, error) GetAll(ctx context.Context) ([]*domain.Post, error) GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error) Update(ctx context.Context, id int, title, content string) (*domain.Post, error) Delete(ctx context.Context, id int) error GetPaginated(ctx context.Context, limit, offset int) ([]*domain.Post, error) } // Реализация сервиса постов type postService struct { postRepo repository.PostRepository } // Конструктор сервиса func NewPostService(postRepo repository.PostRepository) PostService { return &postService{postRepo: postRepo} } func (s *postService) Create(ctx context.Context, authorID int, title string, content graphql.Upload) (*domain.Post, error) { const op = "service/postService.Create" // Валидация if content.Filename == "" { return nil, errors.New("post content cannot be empty") } // Сбрасываем позицию чтения на начало if seeker, ok := content.File.(io.Seeker); ok { seeker.Seek(0, io.SeekStart) } // Проверяем тип файла contentType := content.ContentType if contentType == "" { // Определяем ContentType по расширению файла ext := strings.ToLower(filepath.Ext(content.Filename)) switch ext { case ".jpg", ".jpeg": contentType = "image/jpeg" case ".png": contentType = "image/png" case ".webp": contentType = "image/webp" case ".heic", ".heif": contentType = "image/heic" default: return nil, errors.New("invalid image format, expected JPEG, PNG, WebP, HEIC") } } // Проверяем поддерживаемые форматы if !isValidImageType(contentType) { return nil, fmt.Errorf("invalid image format: %s, expected JPEG, PNG, WebP, HEIC", contentType) } // Загрузка в S3 fileExt := getFileExtension(contentType, content.Filename) randName := utils.GenerateId() + fileExt sess := session.Must(session.NewSession(&aws.Config{ Region: aws.String("ru-central1"), Endpoint: aws.String("https://s3.regru.cloud"), S3ForcePathStyle: aws.Bool(true), Credentials: credentials.NewStaticCredentials( "TJ946G2S1Z5FEI3I7DQQ", "C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl", "", ), })) uploader := s3manager.NewUploader(sess) s3Key := fmt.Sprintf("posts/%d/%s", authorID, randName) // Загружаем напрямую из io.ReadSeeker _, err := uploader.Upload(&s3manager.UploadInput{ Bucket: aws.String("tailly"), Key: aws.String(s3Key), Body: content.File, // используем оригинальный ReadSeeker ContentType: aws.String(contentType), }) if err != nil { return nil, fmt.Errorf("%s: failed to upload to S3: %w", op, err) } imageURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", s3Key) modClient, err := moderation.NewModerationClient("tailly_censor:50051") if err != nil { S3.DeleteFromS3(imageURL) // удаляем если ошибка модерации return nil, fmt.Errorf("%s: failed to create moderation client: %w", op, err) } defer modClient.Close() allowed, err := modClient.CheckImageURL(ctx, imageURL) if err != nil { S3.DeleteFromS3(imageURL) return nil, fmt.Errorf("%s: image moderation failed: %w", op, err) } if !allowed { S3.DeleteFromS3(imageURL) return nil, errors.New("image rejected by moderation service") } post := &domain.Post{ Title: title, Content: imageURL, AuthorID: authorID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.postRepo.Create(ctx, post); err != nil { // Если не удалось сохранить пост, удаляем файл из S3 S3.DeleteFromS3(imageURL) return nil, fmt.Errorf("%s: failed to save post: %w", op, err) } return post, nil } func isValidImageType(contentType string) bool { validTypes := []string{ "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", } for _, t := range validTypes { if contentType == t { return true } } return false } func getFileExtension(contentType, filename string) string { // Пытаемся получить расширение из оригинального имени файла if ext := filepath.Ext(filename); ext != "" { return ext } // Если не получилось, определяем по ContentType switch contentType { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/webp": return ".webp" case "image/heic", "image/heif": return ".heic" default: return ".jpg" // fallback } } // Получение поста по ID func (s *postService) GetByID(ctx context.Context, id int) (*domain.Post, error) { post, err := s.postRepo.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrPostNotFound) { return nil, errors.New("post not found") } return nil, err } return post, nil } // Получение всех постов func (s *postService) GetAll(ctx context.Context) ([]*domain.Post, error) { posts, err := s.postRepo.GetAll(ctx) if err != nil { return nil, err } // Возвращаем пустой слайс вместо nil if posts == nil { return []*domain.Post{}, nil } return posts, nil } // Получение постов автора func (s *postService) GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error) { posts, err := s.postRepo.GetByAuthorID(ctx, authorID) if err != nil { return nil, err } if posts == nil { return []*domain.Post{}, nil } return posts, nil } // Обновление поста func (s *postService) Update(ctx context.Context, id int, title, content string) (*domain.Post, error) { // Получаем существующий пост post, err := s.postRepo.GetByID(ctx, id) if err != nil { return nil, err } // Обновляем поля post.Title = title post.Content = content post.UpdatedAt = time.Now() if err := s.postRepo.Update(ctx, post); err != nil { return nil, err } return post, nil } // Удаление поста func (s *postService) Delete(ctx context.Context, id int) error { post, err := s.postRepo.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrPostNotFound) { return errors.New("post not found") } return err } err = S3.DeleteFromS3(post.Content) if err != nil { return err } return s.postRepo.Delete(ctx, id) } func (s *postService) GetPaginated(ctx context.Context, limit, offset int) ([]*domain.Post, error) { posts, err := s.postRepo.GetPaginated(ctx, limit, offset) if err != nil { return nil, err } if posts == nil { return []*domain.Post{}, nil } return posts, nil }