Skip to content

Commit

Permalink
feat: 가수 이름, 동의어 검색 기능 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
somsom13 committed Oct 12, 2023
1 parent 73036d1 commit f97321d
Show file tree
Hide file tree
Showing 24 changed files with 1,256 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ public enum ErrorCode {
EMPTY_ARTIST_PROFILE_URL(3014, "가수 프로필 이미지는 비어있을 수 없습니다."),
TOO_LONG_ARTIST_PROFILE_URL(3015, "가수 프로필 이미지URL은 65,356자를 넘길 수 없습니다."),
EMPTY_ARTIST_SYNONYM(3016, "가수 동의어는 비어있을 수 없습니다."),
TOO_LONG_ARTIST_SYNONYM(3017, "가수 동의어는 255자를 넘길 수 없습니다.."),
TOO_LONG_ARTIST_SYNONYM(3017, "가수 동의어는 255자를 넘길 수 없습니다."),
ARTIST_NOT_EXIST(3018, "존재하지 않는 가수입니다."),


// 4000: 투표
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 lombok.extern.slf4j.Slf4j;
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.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
@Slf4j
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;

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<Song> all = songRepository.findAll();
log.info("all song: {}", all);
final List<SongTotalLikeCountDto> songsWithTotalLikeCount = songRepository.findAllSongsWithTotalLikeCountByArtist(
artist);

log.info("found song of artist: {}", songsWithTotalLikeCount);
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))
));
}
}
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,47 @@
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)
);
}

private static List<SongSearchResponse> convertToSongSearchResponse(final List<Song> songs) {
return songs.stream()
.map(SongSearchResponse::from)
.toList();
}
}
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.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;

public static SongSearchResponse from(final Song song) {
return new SongSearchResponse(song.getId(), song.getTitle(), song.getAlbumCoverUrl(),
song.getLength());
}
}
12 changes: 12 additions & 0 deletions backend/src/main/java/shook/shook/song/domain/Artist.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,22 @@ public Artist(final ProfileImageUrl profileImageUrl, final ArtistName artistName
this.artistName = artistName;
}

public boolean nameStartsWith(final String keyword) {
return artistName.startsWithIgnoringCaseAndWhiteSpace(keyword);
}

public boolean nameEndsWith(final String keyword) {
return artistName.endsWithIgnoringCaseAndWhiteSpace(keyword);
}

public String getArtistName() {
return artistName.getValue();
}

public String getProfileImageUrl() {
return profileImageUrl.getValue();
}

@Override
public boolean equals(final Object o) {
if (this == o) {
Expand Down
29 changes: 29 additions & 0 deletions backend/src/main/java/shook/shook/song/domain/ArtistName.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
public class ArtistName {

private static final int NAME_MAXIMUM_LENGTH = 50;
private static final String BLANK = "\\s";

@Column(name = "name", length = 50, nullable = false)
private String value;
Expand All @@ -36,4 +37,32 @@ private void validateName(final String value) {
);
}
}

public boolean startsWithIgnoringCaseAndWhiteSpace(final String keyword) {
final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword);
if (StringChecker.isNullOrBlank(targetKeyword)) {
return false;
}

return toLowerCaseRemovingWhiteSpace(value)
.startsWith(toLowerCaseRemovingWhiteSpace(keyword));
}

public boolean endsWithIgnoringCaseAndWhiteSpace(final String keyword) {
final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword);
if (StringChecker.isNullOrBlank(targetKeyword)) {
return false;
}

return toLowerCaseRemovingWhiteSpace(value)
.endsWith(toLowerCaseRemovingWhiteSpace(keyword));
}

private String toLowerCaseRemovingWhiteSpace(final String word) {
return removeAllWhiteSpace(word).toLowerCase();
}

private String removeAllWhiteSpace(final String word) {
return word.replaceAll(BLANK, "");
}
}
19 changes: 14 additions & 5 deletions backend/src/main/java/shook/shook/song/domain/ArtistSynonym.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,28 @@ public class ArtistSynonym {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "artist_id", foreignKey = @ForeignKey(name = "none"), updatable = false, nullable = false)
private Artist artist;

@Embedded
private Synonym synonym;

public String getArtistName() {
return artist.getArtistName();
public ArtistSynonym(final Artist artist, final Synonym synonym) {
this.artist = artist;
this.synonym = synonym;
}

public boolean startsWith(final String keyword) {
return synonym.startsWithIgnoringCaseAndWhiteSpace(keyword);
}

public String getSynonym() {
return synonym.getValue();
public boolean endsWith(final String keyword) {
return synonym.endsWithIgnoringCaseAndWhiteSpace(keyword);
}

public String getArtistName() {
return artist.getArtistName();
}

@Override
Expand Down
Loading

0 comments on commit f97321d

Please sign in to comment.