From 9b2d84ff33656af576e6bab6bdb3d28a4beea1ab Mon Sep 17 00:00:00 2001 From: bcjang Date: Fri, 28 Nov 2025 15:34:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=8F=AC=ED=8B=B0=ED=8C=8C=EC=9D=B4,?= =?UTF-8?q?=20=EC=95=94=ED=98=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/domain/api/SpotifyController.java | 193 ++++ .../request/SpotifyAddTracksRequest.java | 48 + .../domain/request/SpotifyTokenRequest.java | 17 + .../response/SpotifyAddTracksResponse.java | 49 + .../response/SpotifyPlaylistResponse.java | 56 ++ .../com/domain/service/SpotifyService.java | 834 ++++++++++++++++++ .../domain/service/TranslationService.java | 147 +++ .../java/com/global/util/EncryptionUtil.java | 168 ++++ 8 files changed, 1512 insertions(+) create mode 100644 src/main/java/com/domain/api/SpotifyController.java create mode 100644 src/main/java/com/domain/request/SpotifyAddTracksRequest.java create mode 100644 src/main/java/com/domain/request/SpotifyTokenRequest.java create mode 100644 src/main/java/com/domain/response/SpotifyAddTracksResponse.java create mode 100644 src/main/java/com/domain/response/SpotifyPlaylistResponse.java create mode 100644 src/main/java/com/domain/service/SpotifyService.java create mode 100644 src/main/java/com/domain/service/TranslationService.java create mode 100644 src/main/java/com/global/util/EncryptionUtil.java diff --git a/src/main/java/com/domain/api/SpotifyController.java b/src/main/java/com/domain/api/SpotifyController.java new file mode 100644 index 0000000..706bdf5 --- /dev/null +++ b/src/main/java/com/domain/api/SpotifyController.java @@ -0,0 +1,193 @@ +package com.domain.api; + +import com.domain.request.SpotifyAddTracksRequest; +import com.domain.request.SpotifyTokenRequest; +import com.domain.request.VibeToSpotifySearchRequest; +import com.domain.response.SpotifyAddTracksResponse; +import com.domain.response.SpotifyPlaylistResponse; +import com.domain.response.VibeToSpotifySearchResponse; +import com.domain.service.SpotifyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/v2/spotify") +@RequiredArgsConstructor +@Tag(name = "Spotify", description = "Spotify 플레이리스트 API") +public class SpotifyController { + + private final SpotifyService spotifyService; + + /** + * Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다. + * + * @param request Spotify Access Token + * @return Spotify 플레이리스트 목록 + */ + @PostMapping("/playlists") + @Operation(summary = "Spotify 플레이리스트 조회", description = "Spotify Access Token으로 사용자의 플레이리스트를 조회합니다.") + public ResponseEntity getSpotifyPlaylists(@RequestBody SpotifyTokenRequest request) { + log.info("Spotify 플레이리스트 조회 요청"); + + // 입력 검증 + if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(SpotifyPlaylistResponse.builder() + .success(false) + .message("Access Token이 필요합니다.") + .build()); + } + + try { + SpotifyPlaylistResponse response = spotifyService.getUserPlaylists(request.getAccessToken()); + + if (response.isSuccess()) { + log.info("Spotify 플레이리스트 조회 성공 - 플레이리스트 개수: {}", + response.getTotalCount()); + return ResponseEntity.ok(response); + } else { + log.warn("Spotify 플레이리스트 조회 실패 - 메시지: {}", response.getMessage()); + return ResponseEntity.status(401).body(response); + } + + } catch (Exception e) { + log.error("Spotify 플레이리스트 조회 중 예외 발생", e); + return ResponseEntity.internalServerError() + .body(SpotifyPlaylistResponse.builder() + .success(false) + .message("서버 오류가 발생했습니다: " + e.getMessage()) + .build()); + } + } + + /** + * 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다. + * + * @param request 트랙 추가 요청 (access_token, playlist_id, track_uris 등) + * @return 트랙 추가 결과 + */ + @PostMapping("/playlists/tracks") + @Operation(summary = "Spotify 트랙 추가", description = "기존 플레이리스트에 트랙을 추가하거나 새 플레이리스트를 생성합니다. playlist_id가 없으면 새로 생성합니다.") + public ResponseEntity addTracksToPlaylist(@RequestBody SpotifyAddTracksRequest request) { + log.info("Spotify 트랙 추가 요청 - 플레이리스트 ID: {}", request.getPlaylistId()); + + // 입력 검증 + if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(SpotifyAddTracksResponse.builder() + .success(false) + .message("Access Token이 필요합니다.") + .build()); + } + + if (request.getTracks() == null || request.getTracks().isEmpty()) { + return ResponseEntity.badRequest() + .body(SpotifyAddTracksResponse.builder() + .success(false) + .message("추가할 트랙 목록이 필요합니다.") + .build()); + } + + // 새 플레이리스트 생성 시 이름 확인 + if ((request.getPlaylistId() == null || request.getPlaylistId().trim().isEmpty()) && + (request.getPlaylistName() == null || request.getPlaylistName().trim().isEmpty())) { + return ResponseEntity.badRequest() + .body(SpotifyAddTracksResponse.builder() + .success(false) + .message("새 플레이리스트 생성 시 이름이 필요합니다.") + .build()); + } + + try { + SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylist(request); + + if (response.isSuccess()) { + log.info("Spotify 트랙 추가 성공 - 플레이리스트 ID: {}, 추가된 트랙 수: {}", + response.getPlaylistId(), response.getTracksAdded()); + return ResponseEntity.ok(response); + } else { + log.warn("Spotify 트랙 추가 실패 - 메시지: {}", response.getMessage()); + return ResponseEntity.status(400).body(response); + } + + } catch (Exception e) { + log.error("Spotify 트랙 추가 중 예외 발생", e); + return ResponseEntity.internalServerError() + .body(SpotifyAddTracksResponse.builder() + .success(false) + .message("서버 오류가 발생했습니다: " + e.getMessage()) + .build()); + } + } + + /** + * Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다. + * + * @param request Vibe 트랙 정보 리스트 (가수, 제목) + * @return Spotify 검색 결과 (track_uri 포함) + */ + @PostMapping("/search/vibe-tracks") + @Operation(summary = "Vibe 트랙 Spotify 검색", description = "Vibe에서 받은 트랙 정보(가수, 제목)를 Spotify에서 검색하여 track_uri를 반환합니다.") + public ResponseEntity searchVibeTracksInSpotify( + @RequestBody VibeToSpotifySearchRequest request) { + + log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}", + request.getTracks() != null ? request.getTracks().size() : 0); + + // 입력 검증 + if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(VibeToSpotifySearchResponse.builder() + .success(false) + .message("Access Token이 필요합니다.") + .build()); + } + + if (request.getTracks() == null || request.getTracks().isEmpty()) { + return ResponseEntity.badRequest() + .body(VibeToSpotifySearchResponse.builder() + .success(false) + .message("검색할 트랙 목록이 필요합니다.") + .build()); + } + + try { + VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibe(request); + + if (response.isSuccess()) { + log.info("Vibe 트랙 Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}", + response.getTotalSearched(), + response.getMatchedCount(), + response.getUnmatchedCount()); + return ResponseEntity.ok(response); + } else { + log.warn("Vibe 트랙 Spotify 검색 실패 - 메시지: {}", response.getMessage()); + return ResponseEntity.status(400).body(response); + } + + } catch (Exception e) { + log.error("Vibe 트랙 Spotify 검색 중 예외 발생", e); + return ResponseEntity.internalServerError() + .body(VibeToSpotifySearchResponse.builder() + .success(false) + .message("서버 오류가 발생했습니다: " + e.getMessage()) + .build()); + } + } + + /** + * Spotify API 연동 상태를 확인합니다. + * + * @return 헬스체크 응답 + */ + @GetMapping("/health") + @Operation(summary = "Spotify API 헬스체크", description = "Spotify API 연동 상태를 확인합니다.") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("Spotify API is ready"); + } +} diff --git a/src/main/java/com/domain/request/SpotifyAddTracksRequest.java b/src/main/java/com/domain/request/SpotifyAddTracksRequest.java new file mode 100644 index 0000000..ab39886 --- /dev/null +++ b/src/main/java/com/domain/request/SpotifyAddTracksRequest.java @@ -0,0 +1,48 @@ +package com.domain.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SpotifyAddTracksRequest { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("playlist_id") + private String playlistId; // null이면 새 플레이리스트 생성 + + @JsonProperty("playlist_name") + private String playlistName; // 새 플레이리스트 생성 시 이름 + + @JsonProperty("playlist_description") + private String playlistDescription; // 새 플레이리스트 생성 시 설명 + + @JsonProperty("is_public") + private Boolean isPublic; // 새 플레이리스트 공개 여부 (기본값: false) + + private List tracks; // Vibe에서 받은 트랙 정보 리스트 + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrackInfo { + @JsonProperty("track_title") + private String trackTitle; + + @JsonProperty("artist_name") + private String artistName; + + @JsonProperty("album_title") + private String albumTitle; // 선택적, 더 정확한 매칭을 위해 + } +} diff --git a/src/main/java/com/domain/request/SpotifyTokenRequest.java b/src/main/java/com/domain/request/SpotifyTokenRequest.java new file mode 100644 index 0000000..796b93f --- /dev/null +++ b/src/main/java/com/domain/request/SpotifyTokenRequest.java @@ -0,0 +1,17 @@ +package com.domain.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SpotifyTokenRequest { + + @JsonProperty("access_token") + private String accessToken; +} diff --git a/src/main/java/com/domain/response/SpotifyAddTracksResponse.java b/src/main/java/com/domain/response/SpotifyAddTracksResponse.java new file mode 100644 index 0000000..90fc660 --- /dev/null +++ b/src/main/java/com/domain/response/SpotifyAddTracksResponse.java @@ -0,0 +1,49 @@ +package com.domain.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SpotifyAddTracksResponse { + + private boolean success; + private String message; + + @JsonProperty("playlist_id") + private String playlistId; + + @JsonProperty("playlist_name") + private String playlistName; + + @JsonProperty("playlist_url") + private String playlistUrl; + + @JsonProperty("tracks_added") + private Integer tracksAdded; + + @JsonProperty("is_new_playlist") + private Boolean isNewPlaylist; + + // 매칭 통계 추가 + @JsonProperty("total_searched") + private Integer totalSearched; + + @JsonProperty("matched_count") + private Integer matchedCount; + + @JsonProperty("unmatched_count") + private Integer unmatchedCount; + + @JsonProperty("unmatched_tracks") + private List unmatchedTracks; // 매칭 실패한 트랙 목록 (가수 - 제목) +} diff --git a/src/main/java/com/domain/response/SpotifyPlaylistResponse.java b/src/main/java/com/domain/response/SpotifyPlaylistResponse.java new file mode 100644 index 0000000..dd9ff1e --- /dev/null +++ b/src/main/java/com/domain/response/SpotifyPlaylistResponse.java @@ -0,0 +1,56 @@ +package com.domain.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SpotifyPlaylistResponse { + + private boolean success; + private String message; + + @JsonProperty("total_count") + private Integer totalCount; + + private List playlists; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Playlist { + @JsonProperty("playlist_id") + private String playlistId; + + @JsonProperty("playlist_name") + private String playlistName; + + private String description; + + @JsonProperty("track_count") + private Integer trackCount; + + @JsonProperty("image_url") + private String imageUrl; + + @JsonProperty("is_public") + private Boolean isPublic; + + @JsonProperty("owner_name") + private String ownerName; + + @JsonProperty("spotify_url") + private String spotifyUrl; + } +} diff --git a/src/main/java/com/domain/service/SpotifyService.java b/src/main/java/com/domain/service/SpotifyService.java new file mode 100644 index 0000000..0af479b --- /dev/null +++ b/src/main/java/com/domain/service/SpotifyService.java @@ -0,0 +1,834 @@ +package com.domain.service; + +import com.domain.request.SpotifyAddTracksRequest; +import com.domain.request.VibeToSpotifySearchRequest; +import com.domain.response.SpotifyAddTracksResponse; +import com.domain.response.SpotifyPlaylistResponse; +import com.domain.response.VibeToSpotifySearchResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpotifyService { + + private final ObjectMapper objectMapper; + private final TranslationService translationService; + + private static final String SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1"; + private static final String SPOTIFY_ME_PLAYLISTS_URL = SPOTIFY_API_BASE_URL + "/me/playlists?limit=50"; + private static final String SPOTIFY_ME_URL = SPOTIFY_API_BASE_URL + "/me"; + private static final String SPOTIFY_CREATE_PLAYLIST_URL = SPOTIFY_API_BASE_URL + "/users/%s/playlists"; + private static final String SPOTIFY_ADD_TRACKS_URL = SPOTIFY_API_BASE_URL + "/playlists/%s/tracks"; + + /** + * Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다. + * + * @param accessToken Spotify Access Token + * @return 플레이리스트 목록 응답 + */ + public SpotifyPlaylistResponse getUserPlaylists(String accessToken) { + try { + List playlists = fetchSpotifyPlaylists(accessToken); + + return SpotifyPlaylistResponse.builder() + .success(true) + .message("플레이리스트 조회 성공") + .totalCount(playlists.size()) + .playlists(playlists) + .build(); + + } catch (Exception e) { + log.error("Spotify 플레이리스트 조회 중 오류 발생", e); + return SpotifyPlaylistResponse.builder() + .success(false) + .message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * Spotify API를 호출하여 플레이리스트 목록을 조회합니다. + * + * @param accessToken Spotify Access Token + * @return 플레이리스트 목록 + */ + private List fetchSpotifyPlaylists(String accessToken) { + List playlists = new ArrayList<>(); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setResponseTimeout(30, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpGet getRequest = new HttpGet(SPOTIFY_ME_PLAYLISTS_URL); + getRequest.setHeader("Authorization", "Bearer " + accessToken); + getRequest.setHeader("Content-Type", "application/json"); + + try (CloseableHttpResponse response = httpClient.execute(getRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + log.info("Spotify Playlist API 응답 코드: {}", statusCode); + + if (statusCode == 200) { + playlists = parseSpotifyPlaylistResponse(responseBody); + log.info("파싱된 플레이리스트 개수: {}", playlists.size()); + } else { + log.error("Spotify Playlist API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody); + } + } + + } catch (Exception e) { + log.error("Spotify 플레이리스트 조회 중 오류 발생", e); + } + + return playlists; + } + + /** + * Spotify Playlist API 응답을 파싱합니다. + * + * @param responseBody JSON 응답 본문 + * @return 플레이리스트 목록 + */ + private List parseSpotifyPlaylistResponse(String responseBody) { + List playlists = new ArrayList<>(); + + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode itemsNode = root.path("items"); + + if (itemsNode.isArray()) { + for (JsonNode playlistNode : itemsNode) { + // 이미지 URL 추출 (첫 번째 이미지) + String imageUrl = null; + JsonNode imagesNode = playlistNode.path("images"); + if (imagesNode.isArray() && imagesNode.size() > 0) { + imageUrl = imagesNode.get(0).path("url").asText(null); + } + + SpotifyPlaylistResponse.Playlist playlist = SpotifyPlaylistResponse.Playlist.builder() + .playlistId(playlistNode.path("id").asText()) + .playlistName(playlistNode.path("name").asText()) + .description(playlistNode.path("description").asText(null)) + .trackCount(playlistNode.path("tracks").path("total").asInt(0)) + .imageUrl(imageUrl) + .isPublic(playlistNode.path("public").asBoolean(false)) + .ownerName(playlistNode.path("owner").path("display_name").asText(null)) + .spotifyUrl(playlistNode.path("external_urls").path("spotify").asText(null)) + .build(); + + playlists.add(playlist); + } + } + + } catch (Exception e) { + log.error("Spotify Playlist 응답 파싱 중 오류 발생", e); + } + + return playlists; + } + + /** + * 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다. + * Vibe 트랙 정보(가수, 제목)를 받아서 Spotify에서 자동으로 검색하여 추가합니다. + * + * @param request 트랙 추가 요청 (tracks: 가수, 제목 정보) + * @return 트랙 추가 응답 + */ + public SpotifyAddTracksResponse addTracksToPlaylist(SpotifyAddTracksRequest request) { + try { + String playlistId = request.getPlaylistId(); + boolean isNewPlaylist = false; + + // 1. Vibe 트랙 정보로 Spotify에서 검색 + log.info("Vibe 트랙 정보로 Spotify 검색 시작 - 트랙 개수: {}", request.getTracks().size()); + + List matchedTrackUris = new ArrayList<>(); + List unmatchedTracks = new ArrayList<>(); + int totalSearched = request.getTracks().size(); + + for (SpotifyAddTracksRequest.TrackInfo trackInfo : request.getTracks()) { + // 단일 트랙 검색 + VibeToSpotifySearchRequest.TrackInfo searchTrackInfo = VibeToSpotifySearchRequest.TrackInfo.builder() + .trackTitle(trackInfo.getTrackTitle()) + .artistName(trackInfo.getArtistName()) + .albumTitle(trackInfo.getAlbumTitle()) + .build(); + + VibeToSpotifySearchResponse.SearchResult result = searchSingleTrack( + request.getAccessToken(), + searchTrackInfo + ); + + if (result.isMatched() && result.getSpotifyTrackUri() != null) { + matchedTrackUris.add(result.getSpotifyTrackUri()); + } else { + String unmatchedInfo = String.format("%s - %s", + trackInfo.getArtistName(), + trackInfo.getTrackTitle()); + unmatchedTracks.add(unmatchedInfo); + log.warn("매칭 실패: {}", unmatchedInfo); + } + } + + int matchedCount = matchedTrackUris.size(); + int unmatchedCount = unmatchedTracks.size(); + + log.info("Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}", + totalSearched, matchedCount, unmatchedCount); + + // 매칭된 트랙이 없으면 에러 + if (matchedTrackUris.isEmpty()) { + return SpotifyAddTracksResponse.builder() + .success(false) + .message("Spotify에서 매칭되는 트랙을 찾을 수 없습니다.") + .totalSearched(totalSearched) + .matchedCount(0) + .unmatchedCount(unmatchedCount) + .unmatchedTracks(unmatchedTracks) + .build(); + } + + // 2. 플레이리스트 ID가 없으면 새로 생성 + if (playlistId == null || playlistId.trim().isEmpty()) { + log.info("새 플레이리스트 생성 요청 - 이름: {}", request.getPlaylistName()); + playlistId = createNewPlaylist( + request.getAccessToken(), + request.getPlaylistName(), + request.getPlaylistDescription(), + request.getIsPublic() != null ? request.getIsPublic() : false + ); + isNewPlaylist = true; + } + + // 3. 매칭된 트랙들을 플레이리스트에 추가 + int tracksAdded = addTracksToExistingPlaylist( + request.getAccessToken(), + playlistId, + matchedTrackUris + ); + + // 플레이리스트 정보 조회 + String playlistUrl = String.format("https://open.spotify.com/playlist/%s", playlistId); + + return SpotifyAddTracksResponse.builder() + .success(true) + .message(String.format("트랙 추가 완료 (매칭: %d, 미매칭: %d)", matchedCount, unmatchedCount)) + .playlistId(playlistId) + .playlistName(request.getPlaylistName()) + .playlistUrl(playlistUrl) + .tracksAdded(tracksAdded) + .isNewPlaylist(isNewPlaylist) + .totalSearched(totalSearched) + .matchedCount(matchedCount) + .unmatchedCount(unmatchedCount) + .unmatchedTracks(unmatchedTracks.isEmpty() ? null : unmatchedTracks) + .build(); + + } catch (Exception e) { + log.error("Spotify 트랙 추가 중 오류 발생", e); + return SpotifyAddTracksResponse.builder() + .success(false) + .message("트랙 추가 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * 새 플레이리스트를 생성합니다. + * + * @param accessToken Spotify Access Token + * @param name 플레이리스트 이름 + * @param description 플레이리스트 설명 + * @param isPublic 공개 여부 + * @return 생성된 플레이리스트 ID + */ + private String createNewPlaylist(String accessToken, String name, String description, boolean isPublic) { + try { + // 1. 사용자 ID 조회 + String userId = getUserId(accessToken); + if (userId == null) { + throw new RuntimeException("사용자 ID를 조회할 수 없습니다."); + } + + // 2. 플레이리스트 생성 + String createPlaylistUrl = String.format(SPOTIFY_CREATE_PLAYLIST_URL, userId); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setResponseTimeout(30, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpPost postRequest = new HttpPost(createPlaylistUrl); + postRequest.setHeader("Authorization", "Bearer " + accessToken); + postRequest.setHeader("Content-Type", "application/json"); + + // Request body 생성 + Map requestBody = new HashMap<>(); + requestBody.put("name", name != null ? name : "New Playlist"); + requestBody.put("description", description != null ? description : ""); + requestBody.put("public", isPublic); + + String jsonBody = objectMapper.writeValueAsString(requestBody); + postRequest.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(postRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + log.info("Spotify Create Playlist API 응답 코드: {}", statusCode); + + if (statusCode == 201) { + JsonNode jsonNode = objectMapper.readTree(responseBody); + String playlistId = jsonNode.path("id").asText(); + log.info("플레이리스트 생성 성공 - ID: {}", playlistId); + return playlistId; + } else { + log.error("플레이리스트 생성 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody); + throw new RuntimeException("플레이리스트 생성 실패: " + responseBody); + } + } + } + + } catch (Exception e) { + log.error("플레이리스트 생성 중 오류 발생", e); + throw new RuntimeException("플레이리스트 생성 실패", e); + } + } + + /** + * 사용자 ID를 조회합니다. + * + * @param accessToken Spotify Access Token + * @return 사용자 ID + */ + private String getUserId(String accessToken) { + try { + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setResponseTimeout(30, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpGet getRequest = new HttpGet(SPOTIFY_ME_URL); + getRequest.setHeader("Authorization", "Bearer " + accessToken); + + try (CloseableHttpResponse response = httpClient.execute(getRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + if (statusCode == 200) { + JsonNode jsonNode = objectMapper.readTree(responseBody); + return jsonNode.path("id").asText(); + } else { + log.error("사용자 정보 조회 실패. 상태 코드: {}", statusCode); + return null; + } + } + } + + } catch (Exception e) { + log.error("사용자 ID 조회 중 오류 발생", e); + return null; + } + } + + /** + * 기존 플레이리스트에 트랙을 추가합니다. + * + * @param accessToken Spotify Access Token + * @param playlistId 플레이리스트 ID + * @param trackUris 트랙 URI 목록 + * @return 추가된 트랙 개수 + */ + private int addTracksToExistingPlaylist(String accessToken, String playlistId, List trackUris) { + try { + String addTracksUrl = String.format(SPOTIFY_ADD_TRACKS_URL, playlistId); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setResponseTimeout(30, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpPost postRequest = new HttpPost(addTracksUrl); + postRequest.setHeader("Authorization", "Bearer " + accessToken); + postRequest.setHeader("Content-Type", "application/json"); + + // Request body 생성 + Map requestBody = new HashMap<>(); + requestBody.put("uris", trackUris); + + String jsonBody = objectMapper.writeValueAsString(requestBody); + postRequest.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(postRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + log.info("Spotify Add Tracks API 응답 코드: {}", statusCode); + + if (statusCode == 201) { + log.info("트랙 추가 성공 - {} 개", trackUris.size()); + return trackUris.size(); + } else { + log.error("트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody); + throw new RuntimeException("트랙 추가 실패: " + responseBody); + } + } + } + + } catch (Exception e) { + log.error("트랙 추가 중 오류 발생", e); + throw new RuntimeException("트랙 추가 실패", e); + } + } + + /** + * Vibe 트랙 정보를 기반으로 Spotify에서 검색하여 매칭합니다. + * + * @param request Vibe 트랙 정보 리스트 + * @return 검색 결과 (Spotify track URI 포함) + */ + public VibeToSpotifySearchResponse searchTracksFromVibe(VibeToSpotifySearchRequest request) { + try { + List results = new ArrayList<>(); + int matchedCount = 0; + + for (VibeToSpotifySearchRequest.TrackInfo trackInfo : request.getTracks()) { + VibeToSpotifySearchResponse.SearchResult result = searchSingleTrack( + request.getAccessToken(), + trackInfo + ); + results.add(result); + + if (result.isMatched()) { + matchedCount++; + } + } + + int unmatchedCount = results.size() - matchedCount; + + return VibeToSpotifySearchResponse.builder() + .success(true) + .message("검색 완료") + .totalSearched(results.size()) + .matchedCount(matchedCount) + .unmatchedCount(unmatchedCount) + .results(results) + .build(); + + } catch (Exception e) { + log.error("Vibe to Spotify 검색 중 오류 발생", e); + return VibeToSpotifySearchResponse.builder() + .success(false) + .message("검색 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * 단일 트랙을 Spotify에서 검색합니다. + * 5단계 검색 전략: + * 1) track + artist (원본) + * 2) track + artist (괄호 안 영문) - artist의 괄호가 있는 경우 + * 2.5) 괄호 안 track명으로 검색 - track의 괄호가 있는 경우 + * 3) track + album + * 4) 영문 번역 후 track + artist + * + * @param accessToken Spotify Access Token + * @param trackInfo Vibe 트랙 정보 + * @return 검색 결과 + */ + private VibeToSpotifySearchResponse.SearchResult searchSingleTrack( + String accessToken, + VibeToSpotifySearchRequest.TrackInfo trackInfo) { + + // 1단계: track + artist (원본)로 검색 + log.debug("1단계 검색: track + artist (원본)"); + VibeToSpotifySearchResponse.SearchResult result = + performSpotifySearch(accessToken, + trackInfo.getTrackTitle(), + trackInfo.getArtistName(), + null); + + result.setOriginalTrackTitle(trackInfo.getTrackTitle()); + result.setOriginalArtistName(trackInfo.getArtistName()); + + if (result.isMatched()) { + log.info("매칭 성공 (1단계 - track+artist 원본): {} - {}", + trackInfo.getArtistName(), trackInfo.getTrackTitle()); + return result; + } + + // 2단계: artist에 괄호가 있으면 괄호 안 영문으로 검색 + String englishArtistInParentheses = extractEnglishFromParentheses(trackInfo.getArtistName()); + if (englishArtistInParentheses != null) { + log.debug("2단계 검색: track + artist (괄호 안 영문: {})", englishArtistInParentheses); + + VibeToSpotifySearchResponse.SearchResult parenthesesResult = + performSpotifySearch(accessToken, + trackInfo.getTrackTitle(), + englishArtistInParentheses, + null); + + parenthesesResult.setOriginalTrackTitle(trackInfo.getTrackTitle()); + parenthesesResult.setOriginalArtistName(trackInfo.getArtistName()); + + if (parenthesesResult.isMatched()) { + log.info("매칭 성공 (2단계 - track+artist 괄호 영문): {} ({}) - {}", + trackInfo.getArtistName(), englishArtistInParentheses, trackInfo.getTrackTitle()); + return parenthesesResult; + } + } + + // 2.5단계: track에 괄호가 있으면 괄호 안 영문 track명으로 검색 + String englishTrackInParentheses = extractEnglishTrackTitle(trackInfo.getTrackTitle()); + if (englishTrackInParentheses != null) { + log.debug("2.5단계 검색: 괄호 안 track명 ({})", englishTrackInParentheses); + + // 2.5-a: 괄호 안 track명 + artist (괄호 제거) + String artistWithoutParentheses = removeParentheses(trackInfo.getArtistName()); + VibeToSpotifySearchResponse.SearchResult trackParenthesesResult1 = + performSpotifySearch(accessToken, + englishTrackInParentheses, + artistWithoutParentheses, + null); + + trackParenthesesResult1.setOriginalTrackTitle(trackInfo.getTrackTitle()); + trackParenthesesResult1.setOriginalArtistName(trackInfo.getArtistName()); + + if (trackParenthesesResult1.isMatched()) { + log.info("매칭 성공 (2.5-a - 괄호 안 track+artist 괄호제거): {} -> {} - {} ({})", + trackInfo.getArtistName(), artistWithoutParentheses, + trackInfo.getTrackTitle(), englishTrackInParentheses); + return trackParenthesesResult1; + } + + // 2.5-b: 괄호 안 track명 + 괄호 안 artist (artist 괄호가 있는 경우만) + if (englishArtistInParentheses != null) { + VibeToSpotifySearchResponse.SearchResult trackParenthesesResult2 = + performSpotifySearch(accessToken, + englishTrackInParentheses, + englishArtistInParentheses, + null); + + trackParenthesesResult2.setOriginalTrackTitle(trackInfo.getTrackTitle()); + trackParenthesesResult2.setOriginalArtistName(trackInfo.getArtistName()); + + if (trackParenthesesResult2.isMatched()) { + log.info("매칭 성공 (2.5-b - 괄호 안 track+괄호 안 artist): {} ({}) - {} ({})", + trackInfo.getArtistName(), englishArtistInParentheses, + trackInfo.getTrackTitle(), englishTrackInParentheses); + return trackParenthesesResult2; + } + } + } + + // 3단계: track + album으로 검색 (앨범 정보가 있는 경우에만) + if (trackInfo.getAlbumTitle() != null && !trackInfo.getAlbumTitle().trim().isEmpty()) { + log.debug("3단계 검색: track + album"); + + VibeToSpotifySearchResponse.SearchResult albumResult = + performSpotifySearch(accessToken, + trackInfo.getTrackTitle(), + null, // artist 제외 + trackInfo.getAlbumTitle()); + + albumResult.setOriginalTrackTitle(trackInfo.getTrackTitle()); + albumResult.setOriginalArtistName(trackInfo.getArtistName()); + + if (albumResult.isMatched()) { + log.info("매칭 성공 (3단계 - track+album): {} - {} (앨범: {})", + trackInfo.getArtistName(), trackInfo.getTrackTitle(), trackInfo.getAlbumTitle()); + return albumResult; + } + } + + // 4단계: 한글이 포함되어 있으면 영문 번역 후 재시도 + if (translationService.containsKorean(trackInfo.getTrackTitle())) { + log.info("4단계 검색: 한글 제목 감지, 영문 번역 시도 - {}", trackInfo.getTrackTitle()); + + String translatedTitle = translationService.translateKoreanToEnglish(trackInfo.getTrackTitle()); + + // Artist는 괄호 영문이 있으면 사용, 없으면 번역 + String artistForTranslatedSearch = englishArtistInParentheses != null ? + englishArtistInParentheses : + translationService.translateKoreanToEnglish(trackInfo.getArtistName()); + + if (translatedTitle != null && !translatedTitle.isEmpty()) { + log.info("번역 완료: {} -> {}", trackInfo.getTrackTitle(), translatedTitle); + + // 번역된 제목으로 재검색 (track + artist) + VibeToSpotifySearchResponse.SearchResult translatedResult = + performSpotifySearch(accessToken, translatedTitle, artistForTranslatedSearch, null); + + translatedResult.setOriginalTrackTitle(trackInfo.getTrackTitle()); + translatedResult.setOriginalArtistName(trackInfo.getArtistName()); + + if (translatedResult.isMatched()) { + log.info("매칭 성공 (4단계 - 영문 번역): {} - {} -> {} - {}", + trackInfo.getArtistName(), trackInfo.getTrackTitle(), + artistForTranslatedSearch, translatedTitle); + return translatedResult; + } else { + log.warn("4단계 실패: 영문 번역 후에도 매칭 실패 - {} - {}", + artistForTranslatedSearch, translatedTitle); + } + } else { + log.warn("제목 번역 실패: {}", trackInfo.getTrackTitle()); + } + } + + // 모든 단계 실패 + log.warn("최종 매칭 실패 (모든 단계 실패): {} - {}", trackInfo.getArtistName(), trackInfo.getTrackTitle()); + return result; + } + + /** + * 괄호 안의 영문을 추출합니다. + * 예: "아이유(IU)" -> "IU" + * "럼블피쉬 (Rumblefish)" -> "Rumblefish" + * + * @param text 괄호가 포함된 텍스트 + * @return 괄호 안의 영문 (없으면 null) + */ + private String extractEnglishFromParentheses(String text) { + if (text == null || text.trim().isEmpty()) { + return null; + } + + // 괄호 안 내용 추출: ( ) 또는 ( ) + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\(([^)]+)\\)|\\(([^)]+)\\)"); + java.util.regex.Matcher matcher = pattern.matcher(text); + + while (matcher.find()) { + String inParentheses = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); + if (inParentheses != null) { + inParentheses = inParentheses.trim(); + + // 영문/숫자로만 구성되어 있는지 확인 (공백, 하이픈, 점 허용) + if (inParentheses.matches("[a-zA-Z0-9\\s\\-\\.]+")) { + log.debug("괄호 안 영문 추출: {} -> {}", text, inParentheses); + return inParentheses; + } + } + } + + return null; + } + + /** + * track 제목의 괄호 안에서 영문 track명을 추출합니다. + * "Feat.", "Prod.", "From"으로 시작하거나 "Ver.", "삽입곡"이 포함되는 경우는 제외합니다. + * 예: "사랑 (Love)" -> "Love" + * "사랑 (Feat. IU)" -> null (Feat.로 시작하므로 제외) + * "사랑 (리믹스 Ver.)" -> null (Ver. 포함하므로 제외) + * + * @param trackTitle track 제목 + * @return 괄호 안의 영문 track명 (조건에 맞지 않으면 null) + */ + private String extractEnglishTrackTitle(String trackTitle) { + if (trackTitle == null || trackTitle.trim().isEmpty()) { + return null; + } + + // 괄호 안 내용 추출 + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\(([^)]+)\\)|\\(([^)]+)\\)"); + java.util.regex.Matcher matcher = pattern.matcher(trackTitle); + + while (matcher.find()) { + String inParentheses = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); + if (inParentheses != null) { + inParentheses = inParentheses.trim(); + + // 제외 조건 체크 + String upperInParentheses = inParentheses.toUpperCase(); + if (upperInParentheses.startsWith("FEAT.") || + upperInParentheses.startsWith("PROD.") || + upperInParentheses.startsWith("FROM") || + inParentheses.contains("Ver.") || + inParentheses.contains("VER.") || + inParentheses.contains("ver.") || + inParentheses.contains("삽입곡")) { + log.debug("track 괄호 내용이 제외 조건에 해당: {}", inParentheses); + continue; // 다음 괄호 확인 + } + + // 영문/숫자로만 구성되어 있는지 확인 (공백, 하이픈, 점, 쉼표, 어포스트로피 허용) + if (inParentheses.matches("[a-zA-Z0-9\\s\\-\\.,'\\\\']+")) { + log.debug("track 괄호 안 영문 추출: {} -> {}", trackTitle, inParentheses); + return inParentheses; + } + } + } + + return null; + } + + /** + * 텍스트에서 괄호 부분을 제거합니다. + * 예: "아이유(IU)" -> "아이유" + * "럼블피쉬 (Rumblefish)" -> "럼블피쉬" + * + * @param text 원본 텍스트 + * @return 괄호가 제거된 텍스트 + */ + private String removeParentheses(String text) { + if (text == null || text.trim().isEmpty()) { + return text; + } + + // 괄호 및 괄호 안 내용 제거: ( ) 또는 ( ) + String result = text.replaceAll("\\(([^)]+)\\)", "") + .replaceAll("\\(([^)]+)\\)", "") + .trim(); + + log.debug("괄호 제거: {} -> {}", text, result); + return result; + } + + /** + * Spotify Search API를 호출하여 트랙을 검색합니다. + * + * @param accessToken Spotify Access Token + * @param trackTitle 트랙 제목 + * @param artistName 아티스트 이름 + * @param albumTitle 앨범 제목 (선택사항) + * @return 검색 결과 + */ + private VibeToSpotifySearchResponse.SearchResult performSpotifySearch( + String accessToken, + String trackTitle, + String artistName, + String albumTitle) { + + VibeToSpotifySearchResponse.SearchResult.SearchResultBuilder resultBuilder = + VibeToSpotifySearchResponse.SearchResult.builder() + .matched(false); + + try { + // Spotify Search API URL 생성 + StringBuilder queryBuilder = new StringBuilder(); + queryBuilder.append("track:").append(trackTitle); + + if (artistName != null && !artistName.trim().isEmpty()) { + queryBuilder.append(" artist:").append(artistName); + } + + if (albumTitle != null && !albumTitle.trim().isEmpty()) { + queryBuilder.append(" album:").append(albumTitle); + } + + String query = queryBuilder.toString(); + log.debug("검색 쿼리: {}", query); + + String searchUrl = SPOTIFY_API_BASE_URL + "/search?q=" + + java.net.URLEncoder.encode(query, StandardCharsets.UTF_8) + + "&type=track&limit=1"; + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setResponseTimeout(30, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpGet getRequest = new HttpGet(searchUrl); + getRequest.setHeader("Authorization", "Bearer " + accessToken); + + try (CloseableHttpResponse response = httpClient.execute(getRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + if (statusCode == 200) { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode tracksNode = root.path("tracks").path("items"); + + if (tracksNode.isArray() && tracksNode.size() > 0) { + JsonNode trackNode = tracksNode.get(0); + + // 아티스트 이름 추출 (첫 번째 아티스트) + String spotifyArtistName = null; + JsonNode artistsNode = trackNode.path("artists"); + if (artistsNode.isArray() && artistsNode.size() > 0) { + spotifyArtistName = artistsNode.get(0).path("name").asText(); + } + + // 앨범 이미지 추출 + String albumImageUrl = null; + JsonNode imagesNode = trackNode.path("album").path("images"); + if (imagesNode.isArray() && imagesNode.size() > 0) { + albumImageUrl = imagesNode.get(0).path("url").asText(null); + } + + resultBuilder + .matched(true) + .spotifyTrackId(trackNode.path("id").asText()) + .spotifyTrackUri(trackNode.path("uri").asText()) + .spotifyTrackName(trackNode.path("name").asText()) + .spotifyArtistName(spotifyArtistName) + .spotifyAlbumName(trackNode.path("album").path("name").asText(null)) + .spotifyTrackUrl(trackNode.path("external_urls").path("spotify").asText(null)) + .albumImageUrl(albumImageUrl) + .previewUrl(trackNode.path("preview_url").asText(null)); + + log.debug("Spotify 검색 성공: {}", query); + } else { + resultBuilder.errorMessage("Spotify에서 매칭되는 트랙을 찾을 수 없습니다."); + log.debug("Spotify 검색 결과 없음: {}", query); + } + } else { + resultBuilder.errorMessage("Spotify API 호출 실패: " + statusCode); + log.error("Spotify Search API 호출 실패. 상태 코드: {}", statusCode); + } + } + } + + } catch (Exception e) { + resultBuilder.errorMessage("검색 중 오류: " + e.getMessage()); + log.error("Spotify 검색 중 오류 발생: track={}, artist={}, album={}", + trackTitle, artistName, albumTitle, e); + } + + return resultBuilder.build(); + } +} diff --git a/src/main/java/com/domain/service/TranslationService.java b/src/main/java/com/domain/service/TranslationService.java new file mode 100644 index 0000000..0d6e245 --- /dev/null +++ b/src/main/java/com/domain/service/TranslationService.java @@ -0,0 +1,147 @@ +package com.domain.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TranslationService { + + private final ObjectMapper objectMapper; + + @Value("${papago.client-id:}") + private String papagoClientId; + + @Value("${papago.client-secret:}") + private String papagoClientSecret; + + // Naver Cloud Platform API (신버전) + private static final String PAPAGO_API_URL = "https://papago.apigw.ntruss.com/nmt/v1/translation"; + + /** + * 한글 텍스트를 영문으로 번역합니다 (Papago API 사용). + * 괄호 내용(Feat, Ver 등)은 제거하고 순수 제목만 번역합니다. + * + * @param koreanText 번역할 한글 텍스트 + * @return 영문 번역 결과 (실패 시 null) + */ + public String translateKoreanToEnglish(String koreanText) { + if (koreanText == null || koreanText.trim().isEmpty()) { + return null; + } + + if (papagoClientId == null || papagoClientId.trim().isEmpty() || + papagoClientSecret == null || papagoClientSecret.trim().isEmpty()) { + log.warn("Papago API 인증 정보가 설정되지 않았습니다."); + return null; + } + + try { + // 괄호 내용 제거 (Feat, Ver, Remix 등) + String cleanedText = koreanText + .replaceAll("\\([^)]*\\)", "") // 소괄호와 내용 제거 + .replaceAll("\\[[^]]*\\]", "") // 대괄호와 내용 제거 + .trim(); + + if (cleanedText.isEmpty()) { + log.warn("괄호 제거 후 빈 문자열: {}", koreanText); + return null; + } + + log.debug("번역 전처리: {} -> {}", koreanText, cleanedText); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(10, TimeUnit.SECONDS) + .setResponseTimeout(10, TimeUnit.SECONDS) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build()) { + + HttpPost postRequest = new HttpPost(PAPAGO_API_URL); + postRequest.setHeader("X-NCP-APIGW-API-KEY-ID", papagoClientId); + postRequest.setHeader("X-NCP-APIGW-API-KEY", papagoClientSecret); + postRequest.setHeader("Content-Type", "application/json"); + + // Request body 생성 (JSON 형식) + String requestBody = String.format("{\"source\":\"ko\",\"target\":\"en\",\"text\":\"%s\"}", + cleanedText.replace("\"", "\\\"")); + postRequest.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + + log.debug("Papago API 요청 - URL: {}, Client-ID: {}, Body: {}", + PAPAGO_API_URL, papagoClientId, requestBody); + + try (CloseableHttpResponse response = httpClient.execute(postRequest)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + + log.debug("Papago API 응답 - 상태: {}, 응답: {}", statusCode, responseBody); + + if (statusCode == 200) { + JsonNode root = objectMapper.readTree(responseBody); + + // Naver Cloud Platform API 응답 형식: message.result.translatedText + String translatedText = root.path("message") + .path("result") + .path("translatedText") + .asText(); + + if (translatedText != null && !translatedText.isEmpty()) { + log.info("Papago 번역 성공: {} -> {}", cleanedText, translatedText); + return translatedText; + } else { + log.warn("Papago 번역 결과가 비어있습니다. 응답: {}", responseBody); + return null; + } + } else { + log.error("Papago API 호출 실패. 상태 코드: {}, 응답: {}", + statusCode, responseBody); + return null; + } + } + } + + } catch (Exception e) { + log.error("Papago 번역 중 오류 발생: {}", koreanText, e); + return null; + } + } + + /** + * 텍스트에 한글이 포함되어 있는지 확인합니다. + * + * @param text 검사할 텍스트 + * @return 한글 포함 여부 + */ + public boolean containsKorean(String text) { + if (text == null || text.isEmpty()) { + return false; + } + + for (char c : text.toCharArray()) { + if (Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_SYLLABLES || + Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO || + Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_JAMO) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/global/util/EncryptionUtil.java b/src/main/java/com/global/util/EncryptionUtil.java new file mode 100644 index 0000000..a506b19 --- /dev/null +++ b/src/main/java/com/global/util/EncryptionUtil.java @@ -0,0 +1,168 @@ +package com.global.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; + +/** + * CryptoJS 호환 AES 암호화/복호화 유틸리티 + * CryptoJS의 OpenSSL 호환 포맷(Salted__)을 지원합니다. + */ +@Slf4j +@Component +public class EncryptionUtil { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; + private static final String SALTED_PREFIX = "Salted__"; + private static final int SALT_LENGTH = 8; + private static final int KEY_LENGTH = 32; // 256 bits + private static final int IV_LENGTH = 16; // 128 bits + + @Value("${vibe.encryption.secret-key:MyListBridgeSecretKey1234567}") + private String secretKeyString; + + /** + * CryptoJS 호환 복호화 + * CryptoJS의 OpenSSL 포맷(Salted__ + salt + data)을 해석합니다. + * + * @param encryptedText Base64 인코딩된 CryptoJS 암호문 + * @return 복호화된 평문 + */ + public String decrypt(String encryptedText) { + if (encryptedText == null || encryptedText.isEmpty()) { + return null; + } + + try { + // 1. Base64 디코딩 + byte[] encryptedData = Base64.getDecoder().decode(encryptedText); + + // 2. "Salted__" 접두사 확인 + byte[] saltedPrefix = Arrays.copyOfRange(encryptedData, 0, 8); + String prefix = new String(saltedPrefix, StandardCharsets.UTF_8); + + if (!SALTED_PREFIX.equals(prefix)) { + log.error("Invalid CryptoJS format - missing 'Salted__' prefix"); + throw new RuntimeException("Invalid CryptoJS format"); + } + + // 3. Salt 추출 (8바이트) + byte[] salt = Arrays.copyOfRange(encryptedData, 8, 16); + + // 4. 실제 암호화된 데이터 + byte[] cipherText = Arrays.copyOfRange(encryptedData, 16, encryptedData.length); + + // 5. CryptoJS의 EVP_BytesToKey 방식으로 키와 IV 생성 + byte[][] keyAndIV = deriveKeyAndIV(secretKeyString.getBytes(StandardCharsets.UTF_8), salt); + byte[] key = keyAndIV[0]; + byte[] iv = keyAndIV[1]; + + // 6. 복호화 + SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); + + byte[] decryptedBytes = cipher.doFinal(cipherText); + String result = new String(decryptedBytes, StandardCharsets.UTF_8); + + log.debug("복호화 성공"); + return result; + + } catch (Exception e) { + log.error("CryptoJS 복호화 실패", e); + throw new RuntimeException("복호화 실패", e); + } + } + + /** + * CryptoJS/OpenSSL의 EVP_BytesToKey 알고리즘 구현 + * 패스워드와 Salt로부터 Key와 IV를 유도합니다. + * + * @param password 패스워드 바이트 + * @param salt Salt 바이트 + * @return [key, iv] 배열 + */ + private byte[][] deriveKeyAndIV(byte[] password, byte[] salt) throws Exception { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + byte[] key = new byte[KEY_LENGTH]; + byte[] iv = new byte[IV_LENGTH]; + byte[] concatenated = new byte[0]; + byte[] result = new byte[0]; + + int totalLength = KEY_LENGTH + IV_LENGTH; + + while (result.length < totalLength) { + md5.reset(); + md5.update(concatenated); + md5.update(password); + md5.update(salt); + concatenated = md5.digest(); + + byte[] temp = new byte[result.length + concatenated.length]; + System.arraycopy(result, 0, temp, 0, result.length); + System.arraycopy(concatenated, 0, temp, result.length, concatenated.length); + result = temp; + } + + System.arraycopy(result, 0, key, 0, KEY_LENGTH); + System.arraycopy(result, KEY_LENGTH, iv, 0, IV_LENGTH); + + return new byte[][]{key, iv}; + } + + /** + * CryptoJS 호환 암호화 (서버에서 암호화할 필요가 있는 경우) + * + * @param plainText 평문 + * @return Base64 인코딩된 CryptoJS 포맷 암호문 + */ + public String encrypt(String plainText) { + if (plainText == null || plainText.isEmpty()) { + return null; + } + + try { + // 1. 랜덤 Salt 생성 + byte[] salt = new byte[SALT_LENGTH]; + new java.security.SecureRandom().nextBytes(salt); + + // 2. Key와 IV 유도 + byte[][] keyAndIV = deriveKeyAndIV(secretKeyString.getBytes(StandardCharsets.UTF_8), salt); + byte[] key = keyAndIV[0]; + byte[] iv = keyAndIV[1]; + + // 3. 암호화 + SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 4. "Salted__" + salt + encryptedData 조합 + byte[] result = new byte[16 + encryptedBytes.length]; + System.arraycopy(SALTED_PREFIX.getBytes(StandardCharsets.UTF_8), 0, result, 0, 8); + System.arraycopy(salt, 0, result, 8, 8); + System.arraycopy(encryptedBytes, 0, result, 16, encryptedBytes.length); + + // 5. Base64 인코딩 + return Base64.getEncoder().encodeToString(result); + + } catch (Exception e) { + log.error("CryptoJS 암호화 실패", e); + throw new RuntimeException("암호화 실패", e); + } + } +}