본문 바로가기

Playlist/Content

ContentService

 

ContentService.java

import com.codeit.playlist.domain.content.dto.data.ContentDto;
import com.codeit.playlist.domain.content.dto.request.ContentCreateRequest;
import com.codeit.playlist.domain.content.dto.request.ContentCursorRequest;
import com.codeit.playlist.domain.content.dto.request.ContentUpdateRequest;
import com.codeit.playlist.domain.content.dto.response.CursorResponseContentDto;
import org.springframework.web.multipart.MultipartFile;

import java.util.UUID;

public interface ContentService {
    ContentDto create(ContentCreateRequest request, MultipartFile thumbnail);
    ContentDto update(UUID contentId, ContentUpdateRequest request, MultipartFile thumbnail);
    void delete(UUID contentId);
    CursorResponseContentDto get(ContentCursorRequest request);
    ContentDto search(UUID contentId);
}

 

 

BasicContentService.java

import com.codeit.playlist.domain.content.dto.data.ContentDto;
import com.codeit.playlist.domain.content.dto.request.ContentCreateRequest;
import com.codeit.playlist.domain.content.dto.request.ContentCursorRequest;
import com.codeit.playlist.domain.content.dto.request.ContentUpdateRequest;
import com.codeit.playlist.domain.content.dto.response.CursorResponseContentDto;
import com.codeit.playlist.domain.content.entity.Content;
import com.codeit.playlist.domain.content.entity.Tag;
import com.codeit.playlist.domain.content.exception.ContentBadRequestException;
import com.codeit.playlist.domain.content.exception.ContentNotFoundException;
import com.codeit.playlist.domain.content.mapper.ContentMapper;
import com.codeit.playlist.domain.content.repository.ContentRepository;
import com.codeit.playlist.domain.content.repository.TagRepository;
import com.codeit.playlist.domain.content.service.ContentService;
import com.codeit.playlist.domain.file.S3Uploader;
import com.codeit.playlist.domain.playlist.repository.PlaylistContentRepository;
import com.codeit.playlist.domain.review.repository.ReviewRepository;
import com.codeit.playlist.domain.watching.repository.RedisWatchingSessionRepository;
import com.codeit.playlist.global.constant.S3Properties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@Slf4j
@RequiredArgsConstructor
public class BasicContentService implements ContentService {
    private final ContentRepository contentRepository;
    private final TagRepository tagRepository;
    private final ContentMapper contentMapper;
    private final S3Uploader s3Uploader;
    private final S3Properties s3Properties;
    private final RedisWatchingSessionRepository redisWatchingSessionRepository;
    private final PlaylistContentRepository playlistContentRepository;
    private final ReviewRepository reviewRepository;

    @Transactional
    @Override
    public ContentDto create(ContentCreateRequest request, MultipartFile thumbnail) {
        log.debug("[콘텐츠 데이터 관리] 관리자 권한 컨텐츠 생성 시작 : request = {}", request);

        log.debug("[콘텐츠 데이터 관리] 타입 생성 시작 : type = {}", request.type());

        if(request.type() == null) {
            throw new ContentBadRequestException("타입을 입력해주세요.");
        }

        if(thumbnail == null || thumbnail.isEmpty()) {
            throw new ContentBadRequestException("썸네일은 필수입니다.");
        }

        String imageKey = saveImageToS3(thumbnail);
        s3Uploader.upload(s3Properties.getContentBucket(), imageKey, thumbnail);
        Long uuid = UUID.randomUUID().getLeastSignificantBits();

        Content content = new Content(
                uuid,
                request.type(),
                request.title(),
                request.description(),
                imageKey,
                0,
                0,
                0);

        contentRepository.save(content);

        log.debug("[콘텐츠 데이터 관리] 태그 생성 시작 : tags = {}", request.tags());
        List<Tag> tagList = request.tags().stream()
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .distinct()
                .map(tagName -> new Tag(content, tagName))
                .toList();

        if(!tagList.isEmpty()) {
            tagRepository.saveAll(tagList);
        }
        log.info("[콘텐츠 데이터 관리] 태그 생성 완료 : tags = {}", tagList);

        log.info("[콘텐츠 데이터 관리] 컨텐츠 생성 완료, cotnent = {}, tagList = {}", content, tagList);

        ContentDto mapDto = contentMapper.toDtoUsingS3(content, tagList, s3Properties);
        return mapDto;
    }

