tailly_back_v2/internal/service/post_service.go
admin 8e247cdb0d
All checks were successful
continuous-integration/drone/push Build is passing
v0.0.27.3
2025-08-28 12:19:17 +03:00

306 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"image/jpeg"
"image/png"
"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/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, content string) (*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)
CreateWithS3Upload(ctx context.Context, authorID int, title, s3TempKey string) (*domain.Post, error)
}
// Реализация сервиса постов
type postService struct {
postRepo repository.PostRepository
}
// Конструктор сервиса
func NewPostService(postRepo repository.PostRepository) PostService {
return &postService{postRepo: postRepo}
}
func (s *postService) CreateWithS3Upload(ctx context.Context, authorID int, title, s3Key string) (*domain.Post, error) {
const op = "service/postService.CreateWithS3Upload"
// Валидация
if s3Key == "" {
return nil, errors.New("S3 key cannot be empty")
}
// Проверяем что ключ принадлежит пользователю и находится в правильной папке
if !isValidUserPostKey(authorID, s3Key) {
return nil, errors.New("invalid S3 key for user")
}
// Формируем URL для модерации
imageURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", s3Key)
// Модерация изображения по URL
modClient, err := moderation.NewModerationClient("tailly_censor:50051")
if err != nil {
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")
}
// Создаем пост с уже готовым URL
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 isValidUserPostKey(userID int, key string) bool {
// Проверяем что ключ принадлежит пользователю и находится в папке posts
// Формат: posts/{userID}/{timestamp}_{filename}
return strings.HasPrefix(key, fmt.Sprintf("posts/%d/", userID))
}
// Создание нового поста
func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) {
const op = "service/postService.Create"
// Валидация
if content == "" {
return nil, errors.New("post content cannot be empty")
}
// Определяем формат и извлекаем данные
var base64Data, ext, contentType string
switch {
case strings.HasPrefix(content, "data:image/jpeg;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/jpeg;base64,")
ext = "jpg"
contentType = "image/jpeg"
case strings.HasPrefix(content, "data:image/png;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/png;base64,")
ext = "png"
contentType = "image/png"
case strings.HasPrefix(content, "data:image/webp;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/webp;base64,")
ext = "webp"
contentType = "image/webp"
default:
return nil, errors.New("invalid image format, expected JPEG, PNG or WebP")
}
// Декодируем base64
imgData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("%s: failed to decode base64: %w", op, err)
}
// Проверяем размер
if len(imgData) > 10_000_000 {
return nil, errors.New("image too large (max 10MB)")
}
// Проверяем валидность изображения
switch ext {
case "jpg":
if _, err := jpeg.Decode(bytes.NewReader(imgData)); err != nil {
return nil, fmt.Errorf("%s: invalid JPEG image: %w", op, err)
}
case "png":
if _, err := png.Decode(bytes.NewReader(imgData)); err != nil {
return nil, fmt.Errorf("%s: invalid PNG image: %w", op, err)
}
}
// Модерация изображения
modClient, err := moderation.NewModerationClient("tailly_censor:50051")
if err != nil {
return nil, fmt.Errorf("%s: failed to create moderation client: %w", op, err)
}
defer modClient.Close()
allowed, err := modClient.CheckImage(ctx, imgData)
if err != nil {
return nil, fmt.Errorf("%s: image moderation failed: %w", op, err)
}
if !allowed {
return nil, errors.New("image rejected by moderation service")
}
// Загрузка в S3
randName := utils.GenerateId() + ".jpg"
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)
_, err = uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String("tailly"),
Key: aws.String(s3Key),
Body: bytes.NewReader(imgData),
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/%s/%s", "tailly", s3Key)
post := &domain.Post{
Title: title,
Content: imageURL,
AuthorID: authorID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.postRepo.Create(ctx, post); err != nil {
return nil, fmt.Errorf("%s: failed to save post: %w", op, err)
}
return post, nil
}
// Получение поста по 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
}