Skip to content

Commit

Permalink
Feat/#498 Artist 엔티티 추가, 가수 검색 기능 구현 (#502)
Browse files Browse the repository at this point in the history
* feat: Artist 도메인 생성 및 Song singer -> artist 변경

* feat: artist 테이블 생성 및 song, voting_song singer -> artist_id 변경

* config: shook-security 스냅샷 최신화

* feat: ArtistSynonym 엔티티 및 테이블 생성

* feat: 가수 이름, 동의어 검색 기능 구현

* refactor: 코드리뷰 반영

* feat: 상세 가수 페이지 반환 시, 노래별 가수 이름 response 추가

* refactor: 불필요한 주석, 개행 제거

* feat: Artist 도메인 생성 및 Song singer -> artist 변경

* feat: artist 테이블 생성 및 song, voting_song singer -> artist_id 변경

* feat: ArtistSynonym 엔티티 및 테이블 생성

* feat: 가수 이름, 동의어 검색 기능 구현

* refactor: 코드리뷰 반영

* feat: 상세 가수 페이지 반환 시, 노래별 가수 이름 response 추가

* refactor: 불필요한 주석, 개행 제거

* refactor: MemberPart 기능 통합

* fix: dev data.sql 아티스트 추가

* feat: 인메모리 아티스트 업데이트 API 추가

* refactor: 검색 API와 가수 API 분리

* fix: song, singer 검색 조건 수정

* data: artist, 동의어 데이터 추가
  • Loading branch information
