From 8f1fbcdcaa6aed760f3d38f71b1d87948590f676 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 11:22:36 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20ArtistEnrichService=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/service/ArtistEnrichService.java | 925 +----------------- .../artists/service/ArtistGroupValidator.java | 107 ++ .../artists/service/EnrichStepExecutor.java | 274 ++++++ .../artists/service/WikidataEnrichHelper.java | 160 +++ 4 files changed, 588 insertions(+), 878 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistGroupValidator.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/EnrichStepExecutor.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/WikidataEnrichHelper.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java index 69c5c938..0dc94aff 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java @@ -3,10 +3,7 @@ import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType; import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; -import com.back.web7_9_codecrete_be.global.flo.FloClient; import com.back.web7_9_codecrete_be.global.musicbrainz.MusicBrainzClient; -import com.back.web7_9_codecrete_be.global.wikidata.WikidataClient; -import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -23,9 +20,9 @@ public class ArtistEnrichService { private final ArtistRepository artistRepository; - private final FloClient floClient; private final MusicBrainzClient musicBrainzClient; - private final WikidataClient wikidataClient; + private final EnrichStepExecutor stepExecutor; + private final ArtistGroupValidator groupValidator; // MusicBrainz ID만 받아오기 public int fetchMusicBrainzIds(int limit) { @@ -33,37 +30,23 @@ public int fetchMusicBrainzIds(int limit) { List targets = artistRepository.findByMusicBrainzIdIsNullOrderByIdAsc( PageRequest.of(0, actualLimit) ); - log.info("MusicBrainz ID 수집 시작: 요청 limit={}, 실제 limit={}, 대상 {}명", limit, actualLimit, targets.size()); - if (targets.isEmpty()) { - log.warn("⚠️ MusicBrainz ID를 수집할 대상 아티스트가 없습니다."); return 0; } int updated = 0; - int failed = 0; - for (Artist artist : targets) { try { fetchMusicBrainzId(artist); updated++; - - // API rate limit 고려 (MusicBrainz는 1초에 1회 요청 권장) - try { - Thread.sleep(1100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("⚠️ MusicBrainz ID 수집 중 sleep 중단됨: 처리된 개수={}", updated); - break; - } + Thread.sleep(1100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; } catch (Exception e) { - log.error("❌ MusicBrainz ID 수집 예외 발생: artistId={}, name={}, error={}", - artist.getId(), artist.getArtistName(), e.getMessage(), e); - failed++; + // 개별 실패는 로그 생략 } } - - log.info("📊 MusicBrainz ID 수집 완료: 성공={}, 실패={}, 총={}", updated, failed, targets.size()); return updated; } @@ -74,46 +57,21 @@ protected void fetchMusicBrainzId(Artist artist) { ? artist.getNameKo() : artist.getArtistName(); - log.debug("MusicBrainz ID 수집 중: artistId={}, nameKo={}, artistName={}, searchName={}", - artist.getId(), artist.getNameKo(), artist.getArtistName(), searchName); - try { Optional mbInfoOpt = musicBrainzClient.searchArtist(searchName); - if (mbInfoOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); + if (mbInfoOpt.isPresent() && mbInfoOpt.get().getMbid() != null && !mbInfoOpt.get().getMbid().isBlank()) { + String mbid = mbInfoOpt.get().getMbid(); + artist.setMusicBrainzId(mbid); - // MusicBrainzClient에서 이미 name이나 aliases 일치 확인을 했으므로, 결과가 있으면 사용 - if (mbInfo.getMbid() != null && !mbInfo.getMbid().isBlank()) { - String mbid = mbInfo.getMbid(); - artist.setMusicBrainzId(mbid); - - // MusicBrainz ID로 아티스트 조회하여 relations에서 member of band의 artist.name 가져오기 - Optional mbDetailOpt = musicBrainzClient.getArtistByMbid(mbid); - if (mbDetailOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbDetail = mbDetailOpt.get(); - - // relations 배열에서 member of band의 artist.name 가져오기 - if (mbDetail.getArtistGroup() != null && !mbDetail.getArtistGroup().isBlank()) { - artist.setArtistGroup(mbDetail.getArtistGroup()); - log.debug("소속 그룹 추출 성공 (MusicBrainz ID 조회): artistId={}, mbid={}, group={}", - artist.getId(), mbid, mbDetail.getArtistGroup()); - } - } - - artistRepository.save(artist); - log.info("✅ MusicBrainz ID 수집 성공: artistId={}, searchName={}, mbid={}, mbName={}, group={}", - artist.getId(), searchName, mbid, mbInfo.getName(), artist.getArtistGroup()); - } else { - log.warn("⚠️ MusicBrainz MBID가 비어있음: artistId={}, searchName={}", - artist.getId(), searchName); + Optional mbDetailOpt = musicBrainzClient.getArtistByMbid(mbid); + if (mbDetailOpt.isPresent() && mbDetailOpt.get().getArtistGroup() != null && !mbDetailOpt.get().getArtistGroup().isBlank()) { + artist.setArtistGroup(mbDetailOpt.get().getArtistGroup()); } - } else { - log.warn("⚠️ MusicBrainz 검색 결과 없음: artistId={}, searchName={}", - artist.getId(), searchName); + + artistRepository.save(artist); } } catch (Exception e) { - log.warn("MusicBrainz ID 수집 실패: artistId={}, searchName={}, error={}", - artist.getId(), searchName, e.getMessage(), e); + // 개별 실패는 로그 생략 } } @@ -123,117 +81,63 @@ public int enrichArtist(int limit) { List targets = artistRepository.findByNameKoIsNullOrderByIdAsc( PageRequest.of(0, actualLimit) ); - log.info("통합 enrich 시작 (Wikidata + Wikipedia + MusicBrainz): 요청 limit={}, 실제 limit={}, 대상 {}명", - limit, actualLimit, targets.size()); - if (targets.isEmpty()) { - log.warn("⚠️ enrich할 대상 아티스트가 없습니다. (모두 이미 enrich되었거나 DB에 아티스트가 없습니다)"); return 0; } + int updated = 0; - int failedNotFound = 0; - int failedException = 0; - + int failed = 0; for (Artist artist : targets) { try { - // 각 아티스트마다 별도 트랜잭션으로 처리하여 즉시 커밋 enrichSingleArtist(artist); updated++; - - // API rate limit 고려 (가장 느린 MusicBrainz 기준) - // InterruptedException을 안전하게 처리 - try { - Thread.sleep(1100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("⚠️ Enrich 중 sleep 중단됨 (서버 종료 가능성): 처리된 개수={}", updated); - // 이미 처리된 것은 저장되었으므로 break - break; - } - - } catch (RuntimeException e) { - // 아티스트 정보를 찾을 수 없는 경우 - if (e.getMessage() != null && e.getMessage().contains("아티스트 정보를 찾을 수 없음")) { - failedNotFound++; - } else { - log.error("❌ Enrich 예외 발생: artistId={}, name={}, spotifyId={}, error={}", - artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId(), e.getMessage(), e); - failedException++; - } + Thread.sleep(1100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; } catch (Exception e) { - log.error("❌ Enrich 예외 발생: artistId={}, name={}, spotifyId={}, error={}", - artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId(), e.getMessage(), e); - failedException++; + failed++; } } - int totalFailed = failedNotFound + failedException; - log.info("📊 통합 enrich 완료: 성공={}, 실패={} (정보없음={}, 예외={}), 총={}", - updated, totalFailed, failedNotFound, failedException, targets.size()); + log.info("Enrich 완료: 성공={}, 실패={}, 총={}", updated, failed, targets.size()); return updated; } @Transactional(propagation = Propagation.REQUIRES_NEW) protected void enrichSingleArtist(Artist artist) { - log.debug("Enrich 처리 중: artistId={}, name={}, spotifyId={}", - artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId()); - EnrichResult result = enrichArtist(artist); if (result == null) { - log.warn("❌ 아티스트 정보를 찾을 수 없음: artistId={}, name={}, spotifyId={}", - artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId()); throw new RuntimeException("아티스트 정보를 찾을 수 없음"); } - // 기존 artistType이 있으면 유지, 없으면 가져온 값 사용 String artistTypeStr = result.artistType != null ? result.artistType : (artist.getArtistType() != null ? artist.getArtistType().name() : null); - // String을 ArtistType enum으로 변환 ArtistType artistType; if (artistTypeStr != null) { try { artistType = ArtistType.valueOf(artistTypeStr); } catch (IllegalArgumentException e) { - log.warn("잘못된 artistType 값: {}, null 사용", artistTypeStr); artistType = null; } } else { - // 기존 값이 없고 새 값도 없으면 null 사용 artistType = artist.getArtistType(); } - // artistGroup 처리: enrich에서 찾지 못했으면 기존 값 유지 String finalArtistGroup = result.artistGroup != null ? result.artistGroup : artist.getArtistGroup(); - if (result.artistGroup == null && artist.getArtistGroup() != null) { - log.debug("enrich에서 그룹 정보를 찾지 못했지만 기존 값 유지: artistId={}, 기존 group={}", - artist.getId(), artist.getArtistGroup()); - } - // artistType이 GROUP이면 artistGroup을 null로 설정 (그룹에는 그룹 이름이 아니라 null이어야 함) if (artistType == ArtistType.GROUP) { finalArtistGroup = null; - log.debug("artistType이 GROUP이므로 artistGroup을 null로 설정: artistId={}, name={}", - artist.getId(), artist.getArtistName()); } - // artistGroup 검증: 멤버 이름, 출연 프로그램, 소속사 등 잘못된 값 필터링 if (finalArtistGroup != null) { - finalArtistGroup = validateArtistGroup(finalArtistGroup, artist.getArtistName(), artist.getNameKo()); - if (finalArtistGroup == null) { - log.debug("artistGroup 검증 실패로 null 처리: artistId={}, name={}", - artist.getId(), artist.getArtistName()); - } + finalArtistGroup = groupValidator.validate(finalArtistGroup, artist.getArtistName(), artist.getNameKo()); } - // 기존 row를 "보강" artist.updateProfile(result.nameKo, finalArtistGroup, artistType); - // 명시적으로 save하여 변경사항을 DB에 즉시 반영 artistRepository.save(artist); - log.info("Enrich 성공: artistId={}, name={}, nameKo={}, group={}, type={}, source={}", - artist.getId(), artist.getArtistName(), result.nameKo, - finalArtistGroup, artistType, result.source); } private EnrichResult enrichArtist(Artist artist) { @@ -242,776 +146,41 @@ private EnrichResult enrichArtist(Artist artist) { String artistType = null; String source = ""; - // -1단계: MusicBrainz ID가 이미 있는 경우, 그것으로 직접 Wikidata 검색 (최우선) - String mbidSource = null; // MBID 출처 추적: "direct-mbid", "wikidata" 또는 "spotify-url" - JsonNode mbidWikidataEntity = null; // MusicBrainz ID로 찾은 Wikidata 엔티티 - String mbidWikidataQid = null; - - if (artist.getMusicBrainzId() != null && !artist.getMusicBrainzId().isBlank()) { - try { - Optional qidOpt = wikidataClient.searchWikidataIdByMusicBrainzId(artist.getMusicBrainzId()); - if (qidOpt.isPresent()) { - mbidWikidataQid = qidOpt.get(); - mbidSource = "direct-mbid"; - log.info("MusicBrainz ID로 직접 Wikidata QID 검색 결과: mbid={}, qid={}, artistName={}", - artist.getMusicBrainzId(), mbidWikidataQid, artist.getArtistName()); - - Optional entityOpt = wikidataClient.getEntityInfo(mbidWikidataQid); - if (entityOpt.isPresent()) { - mbidWikidataEntity = entityOpt.get(); - - // 1. Wikidata P31로 artistType 판별 - if (artistType == null) { - List instanceOfList = wikidataClient.getAllEntityIdClaims(mbidWikidataEntity, "P31"); - boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); - boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); - - if (isGroup) { - artistType = "GROUP"; - source += "Wikidata(MBID) "; - log.debug("artistType 추출 성공 (Wikidata via MBID): mbid={}, type=GROUP", - artist.getMusicBrainzId()); - } else if (isHuman) { - artistType = "SOLO"; - source += "Wikidata(MBID) "; - log.debug("artistType 추출 성공 (Wikidata via MBID): mbid={}, type=SOLO", - artist.getMusicBrainzId()); - } - } - - // 2. MusicBrainz 상세 조회 - Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(artist.getMusicBrainzId()); - if (mbInfoOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); - - // Type 덮어쓰기 정책: 합의(consensus) 방식 - if (mbInfo.getArtistType() != null && !mbInfo.getArtistType().isBlank()) { - String mbType = mbInfo.getArtistType(); - String wdType = artistType; - - if (wdType != null && wdType.equals(mbType)) { - // 합의: 두 소스가 같으면 확정(High confidence) - artistType = mbType; - source = source.replace("Wikidata(MBID) ", ""); - source += "MusicBrainz(High) "; - log.debug("artistType 합의 확정 (Wikidata=MusicBrainz via MBID): mbid={}, type={}", - artist.getMusicBrainzId(), mbType); - } else if (wdType == null) { - // Wikidata에서 type을 못 찾았으면 MusicBrainz 사용 - artistType = mbType; - source += "MusicBrainz "; - log.debug("artistType 추출 성공 (MusicBrainz, Wikidata type 없음): mbid={}, type={}", - artist.getMusicBrainzId(), mbType); - } else { - // 충돌: 덮어쓰기 금지, Wikidata 유지 - log.warn("artistType 충돌 감지 - 덮어쓰기 금지: mbid={}, Wikidata={}, MusicBrainz={}, Wikidata 유지", - artist.getMusicBrainzId(), wdType, mbType); - } - } - - // SOLO일 때만 group 추출 (Wikidata 우선, 없으면 MusicBrainz) - if ("SOLO".equals(artistType)) { - // Wikidata에서 먼저 시도 - if (artistGroup == null) { - artistGroup = resolveGroupNameFromWikidata(mbidWikidataEntity); - if (artistGroup != null) { - source += "Wikidata(MBID) "; - log.debug("소속 그룹 추출 성공 (Wikidata via MBID): mbid={}, group={}", - artist.getMusicBrainzId(), artistGroup); - } - } - // Wikidata에서 못 찾으면 MusicBrainz에서 시도 - if (artistGroup == null && mbInfo.getArtistGroup() != null && - !mbInfo.getArtistGroup().isBlank()) { - artistGroup = mbInfo.getArtistGroup(); - source += "MusicBrainz "; - log.debug("소속 그룹 추출 성공 (MusicBrainz): mbid={}, group={}", - artist.getMusicBrainzId(), artistGroup); - } - } - } - - if (artistType != null || artistGroup != null) { - log.info("✅ -1단계 성공 (MBID 직접 검색): artistId={}, mbid={}, qid={}, type={}, group={}", - artist.getId(), artist.getMusicBrainzId(), mbidWikidataQid, artistType, artistGroup); - } - } - } else { - log.debug("MusicBrainz ID로 Wikidata QID 검색 결과 없음: mbid={}", artist.getMusicBrainzId()); - } - } catch (Exception e) { - log.warn("⚠️ -1단계 실패 (MBID 직접 검색): artistId={}, mbid={} (예외 발생: {})", - artist.getId(), artist.getMusicBrainzId(), e.getMessage(), e); - } - } - - // 0단계: Spotify ID 기반 (MusicBrainz ID로 찾지 못한 경우에만) - if (mbidSource == null && artist.getSpotifyArtistId() != null && !artist.getSpotifyArtistId().isBlank()) { - try { - // Spotify ID로 Wikidata 후보 리스트 검색 - List candidateQids = wikidataClient.searchWikidataIdCandidatesBySpotifyId(artist.getSpotifyArtistId()); - log.info("Spotify ID로 Wikidata QID 검색 결과: spotifyId={}, artistName={}, 후보 개수={}, QIDs={}", - artist.getSpotifyArtistId(), artist.getArtistName(), candidateQids.size(), candidateQids); - if (candidateQids.isEmpty()) { - log.debug("Spotify ID로 Wikidata 후보 없음: spotifyId={}", artist.getSpotifyArtistId()); - } else { - log.debug("Spotify ID로 Wikidata 후보 {}개 발견: spotifyId={}, candidates={}", - candidateQids.size(), artist.getSpotifyArtistId(), candidateQids); - } - - // 후보 리스트에서 검증하여 최적 QID 선택 - String qid = null; - JsonNode entity = null; - int bestScore = -1; - - for (String candidateQid : candidateQids) { - Optional entityOpt = wikidataClient.getEntityInfo(candidateQid); - if (entityOpt.isEmpty()) { - continue; - } - - JsonNode candidateEntity = entityOpt.get(); - int score = validateWikidataEntity(candidateEntity, artist.getArtistName(), nameKo); - - if (score > bestScore) { - bestScore = score; - qid = candidateQid; - entity = candidateEntity; - } - } - - // 검증 통과한 QID가 있으면 사용 - if (qid != null && entity != null && bestScore > 0) { - log.debug("Wikidata QID 검증 통과: spotifyId={}, qid={}, score={}", - artist.getSpotifyArtistId(), qid, bestScore); - - // 1. Wikidata P31로 artistType 판별 - // musical group 있으면 GROUP, else human 있으면 SOLO - if (artistType == null) { - List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); - boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); - boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); - - if (isGroup) { - artistType = "GROUP"; - source += "Wikidata "; - log.debug("artistType 추출 성공 (Wikidata): spotifyId={}, type=GROUP", - artist.getSpotifyArtistId()); - } else if (isHuman) { - artistType = "SOLO"; - source += "Wikidata "; - log.debug("artistType 추출 성공 (Wikidata): spotifyId={}, type=SOLO", - artist.getSpotifyArtistId()); - } - } - - // 2. Wikidata P434로 MBID 확보 - List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); - if (!mbids.isEmpty()) { - String mbid = mbids.get(0); - mbidSource = "wikidata"; - log.debug("Wikidata에서 MusicBrainz ID 찾음: spotifyId={}, mbid={}", - artist.getSpotifyArtistId(), mbid); - - // MBID 상세 조회 - Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(mbid); - if (mbInfoOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); - - // Type 덮어쓰기 정책: 합의(consensus) 방식 - // Wikidata type과 MusicBrainz type이 같으면 → 확정(High) - // 서로 다르면 → 덮어쓰기 금지, Wikidata 유지 + confidence 낮춤 - if (mbInfo.getArtistType() != null && !mbInfo.getArtistType().isBlank() && - "wikidata".equals(mbidSource)) { - String mbType = mbInfo.getArtistType(); - String wdType = artistType; - - if (wdType != null && wdType.equals(mbType)) { - // 합의: 두 소스가 같으면 확정(High confidence) - artistType = mbType; - source = source.replace("Wikidata ", ""); - source += "MusicBrainz(High) "; - log.debug("artistType 합의 확정 (Wikidata=MusicBrainz): spotifyId={}, mbid={}, type={}", - artist.getSpotifyArtistId(), mbid, mbType); - } else if (wdType == null) { - // Wikidata에서 type을 못 찾았으면 MusicBrainz 사용 - artistType = mbType; - source += "MusicBrainz "; - log.debug("artistType 추출 성공 (MusicBrainz, Wikidata type 없음): spotifyId={}, mbid={}, type={}", - artist.getSpotifyArtistId(), mbid, mbType); - } else { - // 충돌: 덮어쓰기 금지, Wikidata 유지 - log.warn("artistType 충돌 감지 - 덮어쓰기 금지: spotifyId={}, Wikidata={}, MusicBrainz={}, Wikidata 유지", - artist.getSpotifyArtistId(), wdType, mbType); - } - } - - // SOLO일 때만 group 추출 (Wikidata 우선, 없으면 MusicBrainz) - if ("SOLO".equals(artistType)) { - // Wikidata에서 먼저 시도 (SPARQL 쿼리 사용) - if (artistGroup == null) { - List groups = wikidataClient.searchGroupBySpotifyId(artist.getSpotifyArtistId()); - if (!groups.isEmpty()) { - // 첫 번째 그룹 사용 (가장 대표적인 그룹) - artistGroup = groups.get(0); - source += "Wikidata(SPARQL) "; - log.debug("소속 그룹 추출 성공 (Wikidata SPARQL): spotifyId={}, group={}, 후보={}", - artist.getSpotifyArtistId(), artistGroup, groups); - } else { - // SPARQL로 못 찾으면 기존 방식 시도 - artistGroup = resolveGroupNameFromWikidata(entity); - if (artistGroup != null) { - source += "Wikidata "; - log.debug("소속 그룹 추출 성공 (Wikidata): spotifyId={}, group={}", - artist.getSpotifyArtistId(), artistGroup); - } + // -1단계: MusicBrainz ID로 직접 Wikidata 검색 + EnrichStepExecutor.EnrichStepResult stepMinusOne = stepExecutor.executeStepMinusOne(artist, artistType, artistGroup); + if (stepMinusOne.artistType != null || stepMinusOne.artistGroup != null) { + artistType = stepMinusOne.artistType != null ? stepMinusOne.artistType : artistType; + artistGroup = stepMinusOne.artistGroup != null ? stepMinusOne.artistGroup : artistGroup; + source += stepMinusOne.source != null ? stepMinusOne.source : ""; } - } - // Wikidata에서 못 찾으면 MusicBrainz에서 시도 - if (artistGroup == null && mbInfo.getArtistGroup() != null && - !mbInfo.getArtistGroup().isBlank()) { - artistGroup = mbInfo.getArtistGroup(); - source += "MusicBrainz "; - log.debug("소속 그룹 추출 성공 (MusicBrainz): spotifyId={}, mbid={}, group={}", - artist.getSpotifyArtistId(), mbid, artistGroup); - } - } - } - } - - if (artistType != null || artistGroup != null) { - log.info("✅ 0단계 성공: artistId={}, spotifyId={}, type={}, group={}", - artist.getId(), artist.getSpotifyArtistId(), artistType, artistGroup); - } - } else { - log.warn("⚠️ Wikidata QID 검증 실패: spotifyId={}, 후보 {}개 중 검증 통과 없음", - artist.getSpotifyArtistId(), candidateQids.size()); - } - - // 0.5단계: MusicBrainz에서 Spotify URL로 MBID 검색 (Wikidata에서 못 찾은 경우) - if (mbidSource == null) { - Optional mbidFromSpotifyOpt = musicBrainzClient.searchMbidBySpotifyUrl(artist.getSpotifyArtistId()); - if (mbidFromSpotifyOpt.isPresent()) { - String mbid = mbidFromSpotifyOpt.get(); - mbidSource = "spotify-url"; - log.debug("Spotify URL로 MusicBrainz ID 찾음: spotifyId={}, mbid={}", - artist.getSpotifyArtistId(), mbid); - - // MBID 상세 조회 - Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(mbid); - if (mbInfoOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); - // Type 덮어쓰기 정책: 합의(consensus) 방식 - if (mbInfo.getArtistType() != null && !mbInfo.getArtistType().isBlank() && - "spotify-url".equals(mbidSource)) { - String mbType = mbInfo.getArtistType(); - String wdType = artistType; - - if (wdType != null && wdType.equals(mbType)) { - // 합의: 두 소스가 같으면 확정(High confidence) - artistType = mbType; - source += "MusicBrainz(High) "; - log.debug("artistType 합의 확정 (Wikidata=MusicBrainz): spotifyId={}, mbid={}, type={}", - artist.getSpotifyArtistId(), mbid, mbType); - } else if (wdType == null) { - // Wikidata에서 type을 못 찾았으면 MusicBrainz 사용 - artistType = mbType; - source += "MusicBrainz "; - log.debug("artistType 추출 성공 (MusicBrainz, Wikidata type 없음): spotifyId={}, mbid={}, type={}", - artist.getSpotifyArtistId(), mbid, mbType); - } else { - // 충돌: 덮어쓰기 금지, Wikidata 유지 - log.warn("artistType 충돌 감지 - 덮어쓰기 금지: spotifyId={}, Wikidata={}, MusicBrainz={}, Wikidata 유지", - artist.getSpotifyArtistId(), wdType, mbType); - } - } - - // SOLO일 때만 group 추출 (MusicBrainz만 사용, Wikidata는 이미 시도했거나 없음) - if ("SOLO".equals(artistType) && artistGroup == null) { - if (mbInfo.getArtistGroup() != null && !mbInfo.getArtistGroup().isBlank()) { - artistGroup = mbInfo.getArtistGroup(); - source += "MusicBrainz "; - log.debug("소속 그룹 추출 성공 (MusicBrainz): spotifyId={}, mbid={}, group={}", - artist.getSpotifyArtistId(), mbid, artistGroup); - } - } - } - } - } - } catch (Exception e) { - log.warn("⚠️ 0단계 실패: artistId={}, spotifyId={} (예외 발생: {})", - artist.getId(), artist.getSpotifyArtistId(), e.getMessage(), e); + // 0단계: Spotify ID 기반 검색 + if (artist.getMusicBrainzId() == null || artist.getMusicBrainzId().isBlank()) { + EnrichStepExecutor.EnrichStepResult stepZero = stepExecutor.executeStepZero(artist, artistType, artistGroup, nameKo); + if (stepZero.artistType != null || stepZero.artistGroup != null) { + artistType = stepZero.artistType != null ? stepZero.artistType : artistType; + artistGroup = stepZero.artistGroup != null ? stepZero.artistGroup : artistGroup; + source += stepZero.source != null ? stepZero.source : ""; } } // 1단계: FLO Client로 한국어 이름 가져오기 - try { - Optional floInfoOpt = floClient.searchArtist(artist.getArtistName()); - if (floInfoOpt.isPresent()) { - FloClient.ArtistInfo floInfo = floInfoOpt.get(); - - // 한국어 이름만 가져오기 - if (floInfo.getNameKo() != null && !floInfo.getNameKo().isBlank()) { - nameKo = floInfo.getNameKo(); - source += "FLO "; - log.info("✅ 1단계 성공: artistId={}, name={}, nameKo={}", - artist.getId(), artist.getArtistName(), nameKo); - } else { - log.warn("⚠️ 1단계 실패: artistId={}, name={} (FLO에서 한국어 이름 없음)", - artist.getId(), artist.getArtistName()); - } - } else { - log.warn("⚠️ 1단계 실패: artistId={}, name={} (FLO 검색 결과 없음)", - artist.getId(), artist.getArtistName()); - } - } catch (Exception e) { - log.warn("⚠️ 1단계 실패: artistId={}, name={} (예외 발생: {})", - artist.getId(), artist.getArtistName(), e.getMessage()); + EnrichStepExecutor.EnrichStepResult stepOne = stepExecutor.executeStepOne(artist); + if (stepOne.nameKo != null) { + nameKo = stepOne.nameKo; + source += stepOne.source != null ? stepOne.source : ""; } - // nameKo는 optional - 없어도 다음 단계 진행 가능 - - // 2단계: nameKo 기반 MB 검색 (0단계에서 MBID 못 찾았을 때만) - // artistType만 보조적으로 사용, 기존 값 없을 때만 채움 - // artist.type이 Group/Person이 명확한 것만 사용 - // artistGroup은 절대 설정하지 않음 - if (mbidSource == null && artistType == null) { - // nameKo가 없으면 검색 불가 - if (nameKo == null || nameKo.isBlank()) { - log.debug("2단계 스킵: nameKo가 없어서 MusicBrainz 검색 불가"); - } else { - try { - Optional mbInfoOpt = musicBrainzClient.searchArtist(nameKo); - if (mbInfoOpt.isPresent()) { - MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); - - // artistType만 보조적으로 사용 (기존 값 없을 때만 채움) - // artist.type이 Group/Person이 명확한 것만 사용 - // Person → SOLO, Group → GROUP - // 중요: "소속 그룹이 있다"는 이유로 SOLO를 GROUP으로 바꾸면 안 됨 - if (artistType == null && mbInfo.getArtistType() != null && !mbInfo.getArtistType().isBlank()) { - String mbType = mbInfo.getArtistType(); - // Group 또는 Person이 명확한 경우만 사용 - if ("GROUP".equals(mbType) || "SOLO".equals(mbType)) { - artistType = mbType; - source += "MusicBrainz(LOW) "; - log.debug("artistType 추출 성공 (MusicBrainz, LOW confidence): nameKo={}, type={}", nameKo, artistType); - } else { - log.debug("artistType이 명확하지 않음, 무시: nameKo={}, type={}", nameKo, mbType); - } - } - - // artistGroup은 절대 설정하지 않음 (2단계에서는 group 추출 금지) - if (artistType != null) { - log.info("✅ 2단계 성공: artistId={}, name={}, nameKo={}, type={}", - artist.getId(), artist.getArtistName(), nameKo, artistType); - } - } else { - log.warn("⚠️ 2단계 실패: artistId={}, name={}, nameKo={} (검색 결과 없음)", - artist.getId(), artist.getArtistName(), nameKo); - } - } catch (Exception e) { - log.warn("⚠️ 2단계 실패: artistId={}, name={}, nameKo={} (예외 발생: {})", - artist.getId(), artist.getArtistName(), nameKo, e.getMessage(), e); - } - } + // 2단계: nameKo 기반 MusicBrainz 검색 + EnrichStepExecutor.EnrichStepResult stepTwo = stepExecutor.executeStepTwo(nameKo, artistType); + if (stepTwo.artistType != null) { + artistType = stepTwo.artistType; + source += stepTwo.source != null ? stepTwo.source : ""; } return new EnrichResult(nameKo, artistGroup, artistType, source.trim()); - } - - // Wikidata 엔티티에서 정보 추출 (한국이름-활동명, 소속그룹, 솔로/그룹) - private EnrichResult extractInfoFromWikidata(JsonNode entity) { - String nameKo = null; - String artistGroup = null; - String artistType = null; - - // 1. 한국어 이름 가져오기 (활동명 기준) - // 우선순위: labels.ko.value (활동명) > Wikipedia 한국어 이름 - JsonNode koLabel = entity.path("labels").path("ko").path("value"); - if (!koLabel.isMissingNode() && !koLabel.asText().isBlank()) { - nameKo = koLabel.asText(); - log.debug("Wikidata 한국어 label (활동명) 확보: {}", nameKo); - } else { - // Wikipedia에서 한국어 이름 가져오기 - Optional nameKoOpt = wikidataClient.getKoreanNameFromWikipedia(entity); - if (nameKoOpt.isPresent()) { - nameKo = nameKoOpt.get(); - log.debug("Wikidata Wikipedia 한국어 이름 확보: {}", nameKo); - } - } - - // 2. 아티스트 타입 추출 (솔로/그룹) - artistType = inferArtistTypeFromWikidata(entity); - - // 3. 소속 그룹 추출 - artistGroup = resolveGroupNameFromWikidata(entity); - - // 한국어 이름이 있어야만 성공으로 간주 - if (nameKo == null) { - return null; - } - - return new EnrichResult(nameKo, artistGroup, artistType, ""); } - //Wikidata 엔티티에서 아티스트 타입 추출 - private String inferArtistTypeFromWikidata(JsonNode entity) { - // P31 instance of: human(Q5), musical group(Q215380) - List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); - log.debug("Wikidata instanceOf 목록: {}", instanceOfList); - - // 타입 판별 로직 - boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); - boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); - - if (isGroup) { - log.debug("Q215380 (musical group) 발견 -> GROUP"); - return "GROUP"; - } else if (isHuman) { - log.debug("Q5 (human) 발견 -> SOLO"); - return "SOLO"; - } - - log.warn("Wikidata에서 artistType을 판단할 수 없음: instanceOf={}", instanceOfList); - return null; - } - - // Wikidata 엔티티에서 소속 그룹 이름 추출 (음악 그룹만, 출연 프로그램/소속사 제외) - // 대표 그룹 선택 규칙: 한국 그룹 우선, nameKo/label 우선, 정렬 기준 적용 - private String resolveGroupNameFromWikidata(JsonNode artistEntity) { - log.debug("소속 그룹 추출 시작: Wikidata 엔티티에서 P463 (member of) 속성 확인"); - - // 모든 member of 엔티티 가져오기 - List memberOfQids = wikidataClient.getAllEntityIdClaims(artistEntity, "P463"); - if (memberOfQids.isEmpty()) { - log.debug("Wikidata에서 member of (P463) 속성 없음 - 소속 그룹 없음"); - return null; - } - - log.debug("Wikidata에서 member of 엔티티 {}개 발견: {}", memberOfQids.size(), memberOfQids); - - // 음악 그룹 후보 수집 (P31 = Q215380 필터 필수) - List candidates = new java.util.ArrayList<>(); - - for (String qid : memberOfQids) { - log.debug("그룹 엔티티 확인 중: qid={}", qid); - Optional entityOpt = wikidataClient.getEntityInfo(qid); - if (entityOpt.isEmpty()) { - log.warn("Wikidata 엔티티 조회 실패: qid={}, 다음 후보 확인", qid); - continue; - } - - JsonNode entity = entityOpt.get(); - - // 음악 그룹인지 확인 (P31 = Q215380) - 필터 필수 - List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); - log.debug("엔티티 instanceOf 확인: qid={}, instanceOf={}", qid, instanceOfList); - - boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); - if (!isGroup) { - // 음악 그룹이 아니면 스킵 (출연 프로그램, 소속사 등 제외) - log.debug("음악 그룹이 아님, 제외: qid={}, instanceOf={} (출연 프로그램/소속사일 가능성)", qid, instanceOfList); - continue; - } - - // 음악 그룹 확인됨 - 후보에 추가 - log.debug("음악 그룹 확인됨: qid={}", qid); - - // 그룹 이름 추출 시도 (우선순위 순) - String koLabel = null; - String nameKo = null; - String enLabel = null; - String wikiTitle = null; - - // 1. 한국어 label - JsonNode koLabelNode = entity.path("labels").path("ko").path("value"); - if (!koLabelNode.isMissingNode() && !koLabelNode.asText().isBlank()) { - koLabel = koLabelNode.asText(); - } - - // 2. Wikipedia 한국어 이름 - Optional nameKoOpt = wikidataClient.getKoreanNameFromWikipedia(entity); - if (nameKoOpt.isPresent()) { - nameKo = nameKoOpt.get(); - } - - // 3. 영어 label - JsonNode enLabelNode = entity.path("labels").path("en").path("value"); - if (!enLabelNode.isMissingNode() && !enLabelNode.asText().isBlank()) { - enLabel = enLabelNode.asText(); - } - - // 4. 한국어 Wikipedia 제목 - Optional wikiTitleOpt = wikidataClient.getWikipediaKoreanTitle(entity); - if (wikiTitleOpt.isPresent()) { - wikiTitle = wikiTitleOpt.get(); - } - - // 대표 그룹 선택을 위한 점수 계산 - int score = 0; - String selectedName = null; - - // 한국어 이름이 있으면 높은 점수 - if (koLabel != null) { - score += 100; - selectedName = koLabel; - } else if (nameKo != null) { - score += 90; - selectedName = nameKo; - } else if (wikiTitle != null) { - score += 80; - selectedName = wikiTitle; - } else if (enLabel != null) { - score += 50; - selectedName = enLabel; - } - - // P434 (MusicBrainz ID) 또는 공식 사이트/위키백과 링크가 있으면 보너스 - List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); - if (!mbids.isEmpty()) { - score += 20; - } - - // sitelinks가 있으면 보너스 - JsonNode sitelinks = entity.path("sitelinks"); - if (!sitelinks.isMissingNode() && sitelinks.size() > 0) { - score += 10; - } - - if (selectedName != null) { - candidates.add(new GroupCandidate(qid, selectedName, score)); - } - } - - if (candidates.isEmpty()) { - log.warn("Wikidata에서 음악 그룹을 찾을 수 없음 (출연 프로그램, 소속사 등만 있음)"); - return null; - } - - // 점수 순으로 정렬 (높은 점수 우선) - candidates.sort((a, b) -> Integer.compare(b.score, a.score)); - - // 최고 점수 후보 반환 - GroupCandidate best = candidates.get(0); - log.debug("대표 그룹 선택: qid={}, name={}, score={} (후보 {}개 중)", - best.qid, best.name, best.score, candidates.size()); - return best.name; - } - - // 그룹 후보 클래스 - private static class GroupCandidate { - final String qid; - final String name; - final int score; - - GroupCandidate(String qid, String name, int score) { - this.qid = qid; - this.name = name; - this.score = score; - } - } - - // Wikidata 엔티티 검증 (QID 후보 검증) - private int validateWikidataEntity(JsonNode entity, String artistName, String nameKo) { - int score = 0; - - // 1. P31(instance of)이 human(Q5) 또는 musical group(Q215380) 포함해야 함 - List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); - boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); - boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); - boolean hasValidType = isGroup || isHuman; - - if (!hasValidType) { - log.debug("Wikidata 엔티티 검증 실패: P31에 human/musical group 없음, instanceOf={}", instanceOfList); - return 0; // 검증 실패 - } - score += 50; // 기본 점수 - - // 2. label(ko/en)이 artistName 또는 nameKo와 부분일치 - String koLabel = entity.path("labels").path("ko").path("value").asText(null); - String enLabel = entity.path("labels").path("en").path("value").asText(null); - - boolean nameMatches = false; - if (artistName != null && !artistName.isBlank()) { - String artistNameLower = artistName.toLowerCase().trim(); - if (koLabel != null && koLabel.toLowerCase().contains(artistNameLower)) { - nameMatches = true; - score += 30; - } - if (enLabel != null && enLabel.toLowerCase().contains(artistNameLower)) { - nameMatches = true; - score += 30; - } - } - - if (nameKo != null && !nameKo.isBlank()) { - String nameKoLower = nameKo.toLowerCase().trim(); - if (koLabel != null && koLabel.toLowerCase().contains(nameKoLower)) { - nameMatches = true; - score += 30; - } - } - - if (!nameMatches) { - log.debug("Wikidata 엔티티 검증 실패: label이 artistName/nameKo와 일치하지 않음, koLabel={}, enLabel={}, artistName={}, nameKo={}", - koLabel, enLabel, artistName, nameKo); - // 이름 일치가 없어도 기본 점수는 유지 (P31 검증 통과) - } - - // 3. P434(MusicBrainz ID) 또는 공식 사이트/위키백과 링크가 있으면 우선 - List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); - if (!mbids.isEmpty()) { - score += 20; - } - - // sitelinks 확인 (위키백과 링크) - JsonNode sitelinks = entity.path("sitelinks"); - if (!sitelinks.isMissingNode() && sitelinks.size() > 0) { - score += 10; - } - - return score; - } - - // artistGroup 검증: 멤버 이름, 출연 프로그램, 소속사 등 잘못된 값 필터링 - private String validateArtistGroup(String groupName, String artistName, String nameKo) { - if (groupName == null || groupName.isBlank()) { - return null; - } - - String normalizedGroupName = normalizeForComparison(groupName); - String lowerGroupName = groupName.toLowerCase().trim(); - - // 0. 그룹 이름이 너무 짧으면 의심 (개인 이름일 가능성) - if (normalizedGroupName.length() <= 3) { - log.debug("artistGroup이 너무 짧아서 null 처리: group={}, length={}", - groupName, normalizedGroupName.length()); - return null; - } - - // 1. 그룹 이름이 아티스트 이름과 동일하거나 유사한 경우 (멤버 이름인 경우) - // 더 엄격한 검증: 정규화된 이름이 완전히 일치하거나, 한쪽이 다른 쪽을 포함하는 경우 - if (artistName != null && !artistName.isBlank()) { - String normalizedArtistName = normalizeForComparison(artistName); - String lowerArtistName = artistName.toLowerCase().trim(); - - // 완전 일치 - if (normalizedGroupName.equals(normalizedArtistName)) { - log.warn("artistGroup이 아티스트 이름과 완전 일치하여 null 처리: group={}, artistName={}", - groupName, artistName); - return null; - } - - // 그룹 이름이 아티스트 이름을 포함하는 경우 (예: "RM"이 "RM of BTS"에 포함) - // 하지만 반대는 허용 (예: "BTS"에 "RM"이 포함되는 것은 정상) - if (normalizedGroupName.contains(normalizedArtistName) && - normalizedArtistName.length() >= 2) { // 너무 짧은 부분 문자열은 무시 - // 예외: 그룹 이름이 아티스트 이름으로 시작하거나 끝나는 경우만 필터링 - // (예: "RM"이 "RM"으로 시작하는 경우는 제외, "RM of BTS" 같은 경우는 허용) - if (normalizedGroupName.startsWith(normalizedArtistName) || - normalizedGroupName.endsWith(normalizedArtistName)) { - log.warn("artistGroup이 아티스트 이름으로 시작/끝나서 null 처리: group={}, artistName={}", - groupName, artistName); - return null; - } - } - - // 아티스트 이름이 그룹 이름을 포함하는 경우 (예: "RM"에 "BTS"가 포함되는 것은 이상함) - if (normalizedArtistName.contains(normalizedGroupName) && - normalizedGroupName.length() >= 2) { - log.warn("artistGroup이 아티스트 이름에 포함되어 null 처리: group={}, artistName={}", - groupName, artistName); - return null; - } - - // 대소문자 무시 완전 일치 - if (lowerGroupName.equals(lowerArtistName)) { - log.warn("artistGroup이 아티스트 이름과 대소문자 무시 일치하여 null 처리: group={}, artistName={}", - groupName, artistName); - return null; - } - } - - if (nameKo != null && !nameKo.isBlank()) { - String normalizedNameKo = normalizeForComparison(nameKo); - String lowerNameKo = nameKo.toLowerCase().trim(); - - // 완전 일치 - if (normalizedGroupName.equals(normalizedNameKo)) { - log.warn("artistGroup이 nameKo와 완전 일치하여 null 처리: group={}, nameKo={}", - groupName, nameKo); - return null; - } - - // 그룹 이름이 nameKo를 포함하는 경우 - if (normalizedGroupName.contains(normalizedNameKo) && - normalizedNameKo.length() >= 2) { - if (normalizedGroupName.startsWith(normalizedNameKo) || - normalizedGroupName.endsWith(normalizedNameKo)) { - log.warn("artistGroup이 nameKo로 시작/끝나서 null 처리: group={}, nameKo={}", - groupName, nameKo); - return null; - } - } - - // nameKo가 그룹 이름을 포함하는 경우 - if (normalizedNameKo.contains(normalizedGroupName) && - normalizedGroupName.length() >= 2) { - log.warn("artistGroup이 nameKo에 포함되어 null 처리: group={}, nameKo={}", - groupName, nameKo); - return null; - } - - // 대소문자 무시 완전 일치 - if (lowerGroupName.equals(lowerNameKo)) { - log.warn("artistGroup이 nameKo와 대소문자 무시 일치하여 null 처리: group={}, nameKo={}", - groupName, nameKo); - return null; - } - } - - // 2. 출연 프로그램 키워드 체크 - String[] programKeywords = { - "produce", "show", "survival", "audition", "competition", - "프로듀스", "쇼", "서바이벌", "오디션", "경쟁", "프로그램" - }; - for (String keyword : programKeywords) { - if (lowerGroupName.contains(keyword)) { - log.debug("artistGroup이 출연 프로그램으로 판단되어 null 처리: group={}", groupName); - return null; - } - } - - // 3. 소속사 키워드 체크 (주요 엔터테인먼트 회사) - String[] companyKeywords = { - "sm entertainment", "yg entertainment", "jyp entertainment", "cube entertainment", - "pledis entertainment", "starship entertainment", "fantagio", "woollim", - "fnc entertainment", "rbw", "source music", "bighit", "hybe", - "sm", "yg", "jyp", "cube", "pledis", "starship", "fantagio", - "woollim", "fnc", "source", "bighit", "hybe", - "엔터테인먼트", "엔터", "기획사", "소속사" - }; - for (String keyword : companyKeywords) { - if (lowerGroupName.contains(keyword)) { - log.debug("artistGroup이 소속사로 판단되어 null 처리: group={}", groupName); - return null; - } - } - - return groupName; - } - - // 이름 정규화 (비교용) - private String normalizeForComparison(String name) { - if (name == null) { - return ""; - } - return name.toLowerCase() - .replaceAll("[\\s\\-_\\(\\)\\[\\]]", "") // 공백, 하이픈, 언더스코어, 괄호 제거 - .trim(); - } private static class EnrichResult { final String nameKo; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistGroupValidator.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistGroupValidator.java new file mode 100644 index 00000000..10d3a88e --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistGroupValidator.java @@ -0,0 +1,107 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import org.springframework.stereotype.Component; + +@Component +public class ArtistGroupValidator { + + /** + * artistGroup 검증: 멤버 이름, 출연 프로그램, 소속사 등 잘못된 값 필터링 + */ + public String validate(String groupName, String artistName, String nameKo) { + if (groupName == null || groupName.isBlank()) { + return null; + } + + String normalizedGroupName = normalizeForComparison(groupName); + String lowerGroupName = groupName.toLowerCase().trim(); + + if (normalizedGroupName.length() <= 3) { + return null; + } + + if (artistName != null && !artistName.isBlank()) { + String normalizedArtistName = normalizeForComparison(artistName); + String lowerArtistName = artistName.toLowerCase().trim(); + + if (normalizedGroupName.equals(normalizedArtistName) || + lowerGroupName.equals(lowerArtistName)) { + return null; + } + + if (normalizedGroupName.contains(normalizedArtistName) && + normalizedArtistName.length() >= 2) { + if (normalizedGroupName.startsWith(normalizedArtistName) || + normalizedGroupName.endsWith(normalizedArtistName)) { + return null; + } + } + + if (normalizedArtistName.contains(normalizedGroupName) && + normalizedGroupName.length() >= 2) { + return null; + } + } + + if (nameKo != null && !nameKo.isBlank()) { + String normalizedNameKo = normalizeForComparison(nameKo); + String lowerNameKo = nameKo.toLowerCase().trim(); + + if (normalizedGroupName.equals(normalizedNameKo) || + lowerGroupName.equals(lowerNameKo)) { + return null; + } + + if (normalizedGroupName.contains(normalizedNameKo) && + normalizedNameKo.length() >= 2) { + if (normalizedGroupName.startsWith(normalizedNameKo) || + normalizedGroupName.endsWith(normalizedNameKo)) { + return null; + } + } + + if (normalizedNameKo.contains(normalizedGroupName) && + normalizedGroupName.length() >= 2) { + return null; + } + } + + String[] programKeywords = { + "produce", "show", "survival", "audition", "competition", + "프로듀스", "쇼", "서바이벌", "오디션", "경쟁", "프로그램" + }; + for (String keyword : programKeywords) { + if (lowerGroupName.contains(keyword)) { + return null; + } + } + + String[] companyKeywords = { + "sm entertainment", "yg entertainment", "jyp entertainment", "cube entertainment", + "pledis entertainment", "starship entertainment", "fantagio", "woollim", + "fnc entertainment", "rbw", "source music", "bighit", "hybe", + "sm", "yg", "jyp", "cube", "pledis", "starship", "fantagio", + "woollim", "fnc", "source", "bighit", "hybe", + "엔터테인먼트", "엔터", "기획사", "소속사" + }; + for (String keyword : companyKeywords) { + if (lowerGroupName.contains(keyword)) { + return null; + } + } + + return groupName; + } + + /** + * 이름 정규화 (비교용) + */ + private String normalizeForComparison(String name) { + if (name == null) { + return ""; + } + return name.toLowerCase() + .replaceAll("[\\s\\-_\\(\\)\\[\\]]", "") + .trim(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/EnrichStepExecutor.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/EnrichStepExecutor.java new file mode 100644 index 00000000..0be5dde9 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/EnrichStepExecutor.java @@ -0,0 +1,274 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import com.back.web7_9_codecrete_be.global.flo.FloClient; +import com.back.web7_9_codecrete_be.global.musicbrainz.MusicBrainzClient; +import com.back.web7_9_codecrete_be.global.wikidata.WikidataClient; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class EnrichStepExecutor { + + private final FloClient floClient; + private final MusicBrainzClient musicBrainzClient; + private final WikidataClient wikidataClient; + private final WikidataEnrichHelper wikidataHelper; + + /** + * -1단계: MusicBrainz ID로 직접 Wikidata 검색 + */ + public EnrichStepResult executeStepMinusOne(Artist artist, String currentArtistType, String currentArtistGroup) { + if (artist.getMusicBrainzId() == null || artist.getMusicBrainzId().isBlank()) { + return new EnrichStepResult(null, null, null, null); + } + + try { + Optional qidOpt = wikidataClient.searchWikidataIdByMusicBrainzId(artist.getMusicBrainzId()); + if (qidOpt.isEmpty()) { + return new EnrichStepResult(null, null, null, null); + } + + String qid = qidOpt.get(); + Optional entityOpt = wikidataClient.getEntityInfo(qid); + if (entityOpt.isEmpty()) { + return new EnrichStepResult(null, null, null, null); + } + + JsonNode mbidWikidataEntity = entityOpt.get(); + String artistType = currentArtistType; + String source = ""; + + if (artistType == null) { + artistType = wikidataHelper.inferArtistType(mbidWikidataEntity); + if (artistType != null) { + source += "Wikidata(MBID) "; + } + } + + Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(artist.getMusicBrainzId()); + if (mbInfoOpt.isPresent()) { + MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); + artistType = resolveTypeConsensus(artistType, mbInfo.getArtistType(), source); + source = artistType != null && artistType.equals(mbInfo.getArtistType()) + ? source.replace("Wikidata(MBID) ", "") + "MusicBrainz(High) " + : source; + + String artistGroup = currentArtistGroup; + if ("SOLO".equals(artistType)) { + if (artistGroup == null) { + artistGroup = wikidataHelper.resolveGroupName(mbidWikidataEntity); + if (artistGroup != null) { + source += "Wikidata(MBID) "; + } + } + if (artistGroup == null && mbInfo.getArtistGroup() != null && !mbInfo.getArtistGroup().isBlank()) { + artistGroup = mbInfo.getArtistGroup(); + source += "MusicBrainz "; + } + } + + return new EnrichStepResult(null, artistGroup, artistType, source); + } + + return new EnrichStepResult(null, currentArtistGroup, artistType, source); + } catch (Exception e) { + return new EnrichStepResult(null, null, null, null); + } + } + + /** + * 0단계: Spotify ID 기반 검색 + */ + public EnrichStepResult executeStepZero(Artist artist, String currentArtistType, String currentArtistGroup, String currentNameKo) { + if (artist.getSpotifyArtistId() == null || artist.getSpotifyArtistId().isBlank()) { + return new EnrichStepResult(null, null, null, null); + } + + try { + List candidateQids = wikidataClient.searchWikidataIdCandidatesBySpotifyId(artist.getSpotifyArtistId()); + + String qid = null; + JsonNode entity = null; + int bestScore = -1; + + for (String candidateQid : candidateQids) { + Optional entityOpt = wikidataClient.getEntityInfo(candidateQid); + if (entityOpt.isEmpty()) continue; + + int score = wikidataHelper.validateEntity(entityOpt.get(), artist.getArtistName(), currentNameKo); + if (score > bestScore) { + bestScore = score; + qid = candidateQid; + entity = entityOpt.get(); + } + } + + if (qid == null || entity == null || bestScore <= 0) { + return executeStepZeroPointFive(artist, currentArtistType, currentArtistGroup); + } + + String artistType = currentArtistType; + String source = ""; + + if (artistType == null) { + artistType = wikidataHelper.inferArtistType(entity); + if (artistType != null) { + source += "Wikidata "; + } + } + + List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); + if (!mbids.isEmpty()) { + String mbid = mbids.get(0); + Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(mbid); + if (mbInfoOpt.isPresent()) { + MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); + artistType = resolveTypeConsensus(artistType, mbInfo.getArtistType(), source); + source = artistType != null && artistType.equals(mbInfo.getArtistType()) + ? source.replace("Wikidata ", "") + "MusicBrainz(High) " + : source; + + String artistGroup = currentArtistGroup; + if ("SOLO".equals(artistType)) { + if (artistGroup == null) { + List groups = wikidataClient.searchGroupBySpotifyId(artist.getSpotifyArtistId()); + if (!groups.isEmpty()) { + artistGroup = groups.get(0); + source += "Wikidata(SPARQL) "; + } else { + artistGroup = wikidataHelper.resolveGroupName(entity); + if (artistGroup != null) { + source += "Wikidata "; + } + } + } + if (artistGroup == null && mbInfo.getArtistGroup() != null && !mbInfo.getArtistGroup().isBlank()) { + artistGroup = mbInfo.getArtistGroup(); + source += "MusicBrainz "; + } + } + + return new EnrichStepResult(null, artistGroup, artistType, source); + } + } + + return new EnrichStepResult(null, currentArtistGroup, artistType, source); + } catch (Exception e) { + return executeStepZeroPointFive(artist, currentArtistType, currentArtistGroup); + } + } + + /** + * 0.5단계: Spotify URL로 MusicBrainz ID 검색 + */ + private EnrichStepResult executeStepZeroPointFive(Artist artist, String currentArtistType, String currentArtistGroup) { + try { + Optional mbidOpt = musicBrainzClient.searchMbidBySpotifyUrl(artist.getSpotifyArtistId()); + if (mbidOpt.isEmpty()) { + return new EnrichStepResult(null, null, null, null); + } + + String mbid = mbidOpt.get(); + Optional mbInfoOpt = musicBrainzClient.getArtistByMbid(mbid); + if (mbInfoOpt.isEmpty()) { + return new EnrichStepResult(null, null, null, null); + } + + MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); + String artistType = resolveTypeConsensus(currentArtistType, mbInfo.getArtistType(), ""); + String source = artistType != null && artistType.equals(mbInfo.getArtistType()) + ? "MusicBrainz(High) " + : "MusicBrainz "; + + String artistGroup = currentArtistGroup; + if ("SOLO".equals(artistType) && artistGroup == null) { + if (mbInfo.getArtistGroup() != null && !mbInfo.getArtistGroup().isBlank()) { + artistGroup = mbInfo.getArtistGroup(); + source += "MusicBrainz "; + } + } + + return new EnrichStepResult(null, artistGroup, artistType, source); + } catch (Exception e) { + return new EnrichStepResult(null, null, null, null); + } + } + + /** + * 1단계: FLO Client로 한국어 이름 가져오기 + */ + public EnrichStepResult executeStepOne(Artist artist) { + try { + Optional floInfoOpt = floClient.searchArtist(artist.getArtistName()); + if (floInfoOpt.isPresent() && floInfoOpt.get().getNameKo() != null && !floInfoOpt.get().getNameKo().isBlank()) { + return new EnrichStepResult(floInfoOpt.get().getNameKo(), null, null, "FLO "); + } + } catch (Exception e) { + // 실패는 조용히 넘어감 + } + return new EnrichStepResult(null, null, null, null); + } + + /** + * 2단계: nameKo 기반 MusicBrainz 검색 + */ + public EnrichStepResult executeStepTwo(String nameKo, String currentArtistType) { + if (nameKo == null || nameKo.isBlank() || currentArtistType != null) { + return new EnrichStepResult(null, null, null, null); + } + + try { + Optional mbInfoOpt = musicBrainzClient.searchArtist(nameKo); + if (mbInfoOpt.isPresent() && mbInfoOpt.get().getArtistType() != null && + !mbInfoOpt.get().getArtistType().isBlank()) { + String mbType = mbInfoOpt.get().getArtistType(); + if ("GROUP".equals(mbType) || "SOLO".equals(mbType)) { + return new EnrichStepResult(null, null, mbType, "MusicBrainz(LOW) "); + } + } + } catch (Exception e) { + // 실패는 조용히 넘어감 + } + return new EnrichStepResult(null, null, null, null); + } + + /** + * 타입 합의 로직 (Wikidata와 MusicBrainz 타입 비교) + */ + private String resolveTypeConsensus(String wdType, String mbType, String currentSource) { + if (mbType == null || mbType.isBlank()) { + return wdType; + } + + if (wdType != null && wdType.equals(mbType)) { + return mbType; // 합의: 두 소스가 같으면 확정 + } else if (wdType == null) { + return mbType; // Wikidata에서 못 찾았으면 MusicBrainz 사용 + } else { + return wdType; // 충돌: Wikidata 유지 + } + } + + /** + * 단계 실행 결과 + */ + public static class EnrichStepResult { + final String nameKo; + final String artistGroup; + final String artistType; + final String source; + + EnrichStepResult(String nameKo, String artistGroup, String artistType, String source) { + this.nameKo = nameKo; + this.artistGroup = artistGroup; + this.artistType = artistType; + this.source = source; + } + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/WikidataEnrichHelper.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/WikidataEnrichHelper.java new file mode 100644 index 00000000..34b955df --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/WikidataEnrichHelper.java @@ -0,0 +1,160 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import com.back.web7_9_codecrete_be.global.wikidata.WikidataClient; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class WikidataEnrichHelper { + + private final WikidataClient wikidataClient; + + /** + * Wikidata 엔티티에서 아티스트 타입 추출 + */ + public String inferArtistType(JsonNode entity) { + List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); + boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); + boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); + + if (isGroup) { + return "GROUP"; + } else if (isHuman) { + return "SOLO"; + } + + return null; + } + + /** + * Wikidata 엔티티에서 소속 그룹 이름 추출 + */ + public String resolveGroupName(JsonNode artistEntity) { + List memberOfQids = wikidataClient.getAllEntityIdClaims(artistEntity, "P463"); + if (memberOfQids.isEmpty()) { + return null; + } + + List candidates = new ArrayList<>(); + + for (String qid : memberOfQids) { + Optional entityOpt = wikidataClient.getEntityInfo(qid); + if (entityOpt.isEmpty()) continue; + + JsonNode entity = entityOpt.get(); + List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); + boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); + if (!isGroup) continue; + + String koLabel = entity.path("labels").path("ko").path("value").asText(null); + Optional nameKoOpt = wikidataClient.getKoreanNameFromWikipedia(entity); + String nameKo = nameKoOpt.orElse(null); + String enLabel = entity.path("labels").path("en").path("value").asText(null); + Optional wikiTitleOpt = wikidataClient.getWikipediaKoreanTitle(entity); + String wikiTitle = wikiTitleOpt.orElse(null); + + int score = 0; + String selectedName = null; + + if (koLabel != null && !koLabel.isBlank()) { + score += 100; + selectedName = koLabel; + } else if (nameKo != null && !nameKo.isBlank()) { + score += 90; + selectedName = nameKo; + } else if (wikiTitle != null && !wikiTitle.isBlank()) { + score += 80; + selectedName = wikiTitle; + } else if (enLabel != null && !enLabel.isBlank()) { + score += 50; + selectedName = enLabel; + } + + List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); + if (!mbids.isEmpty()) { + score += 20; + } + + JsonNode sitelinks = entity.path("sitelinks"); + if (!sitelinks.isMissingNode() && sitelinks.size() > 0) { + score += 10; + } + + if (selectedName != null) { + candidates.add(new GroupCandidate(qid, selectedName, score)); + } + } + + if (candidates.isEmpty()) { + return null; + } + + candidates.sort((a, b) -> Integer.compare(b.score, a.score)); + return candidates.get(0).name; + } + + /** + * Wikidata 엔티티 검증 (QID 후보 검증) + */ + public int validateEntity(JsonNode entity, String artistName, String nameKo) { + int score = 0; + + List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); + boolean isGroup = instanceOfList.contains("http://www.wikidata.org/entity/Q215380"); + boolean isHuman = instanceOfList.contains("http://www.wikidata.org/entity/Q5"); + if (!isGroup && !isHuman) { + return 0; + } + score += 50; + + String koLabel = entity.path("labels").path("ko").path("value").asText(null); + String enLabel = entity.path("labels").path("en").path("value").asText(null); + + if (artistName != null && !artistName.isBlank()) { + String artistNameLower = artistName.toLowerCase().trim(); + if (koLabel != null && koLabel.toLowerCase().contains(artistNameLower)) { + score += 30; + } + if (enLabel != null && enLabel.toLowerCase().contains(artistNameLower)) { + score += 30; + } + } + + if (nameKo != null && !nameKo.isBlank()) { + String nameKoLower = nameKo.toLowerCase().trim(); + if (koLabel != null && koLabel.toLowerCase().contains(nameKoLower)) { + score += 30; + } + } + + List mbids = wikidataClient.getAllEntityIdClaims(entity, "P434"); + if (!mbids.isEmpty()) { + score += 20; + } + + JsonNode sitelinks = entity.path("sitelinks"); + if (!sitelinks.isMissingNode() && sitelinks.size() > 0) { + score += 10; + } + + return score; + } + + private static class GroupCandidate { + final String qid; + final String name; + final int score; + + GroupCandidate(String qid, String name, int score) { + this.qid = qid; + this.name = name; + this.score = score; + } + } +} From e1fec74d8e91f97cd4a59e2ac6f054a9e7af0ba6 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 12:21:21 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=20DTO=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/controller/ArtistsController.java | 10 ++++------ .../dto/response/ArtistSliceResponse.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistSliceResponse.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java index 1f1bafae..d2562fe7 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java @@ -1,13 +1,10 @@ package com.back.web7_9_codecrete_be.domain.artists.controller; +import com.back.web7_9_codecrete_be.domain.artists.dto.response.*; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistSort; import com.back.web7_9_codecrete_be.domain.artists.dto.request.CreateRequest; import com.back.web7_9_codecrete_be.domain.artists.dto.request.SearchRequest; import com.back.web7_9_codecrete_be.domain.artists.dto.request.UpdateRequest; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistListResponse; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistDetailResponse; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ConcertListByArtistResponse; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.SearchResponse; import com.back.web7_9_codecrete_be.domain.artists.service.ArtistService; import com.back.web7_9_codecrete_be.domain.artists.service.ArtistEnrichService; import com.back.web7_9_codecrete_be.domain.users.entity.User; @@ -69,12 +66,13 @@ public RsData create( @Operation(summary = "아티스트 목록 조회", description = "아티스트 전체 목록을 조회합니다(NAME: 이름순 / LIKE: 인기순 (좋아요 많은 순)") @GetMapping() - public RsData> list( + public RsData list( Pageable pageable, @RequestParam(required = false) ArtistSort sort ) { User user = rq.getUserOrNull(); // 로그인하지 않은 경우 null - return RsData.success("아티스트 전체 목록을 조회했습니다.", artistService.listArtist(pageable, user, sort)); + return RsData.success("아티스트 전체 목록을 조회했습니다.", + ArtistSliceResponse.from(artistService.listArtist(pageable, user, sort))); } @Operation(summary = "아티스트 상세 조회", description = "아티스트의 상세 정보를 조회합니다.") diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistSliceResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistSliceResponse.java new file mode 100644 index 00000000..e3fc5f68 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistSliceResponse.java @@ -0,0 +1,17 @@ +package com.back.web7_9_codecrete_be.domain.artists.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public record ArtistSliceResponse( + @Schema(description = "아티스트 정보입니다.") + List content +) { + public static ArtistSliceResponse from(Slice slice) { + return new ArtistSliceResponse( + slice.getContent() + ); + } +} From e20c80c325f6d6b2edbabae7981edb24a040672c Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 12:34:07 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20DTO=20=EC=97=90=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=20=EA=B8=B0=EC=A4=80=20=EC=95=84=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ArtistDetailResponse.java | 6 ++++ .../dto/response/ArtistListResponse.java | 5 +++ .../dto/response/RelatedArtistResponse.java | 3 ++ .../artists/dto/response/SearchResponse.java | 4 +++ .../artists/service/SpotifyService.java | 32 +++++++++++++++---- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java index 86b70f3d..ca429f8a 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java @@ -6,9 +6,15 @@ import java.util.List; public record ArtistDetailResponse( + @Schema(description = "아티스트 아이디입니다.") + Long id, + @Schema(description = "아티스트 이름입니다.") String artistName, + @Schema(description = "한국어 기준 아티스트 이름입니다.") + String nameKo, + @Schema(description = "아티스트 소속 그룹입니다. 아티스트 이름이 그룹인 경우, null 로 처리됩니다.") String artistGroup, diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java index d10ecffa..f9988889 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistListResponse.java @@ -12,6 +12,9 @@ public record ArtistListResponse( @Schema(description = "아티스트 이름입니다.") String artistName, + @Schema(description = "한국어 기준 아티스트 이름 입니다.") + String nameKo, + @Schema(description = "아티스트 소속 그룹입니다. 아티스트 이름이 그룹인 경우, null 로 처리됩니다.") String artistGroup, @@ -39,6 +42,7 @@ public static ArtistListResponse from(Artist artist) { return new ArtistListResponse( artist.getId(), artist.getArtistName(), + artist.getNameKo(), artist.getArtistGroup(), genres, artist.getLikeCount(), @@ -52,6 +56,7 @@ public static ArtistListResponse from(Artist artist, boolean isLiked) { return new ArtistListResponse( artist.getId(), artist.getArtistName(), + artist.getNameKo(), artist.getArtistGroup(), genres, artist.getLikeCount(), diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java index ec7d793b..5531a5d3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java @@ -6,6 +6,9 @@ public record RelatedArtistResponse( @Schema(description = "아티스트 이름입니다.") String artistName, + @Schema(description = "한국어 기준 아티스트 이름입니다.") + String nameKo, + @Schema(description = "아티스트 사진 URL 입니다.") String imageUrl, diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SearchResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SearchResponse.java index 16c59dea..591d2333 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SearchResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SearchResponse.java @@ -7,6 +7,9 @@ public record SearchResponse( @Schema(description = "아티스트 이름입니다.") String artistName, + @Schema(description = "한국어 기준 아티스트 이름입니다.") + String nameKo, + @Schema(description = "아티스트 소속 그룹입니다. 아티스트 이름이 그룹인 경우, null 로 처리됩니다.") String artistGroup, @@ -16,6 +19,7 @@ public record SearchResponse( public static SearchResponse from(Artist artist) { return new SearchResponse( artist.getArtistName(), + artist.getNameKo(), artist.getArtistGroup(), artist.getLikeCount() ); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java index a4520924..f32db9b3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -299,6 +299,11 @@ public ArtistDetailResponse getArtistDetail( se.michaelthelin.spotify.model_objects.specification.Artist artist = api.getArtist(spotifyArtistId).build().execute(); // 메인 정보는 실패 시 예외 발생 + // DB에서 아티스트 정보 조회하여 nameKo 가져오기 + Artist dbArtist = artistRepository.findById(artistId) + .orElse(null); + String nameKo = dbArtist != null ? dbArtist.getNameKo() : null; + Track[] topTracks = safeGetTopTracks(api, spotifyArtistId); Paging albums = safeGetAlbums(api, spotifyArtistId); @@ -311,7 +316,9 @@ public ArtistDetailResponse getArtistDetail( ); return new ArtistDetailResponse( + artistId, artist.getName(), + nameKo, artistGroup, artistType, pickImageUrl(artist.getImages()), @@ -399,7 +406,8 @@ private List fallbackRelatedFromDb(String artistGroup, lo return artistRepository.findTop5ByArtistGroupAndIdNot(artistGroup, artistId).stream() .map(a -> new RelatedArtistResponse( a.getArtistName(), - null, + a.getNameKo(), + a.getImageUrl(), a.getSpotifyArtistId() )) .toList(); @@ -409,7 +417,8 @@ private List fallbackRelatedFromDb(String artistGroup, lo org.springframework.data.domain.PageRequest.of(0, 5)).stream() .map(a -> new RelatedArtistResponse( a.getArtistName(), - null, + a.getNameKo(), + a.getImageUrl(), a.getSpotifyArtistId() )) .toList(); @@ -473,11 +482,20 @@ private List toRelatedArtistResponses(se.michaelthelin.sp if (artists == null) return List.of(); return Stream.of(artists) .filter(Objects::nonNull) - .map(a -> new RelatedArtistResponse( - a.getName(), - pickImageUrl(a.getImages()), - a.getId() - )) + .map(a -> { + // DB에서 아티스트 정보 조회하여 nameKo 가져오기 + String nameKo = null; + Optional dbArtist = artistRepository.findBySpotifyArtistId(a.getId()); + if (dbArtist.isPresent()) { + nameKo = dbArtist.get().getNameKo(); + } + return new RelatedArtistResponse( + a.getName(), + nameKo, + pickImageUrl(a.getImages()), + a.getId() + ); + }) .collect(toList()); } } From 34d9bf4876f4e0e4bf282ae017faf6ea707d884c Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 15:11:06 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=EA=B0=80=20?= =?UTF-8?q?=EC=B0=9C=ED=95=9C=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/controller/ArtistsController.java | 6 ++++++ .../dto/response/LikeArtistResponse.java | 21 +++++++++++++++++++ .../repository/ArtistLikeRepository.java | 6 ++++++ .../domain/artists/service/ArtistService.java | 15 ++++++++----- 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/LikeArtistResponse.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java index d2562fe7..94a1b99b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java @@ -149,6 +149,12 @@ public RsData> concertList() { return RsData.success("찜한 아티스트 공연 리스트 조회 성공", artistService.getConcertList(user.getId())); } + @GetMapping("/likes") + public RsData> findLikeArtists() { + User user = rq.getUser(); + return RsData.success("유저가 찜한 아티스트 목록 조회 성공", artistService.findArtistsLikeByUserId(user)); + } + @Operation(summary = "아티스트 인기 순위(구현 전)", description = "Spotify 인기도를 바탕으로 아티스트 인기 순위 랭킹을 제공합니다.") @GetMapping("/ranking") public void artistRanking() {} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/LikeArtistResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/LikeArtistResponse.java new file mode 100644 index 00000000..8acecd6d --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/LikeArtistResponse.java @@ -0,0 +1,21 @@ +package com.back.web7_9_codecrete_be.domain.artists.dto.response; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; + +public record LikeArtistResponse( + Long artistId, + String artistName, + String nameKo, + String imageUrl, + boolean isLiked +) { + public static LikeArtistResponse from(Artist artist) { + return new LikeArtistResponse( + artist.getId(), + artist.getArtistName(), + artist.getNameKo(), + artist.getImageUrl(), + true + ); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistLikeRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistLikeRepository.java index 7530ded0..0fd2c1af 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistLikeRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistLikeRepository.java @@ -22,4 +22,10 @@ public interface ArtistLikeRepository extends JpaRepository { where al.user.id = :userId """) List findArtistIdsByUserId(@Param("userId") Long userId); + @Query(""" + select al.artist + from ArtistLike al + where al.user.id = :userId + """) + List findArtistsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index cfdca533..fe4cd2c9 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -1,15 +1,12 @@ package com.back.web7_9_codecrete_be.domain.artists.service; +import com.back.web7_9_codecrete_be.domain.artists.dto.response.*; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistSort; import com.back.web7_9_codecrete_be.domain.artists.dto.request.UpdateRequest; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistListResponse; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistDetailResponse; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.SearchResponse; import com.back.web7_9_codecrete_be.domain.artists.entity.*; import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistLikeRepository; import com.back.web7_9_codecrete_be.domain.artists.repository.ConcertArtistRepository; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.ConcertListByArtistResponse; import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert; import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository; import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService; @@ -207,7 +204,7 @@ public void deleteLikeArtist(Long artistId, User user) { @Transactional public void linkArtistConcert(Long artistId, Long concertId) { Artist artist = findArtist(artistId); - // TODO: 멘토링 질문 남겨놓은 기능이라, 멘토링 후 구현 방향 확정되면 함수 선언 후 Service 사용 예정. 현재는 임시로 Repository 사용 + // TODO: 추후 수정 예정 Concert concert = concertRepository.findById(concertId) .orElseThrow(); concertArtistRepository.save(new ConcertArtist(artist, concert)); @@ -228,5 +225,13 @@ public List getConcertList(Long userId) { .toList(); } + @Transactional(readOnly = true) + public List findArtistsLikeByUserId(User user) { + List artists = artistLikeRepository.findArtistsByUserId(user.getId()); + + return artists.stream() + .map(LikeArtistResponse::from) + .toList(); + } } From 7e23feaecb99bcee306cee85b32a34763314d060 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 15:18:44 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20isLiked=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/dto/response/ArtistDetailResponse.java | 5 +---- .../domain/artists/service/ArtistService.java | 9 +-------- .../domain/artists/service/SpotifyService.java | 12 +++++------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java index ca429f8a..b6b50929 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/ArtistDetailResponse.java @@ -43,9 +43,6 @@ public record ArtistDetailResponse( List topTracks, @Schema(description = "아티스트와 관련 있는 다른 아티스트 목록입니다.") - List relatedArtists, - - @Schema(description = "로그인한 유저의 좋아요 여부입니다. 비회원인 경우 false입니다.") - Boolean isLiked + List relatedArtists ) { } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index fe4cd2c9..33c1fdb7 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -105,12 +105,6 @@ public ArtistDetailResponse getArtistDetail(Long artistId, User user) { long likeCount = artistLikeRepository.countByArtistId(artistId); - // 로그인한 유저의 좋아요 여부 확인 - boolean isLiked = false; - if (user != null) { - isLiked = artistLikeRepository.existsByArtistAndUser(artist, user); - } - // 첫 번째 장르 ID 가져오기 (없으면 null) Long genreId = artist.getArtistGenres().stream() .findFirst() @@ -123,8 +117,7 @@ public ArtistDetailResponse getArtistDetail(Long artistId, User user) { artist.getArtistType(), likeCount, artist.getId(), - genreId, - isLiked + genreId ); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java index f32db9b3..79cf58dc 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -291,8 +291,7 @@ public ArtistDetailResponse getArtistDetail( ArtistType artistType, long likeCount, long artistId, - Long genreId, - boolean isLiked + Long genreId ) { try { SpotifyApi api = spotifyClient.getAuthorizedApi(); @@ -316,7 +315,7 @@ public ArtistDetailResponse getArtistDetail( ); return new ArtistDetailResponse( - artistId, + (long) artistId, artist.getName(), nameKo, artistGroup, @@ -324,12 +323,11 @@ public ArtistDetailResponse getArtistDetail( pickImageUrl(artist.getImages()), likeCount, albums != null ? albums.getTotal() : 0, - artist.getPopularity(), // 별점으로 수정 - "", // 설명 + artist.getPopularity(), + "", toAlbumResponses(albums != null ? albums.getItems() : null, spotifyArtistId), toTopTrackResponses(topTracks), - relatedResponses, - isLiked + relatedResponses ); } catch (Exception e) { log.error("Spotify 상세 조회 실패: artistId={}", spotifyArtistId, e); From ef67077e1ea13fc9d31d7e6fad8421ff072db3ef Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 15:25:16 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20admin=20=EA=B8=B0=EB=8A=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ArtistAdminController.java | 47 +++++++++++++++++++ .../artists/controller/ArtistsController.java | 33 +------------ .../domain/artists/service/ArtistService.java | 2 +- 3 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java new file mode 100644 index 00000000..2195d7e5 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java @@ -0,0 +1,47 @@ +package com.back.web7_9_codecrete_be.domain.artists.controller; + +import com.back.web7_9_codecrete_be.domain.artists.dto.request.CreateRequest; +import com.back.web7_9_codecrete_be.domain.artists.dto.request.UpdateRequest; +import com.back.web7_9_codecrete_be.domain.artists.service.ArtistService; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/artist") +@RequiredArgsConstructor +public class ArtistAdminController { + + private final ArtistService artistService; + + @Operation(summary = "아티스트 생성", description = "아티스트를 등록합니다.") + @PostMapping() + public RsData create( + @Valid @RequestBody CreateRequest reqBody + ) { + artistService.createArtist(reqBody.spotifyID(), reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); + return RsData.success("아티스트 생성이 완료되었습니다.", null); + } + + @Operation(summary = "아티스트 정보 수정", description = "아티스트 정보를 수정합니다.") + @PatchMapping("/{id}") + public RsData update( + @PathVariable Long id, + @Valid @RequestBody UpdateRequest reqBody + ) { + artistService.updateArtist(id, reqBody); + return RsData.success("아티스트 정보 수정을 완료했습니다.", null); + } + + @Operation(summary = "아티스트 정보 삭제", description = "아티스트 정보를 삭제합니다.") + @DeleteMapping("/{id}") + public RsData delete( + @PathVariable Long id + ) { + artistService.delete(id); + return RsData.success("아티스트 정보를 삭제했습니다.", null); + } + +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java index 94a1b99b..371185da 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java @@ -15,7 +15,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -54,15 +53,6 @@ public RsData fetchMusicBrainzIds( return RsData.success("MusicBrainz ID 수집 성공", updated); } - @Operation(summary = "아티스트 생성", description = "아티스트를 등록합니다.") - @PostMapping() - public RsData create( - @Valid @RequestBody CreateRequest reqBody - ) { - artistService.createArtist(reqBody.spotifyID(), reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); - return RsData.success("아티스트 생성이 완료되었습니다.", null); - } - @Operation(summary = "아티스트 목록 조회", description = "아티스트 전체 목록을 조회합니다(NAME: 이름순 / LIKE: 인기순 (좋아요 많은 순)") @GetMapping() @@ -80,27 +70,7 @@ public RsData list( public RsData artist( @PathVariable Long id ) { - User user = rq.getUserOrNull(); // 로그인하지 않은 경우 null - return RsData.success("아티스트 상세 조회를 성공했습니다.", artistService.getArtistDetail(id, user)); - } - - @Operation(summary = "아티스트 정보 수정", description = "아티스트 정보를 수정합니다.") - @PatchMapping("/{id}") - public RsData update( - @PathVariable Long id, - @Valid @RequestBody UpdateRequest reqBody - ) { - artistService.updateArtist(id, reqBody); - return RsData.success("아티스트 정보 수정을 완료했습니다.", null); - } - - @Operation(summary = "아티스트 정보 삭제", description = "아티스트 정보를 삭제합니다.") - @DeleteMapping("/{id}") - public RsData delete( - @PathVariable Long id - ) { - artistService.delete(id); - return RsData.success("아티스트 정보를 삭제했습니다.", null); + return RsData.success("아티스트 상세 조회를 성공했습니다.", artistService.getArtistDetail(id)); } @Operation(summary = "아티스트 검색", @@ -149,6 +119,7 @@ public RsData> concertList() { return RsData.success("찜한 아티스트 공연 리스트 조회 성공", artistService.getConcertList(user.getId())); } + @Operation(summary = "유저가 찜한 아티스트 목록", description = "유저 Id 를 통해 해당 유저가 찜한 아티스트 목록을 조회합니다.") @GetMapping("/likes") public RsData> findLikeArtists() { User user = rq.getUser(); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index 33c1fdb7..3045aa57 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -95,7 +95,7 @@ public Slice listArtist(Pageable pageable, User user, Artist } @Transactional(readOnly = true) - public ArtistDetailResponse getArtistDetail(Long artistId, User user) { + public ArtistDetailResponse getArtistDetail(Long artistId) { Artist artist = artistRepository.findById(artistId) .orElseThrow(() -> new BusinessException(ArtistErrorCode.ARTIST_NOT_FOUND)); From fadbf7006306ecca831f2778922e69bcaa1689c4 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 15:33:22 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20admin=20=EA=B8=B0=EB=8A=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/artists/controller/ArtistAdminController.java | 4 ++-- .../domain/artists/dto/request/CreateRequest.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java index 2195d7e5..00c51896 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistAdminController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/v1/admin/artist") +@RequestMapping("/api/v1/admin/artists") @RequiredArgsConstructor public class ArtistAdminController { @@ -21,7 +21,7 @@ public class ArtistAdminController { public RsData create( @Valid @RequestBody CreateRequest reqBody ) { - artistService.createArtist(reqBody.spotifyID(), reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); + artistService.createArtist(reqBody.spotifyId(), reqBody.artistName(), reqBody.artistGroup(), reqBody.artistType(), reqBody.genreName()); return RsData.success("아티스트 생성이 완료되었습니다.", null); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java index e19b6ca2..c553254e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java @@ -11,7 +11,7 @@ public record CreateRequest( @NotBlank @Size(max = 30, message = "Spotify ID 는 필수로 입력해야합니다.") @Schema(description = "Spotify ID 입니다.") - String spotifyID, + String spotifyId, @NotBlank(message = "아티스트 이름은 필수로 입력해야합니다.") @Size(max = 200, message = "아티스트 이름은 200자를 넘길 수 없습니다.") From 1752808adfd161de58df9bbbbc024f2b68e9d121 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 16:05:25 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=9E=A5?= =?UTF-8?q?=EB=A5=B4=20=EB=AA=A9=EB=A1=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artists/controller/GenreController.java | 24 +++++++++++++++++++ .../artists/dto/response/GenreResponse.java | 15 ++++++++++++ .../domain/artists/service/GenreService.java | 12 ++++++++++ 3 files changed, 51 insertions(+) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/GenreController.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/GenreResponse.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/GenreController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/GenreController.java new file mode 100644 index 00000000..5a8b68d7 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/GenreController.java @@ -0,0 +1,24 @@ +package com.back.web7_9_codecrete_be.domain.artists.controller; + +import com.back.web7_9_codecrete_be.domain.artists.dto.response.GenreResponse; +import com.back.web7_9_codecrete_be.domain.artists.service.GenreService; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/genre") +@RequiredArgsConstructor +public class GenreController { + + private final GenreService genreService; + + @GetMapping() + public RsData> genreList() { + return RsData.success("전체 장르 조회 성공", genreService.genreList()); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/GenreResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/GenreResponse.java new file mode 100644 index 00000000..5da57deb --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/GenreResponse.java @@ -0,0 +1,15 @@ +package com.back.web7_9_codecrete_be.domain.artists.dto.response; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; + +public record GenreResponse( + Long genreId, + String genreName +) { + public static GenreResponse from(Genre genre) { + return new GenreResponse( + genre.getId(), + genre.getGenreName() + ); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/GenreService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/GenreService.java index e6d600eb..253d1bc4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/GenreService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/GenreService.java @@ -1,12 +1,17 @@ package com.back.web7_9_codecrete_be.domain.artists.service; +import com.back.web7_9_codecrete_be.domain.artists.dto.response.ConcertListByArtistResponse; +import com.back.web7_9_codecrete_be.domain.artists.dto.response.GenreResponse; import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; import com.back.web7_9_codecrete_be.domain.artists.repository.GenreRepository; +import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert; import com.back.web7_9_codecrete_be.global.error.code.GenreErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class GenreService { @@ -19,4 +24,11 @@ public Genre findByGenreName(String genreName) { .orElseThrow(() -> new BusinessException(GenreErrorCode.GENRE_NOT_FOUND)); return genre; } + + public List genreList() { + List genre = genreRepository.findAll(); + return genre.stream() + .map(GenreResponse::from) + .toList(); + } } From fb39d38a4061cbfffb752cdf62d0d8064e19c29a Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 24 Dec 2025 17:24:05 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A0=A8=20=EC=95=84?= =?UTF-8?q?=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=EC=97=90=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20id=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/artists/dto/response/RelatedArtistResponse.java | 3 +++ .../domain/artists/service/SpotifyService.java | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java index 5531a5d3..acf240d8 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/RelatedArtistResponse.java @@ -3,6 +3,9 @@ import io.swagger.v3.oas.annotations.media.Schema; public record RelatedArtistResponse( + @Schema(description = "아티스트 id 입니다.") + Long id, + @Schema(description = "아티스트 이름입니다.") String artistName, diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java index 79cf58dc..14bbfc10 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -403,6 +403,7 @@ private List fallbackRelatedFromDb(String artistGroup, lo if (artistGroup != null && !artistGroup.isBlank()) { return artistRepository.findTop5ByArtistGroupAndIdNot(artistGroup, artistId).stream() .map(a -> new RelatedArtistResponse( + a.getId(), a.getArtistName(), a.getNameKo(), a.getImageUrl(), @@ -414,6 +415,7 @@ private List fallbackRelatedFromDb(String artistGroup, lo return artistRepository.findTop5ByGenreIdAndIdNot(genreId, artistId, org.springframework.data.domain.PageRequest.of(0, 5)).stream() .map(a -> new RelatedArtistResponse( + a.getId(), a.getArtistName(), a.getNameKo(), a.getImageUrl(), @@ -481,13 +483,16 @@ private List toRelatedArtistResponses(se.michaelthelin.sp return Stream.of(artists) .filter(Objects::nonNull) .map(a -> { - // DB에서 아티스트 정보 조회하여 nameKo 가져오기 + // DB에서 아티스트 정보 조회하여 id와 nameKo 가져오기 + Long artistId = null; String nameKo = null; Optional dbArtist = artistRepository.findBySpotifyArtistId(a.getId()); if (dbArtist.isPresent()) { + artistId = dbArtist.get().getId(); nameKo = dbArtist.get().getNameKo(); } return new RelatedArtistResponse( + artistId, a.getName(), nameKo, pickImageUrl(a.getImages()),