    @Transactional
    @Override
    public ContentDto update(UUID contentId, ContentUpdateRequest request, MultipartFile thumbnail) {
        log.debug("[콘텐츠 데이터 관리] 컨텐츠 수정 시작 : id = {}", contentId);
        Content content = contentRepository.findById(contentId)
                .orElseThrow(() -> ContentNotFoundException.withId(contentId));

        String currentImagekey = content.getThumbnailUrl(); // key
        String updateImageKey = currentImagekey;

        if(thumbnail != null && !thumbnail.isEmpty()) { // 만약 썸네일이 들어왔다면, 저장함
            String newImageKey = saveImageToS3(thumbnail); // 새로운 썸네일 key를 생성하고,
            s3Uploader.upload(s3Properties.getContentBucket(), newImageKey, thumbnail); // 업로드한다
            updateImageKey = newImageKey;// 이걸 업데이트 이미지에 넣어준다
        }

        List<Tag> oldtags = tagRepository.findByContentId(contentId);
        if(!oldtags.isEmpty()) { // 비어있지 않다면, 싹 다 밀어버림
            tagRepository.deleteAll(oldtags);
        }

        log.info("[콘텐츠 데이터 관리] 태그 수정 시작 : tag = {}", request.tags());

        List<String> updateTags = request.tags();
        if(request.tags() == null) {
            updateTags = List.of();
        }

        List<Tag> tagList = updateTags.stream()
                        .map(String::trim)
                        .filter(s -> !s.isEmpty())
                        .distinct()
                        .map(tagName -> new Tag(content, tagName))
                        .toList();

        tagRepository.saveAll(tagList);
        log.info("[콘텐츠 데이터 관리] 태그 수정 완료 : tag = {}", tagList);

        content.updateContent(request.title(), request.description(), updateImageKey);
        if(!Objects.equals(updateImageKey, currentImagekey)) {
            deleteImageFromS3(currentImagekey); // 기존 이미지는 삭제한다
        }

        log.info("[콘텐츠 데이터 관리] 컨텐츠 수정 완료 : id = {}, tag = {}",
                content.getId(), tagRepository.findByContentId(content.getId()));

        ContentDto mapDto = contentMapper.toDtoUsingS3(content, tagList, s3Properties); // 맵핑을 통해 Dto로 변환
        return mapDto;
    }

    @Transactional
    @Override
    public void delete(UUID contentId) {
        log.debug("[콘텐츠 데이터 관리] 컨텐츠 삭제 시작 : id = {}", contentId);
        if(contentRepository.existsById(contentId)) {
            reviewRepository.deleteAllByContent_Id(contentId);
            playlistContentRepository.deleteAllByContent_Id(contentId);
            tagRepository.deleteAllByContentId(contentId); // contentId와 연결된 tags 리스트를 삭제함

            contentRepository.deleteById(contentId);
            log.info("[콘텐츠 데이터 관리] 컨텐츠 삭제 완료 : id = {}", contentId);
        } else {
            throw ContentNotFoundException.withId(contentId);
        }
    }