somsom13 authored Oct 19, 2023
1 parent bab208b commit fced3bd
Show file tree
Hide file tree
Showing 73 changed files with 2,640 additions and 596 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public enum ErrorCode {
CAN_NOT_READ_SONG_DATA_FILE(3011, "노래 데이터 파일을 읽을 수 없습니다."),
SONG_ALREADY_EXIST(3012, "등록하려는 노래가 이미 존재합니다."),
WRONG_GENRE_TYPE(3013, "잘못된 장르 타입입니다."),
EMPTY_ARTIST_PROFILE_URL(3014, "가수 프로필 이미지는 비어있을 수 없습니다."),
TOO_LONG_ARTIST_PROFILE_URL(3015, "가수 프로필 이미지URL은 65,356자를 넘길 수 없습니다."),
EMPTY_ARTIST_SYNONYM(3016, "가수 동의어는 비어있을 수 없습니다."),
TOO_LONG_ARTIST_SYNONYM(3017, "가수 동의어는 255자를 넘길 수 없습니다."),
ARTIST_NOT_EXIST(3018, "존재하지 않는 가수입니다."),


// 4000: 투표

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import shook.shook.member.exception.MemberException;
import shook.shook.member_part.exception.MemberPartException;
import shook.shook.part.exception.PartException;
import shook.shook.song.exception.ArtistException;
import shook.shook.song.exception.SongException;
import shook.shook.song.exception.killingpart.KillingPartCommentException;
import shook.shook.song.exception.killingpart.KillingPartException;
Expand Down Expand Up @@ -57,6 +58,7 @@ public ResponseEntity<ErrorResponse> handleTokenException(final CustomException
VotingSongException.class,
VotingSongPartException.PartNotExistException.class,
PartException.class,
ArtistException.class,
MemberPartException.class
})
public ResponseEntity<ErrorResponse> handleGlobalBadRequestException(final CustomException e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package shook.shook.song.application;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import shook.shook.song.application.dto.ArtistResponse;
import shook.shook.song.application.dto.ArtistWithSongSearchResponse;
import shook.shook.song.domain.Artist;
import shook.shook.song.domain.InMemoryArtistSynonyms;
import shook.shook.song.domain.InMemoryArtistSynonymsGenerator;
import shook.shook.song.domain.Song;
import shook.shook.song.domain.repository.ArtistRepository;
import shook.shook.song.domain.repository.SongRepository;
import shook.shook.song.domain.repository.dto.SongTotalLikeCountDto;
import shook.shook.song.exception.ArtistException;

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ArtistSearchService {

private static final int TOP_SONG_COUNT_OF_ARTIST = 3;

private final InMemoryArtistSynonyms inMemoryArtistSynonyms;
private final ArtistRepository artistRepository;
private final SongRepository songRepository;
private final InMemoryArtistSynonymsGenerator generator;

public List<ArtistResponse> searchArtistsByKeyword(final String keyword) {
final List<Artist> artists = findArtistsStartsWithKeyword(keyword);

return artists.stream()
.map(ArtistResponse::from)
.toList();
}

private List<Artist> findArtistsStartsWithKeyword(final String keyword) {
final List<Artist> artistsFoundByName = inMemoryArtistSynonyms.findAllArtistsNameStartsWith(
keyword);
final List<Artist> artistsFoundBySynonym = inMemoryArtistSynonyms.findAllArtistsHavingSynonymStartsWith(
keyword);

return removeDuplicateArtistResultAndSortByName(artistsFoundByName, artistsFoundBySynonym);
}

private List<Artist> removeDuplicateArtistResultAndSortByName(final List<Artist> firstResult,
final List<Artist> secondResult) {
return Stream.concat(firstResult.stream(), secondResult.stream())
.distinct()
.sorted(Comparator.comparing(Artist::getArtistName))
.toList();
}

public List<ArtistWithSongSearchResponse> searchArtistsAndTopSongsByKeyword(
final String keyword) {
final List<Artist> artists = findArtistsStartsOrEndsWithKeyword(keyword);

return artists.stream()
.map(artist -> ArtistWithSongSearchResponse.of(
artist,
getSongsOfArtistSortedByLikeCount(artist).size(),
getTopSongsOfArtist(artist))
)
.toList();
}

private List<Artist> findArtistsStartsOrEndsWithKeyword(final String keyword) {
final List<Artist> artistsFoundByName = inMemoryArtistSynonyms.findAllArtistsNameStartsOrEndsWith(
keyword);
final List<Artist> artistsFoundBySynonym = inMemoryArtistSynonyms.findAllArtistsHavingSynonymStartsOrEndsWith(
keyword);

return removeDuplicateArtistResultAndSortByName(artistsFoundByName, artistsFoundBySynonym);
}

private List<Song> getTopSongsOfArtist(final Artist artist) {
final List<Song> songs = getSongsOfArtistSortedByLikeCount(artist);
if (songs.size() < TOP_SONG_COUNT_OF_ARTIST) {
return songs;
}

return songs.subList(0, TOP_SONG_COUNT_OF_ARTIST);
}

private List<Song> getSongsOfArtistSortedByLikeCount(final Artist artist) {
final List<SongTotalLikeCountDto> songsWithTotalLikeCount = songRepository.findAllSongsWithTotalLikeCountByArtist(
artist);

return songsWithTotalLikeCount.stream()
.sorted(Comparator.comparing(SongTotalLikeCountDto::getTotalLikeCount,
Comparator.reverseOrder())
.thenComparing(songWithTotalLikeCount -> songWithTotalLikeCount.getSong().getId(),
Comparator.reverseOrder())
)
.map(SongTotalLikeCountDto::getSong)
.toList();
}

public ArtistWithSongSearchResponse searchAllSongsByArtist(final long artistId) {
final Artist artist = findArtistById(artistId);
final List<Song> songs = getSongsOfArtistSortedByLikeCount(artist);

return ArtistWithSongSearchResponse.of(artist, songs.size(), songs);
}

private Artist findArtistById(final long artistId) {
return artistRepository.findById(artistId)
.orElseThrow(() -> new ArtistException.NotExistException(
Map.of("ArtistId", String.valueOf(artistId))
));
}

public void updateArtistSynonymFromDatabase() {
generator.initialize();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import shook.shook.song.domain.Artist;
import shook.shook.song.domain.Genre;
import shook.shook.song.domain.KillingParts;
import shook.shook.song.domain.Song;
Expand Down Expand Up @@ -90,7 +91,10 @@ private Optional<Song> parseToSong(final Row currentRow) {
final Optional<KillingParts> killingParts = getKillingParts(cellIterator);

return killingParts.map(
parts -> new Song(title, videoId, albumCoverUrl, singer, length, Genre.from(genre), parts));
parts -> new Song(title, videoId, albumCoverUrl,
new Artist("image", "name"), length,
Genre.from(genre),
parts));
}

private String getString(final Iterator<Cell> iterator) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@
import shook.shook.song.domain.Genre;
import shook.shook.song.domain.InMemorySongs;
import shook.shook.song.domain.Song;
import shook.shook.song.domain.SongTitle;
import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository;
import shook.shook.song.domain.killingpart.repository.KillingPartRepository;
import shook.shook.song.domain.repository.ArtistRepository;
import shook.shook.song.domain.repository.SongRepository;
import shook.shook.song.exception.SongException;

@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand All @@ -43,6 +42,7 @@ public class SongService {
private final MemberRepository memberRepository;
private final MemberPartRepository memberPartRepository;
private final InMemorySongs inMemorySongs;
private final ArtistRepository artistRepository;
private final SongDataExcelReader songDataExcelReader;

@Transactional
Expand All @@ -53,9 +53,7 @@ public Long register(final SongWithKillingPartsRegisterRequest request) {
}

private Song saveSong(final Song song) {
if (songRepository.existsSongByTitle(new SongTitle(song.getTitle()))) {
throw new SongException.SongAlreadyExistException(Map.of("Song-Name", song.getTitle()));
}
artistRepository.save(song.getArtist());
final Song savedSong = songRepository.save(song);
killingPartRepository.saveAll(song.getKillingParts());
return savedSong;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package shook.shook.song.application.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import shook.shook.song.domain.Artist;

@Schema(description = "아티스트를 통한 아티스트, 해당 아티스트의 노래 검색 결과")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class ArtistResponse {

@Schema(description = "아티스트 id", example = "1")
private final Long id;

@Schema(description = "가수 이름", example = "가수")
private final String singer;

@Schema(description = "가수 대표 이미지 url", example = "https://image.com/artist-profile.jpg")
private final String profileImageUrl;

public static ArtistResponse from(final Artist artist) {
return new ArtistResponse(
artist.getId(),
artist.getArtistName(),
artist.getProfileImageUrl()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package shook.shook.song.application.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import shook.shook.song.domain.Artist;
import shook.shook.song.domain.Song;

@Schema(description = "아티스트를 통한 아티스트, 해당 아티스트의 노래 검색 결과")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class ArtistWithSongSearchResponse {

@Schema(description = "아티스트 id", example = "1")
private final Long id;

@Schema(description = "가수 이름", example = "가수")
private final String singer;

@Schema(description = "가수 대표 이미지 url", example = "https://image.com/artist-profile.jpg")
private final String profileImageUrl;

@Schema(description = "가수 노래 총 개수", example = "10")
private final int totalSongCount;

@Schema(description = "아티스트의 노래 목록")
private final List<SongSearchResponse> songs;

public static ArtistWithSongSearchResponse of(final Artist artist, final int totalSongCount,
final List<Song> songs) {
return new ArtistWithSongSearchResponse(
artist.getId(),
artist.getArtistName(),
artist.getProfileImageUrl(),
totalSongCount,
convertToSongSearchResponse(songs, artist.getArtistName())
);
}

private static List<SongSearchResponse> convertToSongSearchResponse(final List<Song> songs,
final String singer) {
return songs.stream()
.map(song -> SongSearchResponse.from(song, singer))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static LikedKillingPartResponse of(final Song song, final KillingPart kil
return new LikedKillingPartResponse(
song.getId(),
song.getTitle(),
song.getSinger(),
song.getArtistName(),
song.getAlbumCoverUrl(),
killingPart.getId(),
killingPart.getStartSecond(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static MyPartsResponse of(final Song song, final MemberPart memberPart) {
song.getId(),
song.getTitle(),
song.getVideoId(),
song.getSinger(),
song.getArtistName(),
song.getAlbumCoverUrl(),
memberPart.getId(),
memberPart.getStartSecond(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public static SongResponse of(final Song song, final List<Long> likedKillingPart
return new SongResponse(
song.getId(),
song.getTitle(),
song.getSinger(),
song.getArtistName(),
song.getLength(),
song.getVideoId(),
song.getAlbumCoverUrl(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package shook.shook.song.application.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import shook.shook.song.domain.Song;

@Schema(description = "검색 결과 (가수, 가수의 노래) 응답")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class SongSearchResponse {

@Schema(description = "노래 id", example = "1")
private final Long id;

@Schema(description = "노래 제목", example = "제목")
private final String title;

@Schema(description = "노래 앨범 커버 이미지 url", example = "https://image.com/album-cover.jpg")
private final String albumCoverUrl;

@Schema(description = "노래 비디오 길이", example = "247")
private final int videoLength;

@Schema(description = "가수 이름", example = "가수")
private final String singer;

public static SongSearchResponse from(final Song song, final String singer) {
return new SongSearchResponse(
song.getId(),
song.getTitle(),
song.getAlbumCoverUrl(),
song.getLength(),
singer
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import shook.shook.song.domain.Artist;
import shook.shook.song.domain.Genre;
import shook.shook.song.domain.KillingParts;
import shook.shook.song.domain.Song;
Expand All @@ -34,7 +35,11 @@ public class SongWithKillingPartsRegisterRequest {

@Schema(description = "가수 이름", example = "가수")
@NotBlank
private String singer;
private String artistName;

@Schema(description = "가수 프로필 이미지", example = "https://image.com/singer-profile.jpg")
@NotBlank
private String profileImageUrl;

@Schema(description = "노래 길이", example = "247")
@NotNull
Expand All @@ -50,8 +55,15 @@ public class SongWithKillingPartsRegisterRequest {
private List<KillingPartRegisterRequest> killingParts;

public Song convertToSong() {
return new Song(title, videoId, imageUrl, singer, length, Genre.from(genre),
convertToKillingParts());
return new Song(
title,
videoId,
imageUrl,
new Artist(profileImageUrl, artistName),
length,
Genre.from(genre),
convertToKillingParts()
);
}

private KillingParts convertToKillingParts() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ private static HighLikedSongResponse from(final Song song) {
return new HighLikedSongResponse(
song.getId(),
song.getTitle(),
song.getSinger(),
song.getArtistName(),
song.getAlbumCoverUrl(),
song.getTotalLikeCount(),
song.getGenre().name()
Expand Down
Loading

0 comments on commit fced3bd

Please sign in to comment.