    @Transactional
    @Override
    public CursorResponseContentDto get(ContentCursorRequest request) {
        log.debug("[콘텐츠 데이터 관리] 커서 페이지네이션 컨텐츠 수집 시작, request = {}", request);
        int limit = request.limit();

        if(limit <= 0 || limit > 1000) {
            limit = 10;
        }

        String sortDirection = request.sortDirection() != null ? request.sortDirection().toString() : "DESCENDING";
        boolean ascending;

        switch(sortDirection) {
            case "ASCENDING":
                ascending = true;
                break;

            case "DESCENDING":
                ascending = false;
                break;

            default:
                throw new IllegalArgumentException("[콘텐츠 데이터 관리] sortDirection was something wrong : " + sortDirection);
        }

        String sortBy = request.sortBy();
        if(sortBy == null) {
            sortBy = "createdAt"; // 디폴트
        }

        List<Content> contents = contentRepository.searchContents(request, ascending, limit, sortBy);
        // hasNext 판단용으로 limit + 1개를 가져왔으니 실제 반환할 데이터는 limit개까지만
        int actualLimitSize = Math.min(contents.size(), limit);
        List<ContentDto> data = new ArrayList<>();

        /**
         * N+1 쿼리 문제 및 반환 데이터 크기 오류, codeRabbit 참조
         * 192 - 205
         */
        List<UUID> contentIds = contents.stream()
                .limit(actualLimitSize)
                .map(Content::getId)
                .toList();

        List<Tag> allTags = tagRepository.findByContentIdIn(contentIds);
        Map<UUID, List<Tag>> tagsByContentId = allTags.stream()
                .collect(Collectors.groupingBy(tag -> tag.getContent().getId()));

        for(int i=0; i < actualLimitSize; i++) {
            Content content = contents.get(i);
            content.setWatcherCount(redisWatchingSessionRepository.countWatchingSessionByContentId(content.getId()));
            List<Tag> tags = tagsByContentId.getOrDefault(content.getId(), List.of());
            ContentDto mapDto = contentMapper.toDtoUsingS3(content, tags, s3Properties);
            data.add(mapDto);
        }

        String nextCursor = null;
        String nextIdAfter = null;

        int size = contents.size();
        boolean hasNext = size == limit + 1;
        int pageSize = Math.min(size, limit);

        if(hasNext && pageSize > 0) {
            Content lastPage = contents.get(pageSize - 1); // 이번 페이지에서 실제로 반환되는 마지막 요소

            switch(sortBy) {
                case "createdAt":
                    nextCursor = lastPage.getCreatedAt().toString();
                    break;

                case "watcherCount":
                    nextCursor = String.valueOf(lastPage.getWatcherCount());
                    break;

                case "rate":
                    nextCursor = String.valueOf(lastPage.getAverageRating());
                    break;

                default:
                    throw new IllegalArgumentException("[콘텐츠 데이터 관리] sortBy was something wrong : " + sortBy);
            }

            nextIdAfter = lastPage.getId().toString();
        }

        CursorResponseContentDto responseDto = new CursorResponseContentDto(data, nextCursor, nextIdAfter, hasNext, pageSize, sortBy, sortDirection);
        log.debug("[콘텐츠 데이터 관리] 커서 페이지네이션 컨텐츠 수집 완료");
        return responseDto;
    }

    @Transactional
    public ContentDto search(UUID contentId) {
        log.info("[콘텐츠 데이터 관리] 컨텐츠 데이터 단건 조회 시작, contentId : {}",contentId);
        Content searchContent = contentRepository.findById(contentId)
                .orElseThrow(() -> ContentNotFoundException.withId(contentId));
        List<Tag> tags = tagRepository.findByContentId(searchContent.getId());
        log.info("[콘텐츠 데이터 관리] 컨텐츠 데이터 단건 조회 완료, searchContent : {}", searchContent);
        ContentDto mapDto = contentMapper.toDtoUsingS3(searchContent, tags, s3Properties);
        return mapDto;
    }

    private String saveImageToS3(MultipartFile file) {
        log.debug("[콘텐츠 데이터 관리] 썸네일 MultipartFile S3업로드 시작");

        if(file == null || file.isEmpty()) {
            log.debug("[콘텐츠 데이터 관리] file이 없어요.");
            throw new ContentBadRequestException("업로드 할 파일이 없어요.");
        }

        String contentType = file.getContentType();
        if(contentType == null || !contentType.startsWith("image/")) {
            throw new ContentBadRequestException("이미지 파일만 업로드 할 수 있어요.");
        }

        String originalFilename = file.getOriginalFilename();
        String extension = "";
        if(originalFilename != null && originalFilename.contains(".")) {
            extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        }
        String s3ImageKey = UUID.randomUUID() + extension;

        return s3ImageKey;
    }

    private void deleteImageFromS3(String key) {
        if(key != null && !key.isEmpty()) {
            s3Uploader.delete(s3Properties.getContentBucket(), key);
        }
    }

    private String s3Url(String s3ImageKey) {
        // https://(버킷명).s3.(지역명).amazonaws.com/(이미지 키)
        String url = "https://" + s3Properties.getContentBucket() + ".s3." + s3Properties.getRegion() + ".amazonaws.com/" + s3ImageKey;
        return url;
    }
}

'Playlist > Content' 카테고리의 다른 글

ContentException, ErrorCode  (0) 2025.12.17
Content, DTO  (0) 2025.12.17
ContentController  (0) 2025.12.17
Service : BasicContentService  (0) 2025.11.25
Entity : Content  (0) 2025.11.25