바이브 모듈
This commit is contained in:
137
src/main/java/com/domain/api/VibeController.java
Normal file
137
src/main/java/com/domain/api/VibeController.java
Normal file
@@ -0,0 +1,137 @@
|
||||
package com.domain.api;
|
||||
|
||||
import com.domain.request.VibeLoginRequest;
|
||||
import com.domain.response.VibePlaylistResponse;
|
||||
import com.domain.response.VibeTrackResponse;
|
||||
import com.domain.service.VibeService;
|
||||
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/vibe")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Vibe", description = "Vibe 플레이리스트 API")
|
||||
public class VibeController {
|
||||
|
||||
private final VibeService vibeService;
|
||||
|
||||
/**
|
||||
* 네이버 로그인을 통해 Vibe 플레이리스트를 조회합니다.
|
||||
*
|
||||
* @param request 네이버 아이디/비밀번호
|
||||
* @return Vibe 플레이리스트 목록
|
||||
*/
|
||||
@PostMapping("/playlists")
|
||||
@Operation(summary = "Vibe 플레이리스트 조회", description = "네이버 계정으로 로그인하여 Vibe 플레이리스트를 조회합니다.")
|
||||
public ResponseEntity<VibePlaylistResponse> getVibePlaylists(@RequestBody VibeLoginRequest request) {
|
||||
log.info("Vibe 플레이리스트 조회 요청 - 네이버 ID: {}", request.getId());
|
||||
|
||||
// 입력 검증
|
||||
if (request.getId() == null || request.getId().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(VibePlaylistResponse.builder()
|
||||
.success(false)
|
||||
.message("네이버 아이디를 입력해주세요.")
|
||||
.build());
|
||||
}
|
||||
|
||||
if (request.getPassword() == null || request.getPassword().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(VibePlaylistResponse.builder()
|
||||
.success(false)
|
||||
.message("네이버 비밀번호를 입력해주세요.")
|
||||
.build());
|
||||
}
|
||||
|
||||
try {
|
||||
VibePlaylistResponse response = vibeService.getVibePlaylistsWithLogin(request);
|
||||
|
||||
if (response.isSuccess()) {
|
||||
log.info("Vibe 플레이리스트 조회 성공 - 플레이리스트 개수: {}",
|
||||
response.getPlaylists() != null ? response.getPlaylists().size() : 0);
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
log.warn("Vibe 플레이리스트 조회 실패 - 메시지: {}", response.getMessage());
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 플레이리스트 조회 중 예외 발생", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(VibePlaylistResponse.builder()
|
||||
.success(false)
|
||||
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 ID를 사용하여 플레이리스트의 트랙 목록을 조회합니다.
|
||||
*
|
||||
* @param sessionId 세션 ID (헤더)
|
||||
* @param playlistId 플레이리스트 ID
|
||||
* @return 트랙 목록
|
||||
*/
|
||||
@GetMapping("/playlists/{playlistId}/tracks")
|
||||
@Operation(summary = "Vibe 트랙 목록 조회", description = "세션 ID를 사용하여 플레이리스트의 트랙 목록을 조회합니다.")
|
||||
public ResponseEntity<VibeTrackResponse> getVibeTracks(
|
||||
@RequestHeader("X-Session-Id") String sessionId,
|
||||
@PathVariable("playlistId") String playlistId) {
|
||||
|
||||
log.info("Vibe 트랙 목록 조회 요청 - 플레이리스트 ID: {}, 세션 ID: {}", playlistId, sessionId);
|
||||
|
||||
// 입력 검증
|
||||
if (sessionId == null || sessionId.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("세션 ID가 필요합니다.")
|
||||
.build());
|
||||
}
|
||||
|
||||
if (playlistId == null || playlistId.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("플레이리스트 ID가 필요합니다.")
|
||||
.build());
|
||||
}
|
||||
|
||||
try {
|
||||
VibeTrackResponse response = vibeService.getVibeTracks(sessionId, playlistId);
|
||||
|
||||
if (response.isSuccess()) {
|
||||
log.info("Vibe 트랙 목록 조회 성공 - 트랙 개수: {}",
|
||||
response.getTracks() != null ? response.getTracks().size() : 0);
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
log.warn("Vibe 트랙 목록 조회 실패 - 메시지: {}", response.getMessage());
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 트랙 목록 조회 중 예외 발생", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibe API 연동 상태를 확인합니다.
|
||||
*
|
||||
* @return 헬스체크 응답
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
@Operation(summary = "Vibe API 헬스체크", description = "Vibe API 연동 상태를 확인합니다.")
|
||||
public ResponseEntity<String> healthCheck() {
|
||||
return ResponseEntity.ok("Vibe API is ready");
|
||||
}
|
||||
}
|
||||
15
src/main/java/com/domain/request/VibeLoginRequest.java
Normal file
15
src/main/java/com/domain/request/VibeLoginRequest.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.domain.request;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VibeLoginRequest {
|
||||
private String id;
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 VibeToSpotifySearchRequest {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
private List<TrackInfo> tracks;
|
||||
|
||||
@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; // 선택적, 더 정확한 매칭을 위해
|
||||
}
|
||||
}
|
||||
80
src/main/java/com/domain/response/VibePlaylistResponse.java
Normal file
80
src/main/java/com/domain/response/VibePlaylistResponse.java
Normal file
@@ -0,0 +1,80 @@
|
||||
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 VibePlaylistResponse {
|
||||
|
||||
private boolean success;
|
||||
private String message;
|
||||
|
||||
@JsonProperty("session_id")
|
||||
private String sessionId;
|
||||
|
||||
private List<Playlist> 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("created_at")
|
||||
private String createdAt;
|
||||
|
||||
@JsonProperty("updated_at")
|
||||
private String updatedAt;
|
||||
|
||||
private List<Track> tracks;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Track {
|
||||
@JsonProperty("track_id")
|
||||
private String trackId;
|
||||
|
||||
@JsonProperty("track_name")
|
||||
private String trackName;
|
||||
|
||||
@JsonProperty("artist_name")
|
||||
private String artistName;
|
||||
|
||||
@JsonProperty("album_name")
|
||||
private String albumName;
|
||||
|
||||
@JsonProperty("album_image_url")
|
||||
private String albumImageUrl;
|
||||
|
||||
@JsonProperty("duration_ms")
|
||||
private Long durationMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 VibeToSpotifySearchResponse {
|
||||
|
||||
private boolean success;
|
||||
private String message;
|
||||
|
||||
@JsonProperty("total_searched")
|
||||
private Integer totalSearched;
|
||||
|
||||
@JsonProperty("matched_count")
|
||||
private Integer matchedCount;
|
||||
|
||||
@JsonProperty("unmatched_count")
|
||||
private Integer unmatchedCount;
|
||||
|
||||
private List<SearchResult> results;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class SearchResult {
|
||||
// 원본 Vibe 정보
|
||||
@JsonProperty("original_track_title")
|
||||
private String originalTrackTitle;
|
||||
|
||||
@JsonProperty("original_artist_name")
|
||||
private String originalArtistName;
|
||||
|
||||
// Spotify 매칭 결과
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("spotify_track_uri")
|
||||
private String spotifyTrackUri;
|
||||
|
||||
@JsonProperty("spotify_track_id")
|
||||
private String spotifyTrackId;
|
||||
|
||||
@JsonProperty("spotify_track_name")
|
||||
private String spotifyTrackName;
|
||||
|
||||
@JsonProperty("spotify_artist_name")
|
||||
private String spotifyArtistName;
|
||||
|
||||
@JsonProperty("spotify_album_name")
|
||||
private String spotifyAlbumName;
|
||||
|
||||
@JsonProperty("spotify_track_url")
|
||||
private String spotifyTrackUrl;
|
||||
|
||||
@JsonProperty("album_image_url")
|
||||
private String albumImageUrl;
|
||||
|
||||
@JsonProperty("preview_url")
|
||||
private String previewUrl;
|
||||
|
||||
@JsonProperty("error_message")
|
||||
private String errorMessage;
|
||||
}
|
||||
}
|
||||
87
src/main/java/com/domain/response/VibeTrackResponse.java
Normal file
87
src/main/java/com/domain/response/VibeTrackResponse.java
Normal file
@@ -0,0 +1,87 @@
|
||||
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 VibeTrackResponse {
|
||||
|
||||
private boolean success;
|
||||
private String message;
|
||||
|
||||
@JsonProperty("total_count")
|
||||
private Integer totalCount;
|
||||
|
||||
private List<Track> tracks;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Track {
|
||||
@JsonProperty("track_id")
|
||||
private Long trackId;
|
||||
|
||||
@JsonProperty("track_title")
|
||||
private String trackTitle;
|
||||
|
||||
@JsonProperty("play_time")
|
||||
private String playTime;
|
||||
|
||||
private List<Artist> artists;
|
||||
|
||||
private Album album;
|
||||
|
||||
@JsonProperty("has_lyric")
|
||||
private Boolean hasLyric;
|
||||
|
||||
@JsonProperty("is_streaming")
|
||||
private Boolean isStreaming;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Artist {
|
||||
@JsonProperty("artist_id")
|
||||
private Long artistId;
|
||||
|
||||
@JsonProperty("artist_name")
|
||||
private String artistName;
|
||||
|
||||
@JsonProperty("image_url")
|
||||
private String imageUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Album {
|
||||
@JsonProperty("album_id")
|
||||
private Long albumId;
|
||||
|
||||
@JsonProperty("album_title")
|
||||
private String albumTitle;
|
||||
|
||||
@JsonProperty("release_date")
|
||||
private String releaseDate;
|
||||
|
||||
@JsonProperty("image_url")
|
||||
private String imageUrl;
|
||||
}
|
||||
}
|
||||
70
src/main/java/com/domain/service/VibeCookieService.java
Normal file
70
src/main/java/com/domain/service/VibeCookieService.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.domain.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VibeCookieService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String VIBE_COOKIE_PREFIX = "vibe:cookie:";
|
||||
private static final long COOKIE_EXPIRE_HOURS = 24; // 24시간
|
||||
|
||||
/**
|
||||
* userId를 키로 Cookie를 Redis에 저장합니다.
|
||||
*
|
||||
* @param userId 사용자 아이디
|
||||
* @param cookies 저장할 Cookie 맵
|
||||
*/
|
||||
public void saveCookieByUserId(String userId, Map<String, String> cookies) {
|
||||
String key = VIBE_COOKIE_PREFIX + userId;
|
||||
|
||||
redisTemplate.opsForHash().putAll(key, cookies);
|
||||
redisTemplate.expire(key, COOKIE_EXPIRE_HOURS, TimeUnit.HOURS);
|
||||
|
||||
log.info("Vibe Cookie 저장 완료 - userId: {}, 유효기간: {}시간", userId, COOKIE_EXPIRE_HOURS);
|
||||
}
|
||||
|
||||
/**
|
||||
* userId로 Cookie를 조회합니다.
|
||||
*
|
||||
* @param userId 사용자 아이디
|
||||
* @return Cookie 맵 (없으면 null)
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, String> getCookieByUserId(String userId) {
|
||||
String key = VIBE_COOKIE_PREFIX + userId;
|
||||
|
||||
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
|
||||
if (entries.isEmpty()) {
|
||||
log.info("Redis에 저장된 Vibe Cookie 없음 - userId: {}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// TTL 갱신
|
||||
redisTemplate.expire(key, COOKIE_EXPIRE_HOURS, TimeUnit.HOURS);
|
||||
log.debug("Vibe Cookie 조회 및 TTL 갱신 - userId: {}", userId);
|
||||
|
||||
return (Map<String, String>) (Map<?, ?>) entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* userId의 Cookie를 삭제합니다.
|
||||
*
|
||||
* @param userId 사용자 아이디
|
||||
*/
|
||||
public void deleteCookieByUserId(String userId) {
|
||||
String key = VIBE_COOKIE_PREFIX + userId;
|
||||
redisTemplate.delete(key);
|
||||
log.info("Vibe Cookie 삭제 완료 - userId: {}", userId);
|
||||
}
|
||||
}
|
||||
517
src/main/java/com/domain/service/VibeService.java
Normal file
517
src/main/java/com/domain/service/VibeService.java
Normal file
@@ -0,0 +1,517 @@
|
||||
package com.domain.service;
|
||||
|
||||
import com.domain.request.VibeLoginRequest;
|
||||
import com.domain.response.VibePlaylistResponse;
|
||||
import com.domain.response.VibeTrackResponse;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.microsoft.playwright.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
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.io.entity.EntityUtils;
|
||||
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 VibeService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final VibeCookieService vibeCookieService;
|
||||
private final com.global.util.EncryptionUtil encryptionUtil;
|
||||
|
||||
private static final String NAVER_LOGIN_URL = "https://nid.naver.com/nidlogin.login";
|
||||
private static final String VIBE_PLAYLIST_API_URL = "https://apis.naver.com/vibeWeb/musicapiweb/vibe/v2/library/PLAYLIST/libraries?start=1&display=50";
|
||||
private static final String VIBE_TRACK_API_URL = "https://apis.naver.com/vibeWeb/musicapiweb/myMusic/myAlbum/%s/tracks?start=1&display=500";
|
||||
|
||||
/**
|
||||
* 네이버 로그인을 통해 Vibe 플레이리스트를 조회합니다.
|
||||
* Redis에 저장된 쿠키를 우선 사용하고, 만료된 경우에만 재로그인합니다.
|
||||
*
|
||||
* @param request 네이버 아이디/비밀번호 (암호화됨)
|
||||
* @return Vibe 플레이리스트 응답 (sessionId 포함)
|
||||
*/
|
||||
public VibePlaylistResponse getVibePlaylistsWithLogin(VibeLoginRequest request) {
|
||||
try {
|
||||
// 0. 아이디/비밀번호 복호화
|
||||
String userId = encryptionUtil.decrypt(request.getId());
|
||||
String password = encryptionUtil.decrypt(request.getPassword());
|
||||
|
||||
log.info("Vibe 플레이리스트 조회 요청 - userId: {}", userId);
|
||||
|
||||
// 1. Redis에서 기존 쿠키 조회
|
||||
Map<String, String> cookies = vibeCookieService.getCookieByUserId(userId);
|
||||
|
||||
// 2. 쿠키가 있으면 유효성 검사
|
||||
if (cookies != null && !cookies.isEmpty()) {
|
||||
log.info("Redis에서 쿠키 발견 - 유효성 검사 수행");
|
||||
|
||||
if (validateVibeCookie(cookies)) {
|
||||
log.info("쿠키 유효 - Redis 쿠키 사용");
|
||||
} else {
|
||||
log.warn("쿠키 만료됨 - 재로그인 필요");
|
||||
cookies = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 쿠키가 없거나 만료된 경우 재로그인
|
||||
if (cookies == null || cookies.isEmpty()) {
|
||||
log.info("재로그인 시작 - userId: {}", userId);
|
||||
cookies = loginToNaver(userId, password);
|
||||
|
||||
if (cookies.isEmpty()) {
|
||||
return VibePlaylistResponse.builder()
|
||||
.success(false)
|
||||
.message("네이버 로그인에 실패했습니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
// 4. 로그인 성공 - Redis에 쿠키 저장
|
||||
vibeCookieService.saveCookieByUserId(userId, cookies);
|
||||
log.info("Redis에 쿠키 저장 완료 - userId: {}", userId);
|
||||
}
|
||||
|
||||
// 5. 쿠키를 사용하여 Vibe 플레이리스트 조회
|
||||
List<VibePlaylistResponse.Playlist> playlists = fetchVibePlaylists(cookies);
|
||||
|
||||
// 6. userId를 암호화하여 sessionId로 반환
|
||||
String sessionId = encryptionUtil.encrypt(userId);
|
||||
log.info("세션 ID 생성 완료 - userId: {}", userId);
|
||||
|
||||
return VibePlaylistResponse.builder()
|
||||
.success(true)
|
||||
.message("플레이리스트 조회 성공")
|
||||
.sessionId(sessionId)
|
||||
.playlists(playlists)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 플레이리스트 조회 중 오류 발생", e);
|
||||
return VibePlaylistResponse.builder()
|
||||
.success(false)
|
||||
.message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright를 사용하여 네이버 로그인을 수행하고 쿠키를 반환합니다.
|
||||
*
|
||||
* @param naverId 네이버 아이디
|
||||
* @param naverPassword 네이버 비밀번호
|
||||
* @return 쿠키 맵 (name -> value)
|
||||
*/
|
||||
private Map<String, String> loginToNaver(String naverId, String naverPassword) {
|
||||
Map<String, String> cookieMap = new HashMap<>();
|
||||
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(true) // 브라우저를 백그라운드에서 실행
|
||||
.setTimeout(60000)); // 60초 타임아웃
|
||||
|
||||
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.setViewportSize(1920, 1080));
|
||||
|
||||
Page page = context.newPage();
|
||||
|
||||
try {
|
||||
log.info("네이버 로그인 페이지 접속");
|
||||
page.navigate(NAVER_LOGIN_URL);
|
||||
|
||||
// 아이디 입력
|
||||
page.fill("input#id", naverId);
|
||||
log.debug("네이버 아이디 입력 완료");
|
||||
|
||||
// 비밀번호 입력
|
||||
page.fill("input#pw", naverPassword);
|
||||
log.debug("네이버 비밀번호 입력 완료");
|
||||
|
||||
// 로그인 버튼 클릭
|
||||
page.click("button[type='submit']");
|
||||
log.info("로그인 버튼 클릭");
|
||||
|
||||
// 로그인 완료 대기 (URL 변경 또는 특정 요소 확인)
|
||||
try {
|
||||
page.waitForURL("https://www.naver.com/**", new Page.WaitForURLOptions().setTimeout(10000));
|
||||
log.info("네이버 로그인 성공");
|
||||
} catch (TimeoutError e) {
|
||||
// URL이 변경되지 않으면 로그인 실패 가능성
|
||||
String currentUrl = page.url();
|
||||
log.warn("로그인 후 URL: {}", currentUrl);
|
||||
|
||||
if (currentUrl.contains("nidlogin")) {
|
||||
log.error("네이버 로그인 실패 - CAPTCHA 또는 인증 오류 가능성");
|
||||
return cookieMap;
|
||||
}
|
||||
}
|
||||
|
||||
// 쿠키 추출
|
||||
List<com.microsoft.playwright.options.Cookie> cookies = context.cookies();
|
||||
for (com.microsoft.playwright.options.Cookie cookie : cookies) {
|
||||
cookieMap.put(cookie.name, cookie.value);
|
||||
log.debug("쿠키 획득: {} = {}", cookie.name, cookie.value);
|
||||
}
|
||||
|
||||
log.info("총 {} 개의 쿠키 획득", cookieMap.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("네이버 로그인 중 오류 발생", e);
|
||||
} finally {
|
||||
page.close();
|
||||
context.close();
|
||||
browser.close();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Playwright 초기화 중 오류 발생", e);
|
||||
}
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키의 유효성을 검사합니다.
|
||||
* Vibe API를 호출하여 401/403 응답이 나오지 않으면 유효한 것으로 판단합니다.
|
||||
*
|
||||
* @param cookies 검사할 쿠키 맵
|
||||
* @return 유효하면 true, 만료되었으면 false
|
||||
*/
|
||||
private boolean validateVibeCookie(Map<String, String> cookies) {
|
||||
RequestConfig requestConfig = RequestConfig.custom()
|
||||
.setConnectTimeout(10, TimeUnit.SECONDS)
|
||||
.setResponseTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||
.setDefaultRequestConfig(requestConfig)
|
||||
.build()) {
|
||||
|
||||
HttpGet getRequest = new HttpGet(VIBE_PLAYLIST_API_URL);
|
||||
|
||||
// 헤더 설정
|
||||
getRequest.setHeader("Accept", "application/json");
|
||||
getRequest.setHeader("Content-Type", "application/json");
|
||||
|
||||
// 쿠키 설정
|
||||
StringBuilder cookieHeader = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||
if (cookieHeader.length() > 0) {
|
||||
cookieHeader.append("; ");
|
||||
}
|
||||
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||
|
||||
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||
int statusCode = response.getCode();
|
||||
|
||||
// 401 Unauthorized 또는 403 Forbidden이면 쿠키 만료
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
log.warn("쿠키 만료 감지 - 상태 코드: {}", statusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 200 OK 또는 기타 성공 응답이면 유효
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
log.debug("쿠키 유효성 검사 성공 - 상태 코드: {}", statusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 기타 에러는 유효하지 않은 것으로 처리
|
||||
log.warn("쿠키 유효성 검사 실패 - 상태 코드: {}", statusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("쿠키 유효성 검사 중 오류 발생", e);
|
||||
return false; // 오류 발생 시 재로그인 유도
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibe API를 호출하여 플레이리스트를 조회합니다.
|
||||
*
|
||||
* @param cookies 네이버 로그인 쿠키
|
||||
* @return 플레이리스트 목록
|
||||
*/
|
||||
private List<VibePlaylistResponse.Playlist> fetchVibePlaylists(Map<String, String> cookies) {
|
||||
List<VibePlaylistResponse.Playlist> 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(VIBE_PLAYLIST_API_URL);
|
||||
getRequest.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
getRequest.setHeader("Referer", "https://vibe.naver.com/");
|
||||
|
||||
// 쿠키 헤더 추가
|
||||
StringBuilder cookieHeader = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||
if (cookieHeader.length() > 0) {
|
||||
cookieHeader.append("; ");
|
||||
}
|
||||
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||
|
||||
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||
int statusCode = response.getCode();
|
||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||
|
||||
log.info("Vibe API 응답 코드: {}", statusCode);
|
||||
log.info("Vibe API 응답 본문 길이: {} bytes", responseBody.length());
|
||||
log.info("Vibe API 응답 본문 (처음 500자): {}", responseBody.length() > 500 ? responseBody.substring(0, 500) : responseBody);
|
||||
|
||||
if (statusCode == 200) {
|
||||
// JSON 응답 파싱
|
||||
playlists = parseVibePlaylistResponse(responseBody);
|
||||
log.info("파싱된 플레이리스트 개수: {}", playlists.size());
|
||||
} else {
|
||||
log.error("Vibe API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 플레이리스트 조회 중 오류 발생", e);
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibe API 응답을 파싱합니다.
|
||||
*
|
||||
* @param responseBody JSON 응답 본문
|
||||
* @return 플레이리스트 목록
|
||||
*/
|
||||
private List<VibePlaylistResponse.Playlist> parseVibePlaylistResponse(String responseBody) {
|
||||
List<VibePlaylistResponse.Playlist> playlists = new ArrayList<>();
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
JsonNode resultNode = root.path("response").path("result");
|
||||
|
||||
if (resultNode.isObject()) {
|
||||
JsonNode librariesNode = resultNode.path("libraries");
|
||||
|
||||
if (librariesNode.isArray()) {
|
||||
for (JsonNode libraryNode : librariesNode) {
|
||||
VibePlaylistResponse.Playlist playlist = VibePlaylistResponse.Playlist.builder()
|
||||
.playlistId(libraryNode.path("id").asText())
|
||||
.playlistName(libraryNode.path("title").asText())
|
||||
.description(libraryNode.path("subtitle").asText(null))
|
||||
.trackCount(libraryNode.path("trackCount").asInt(0))
|
||||
.imageUrl(libraryNode.path("imageUrl").asText(null))
|
||||
.createdAt(null) // API 응답에 없음
|
||||
.updatedAt(null) // API 응답에 없음
|
||||
.build();
|
||||
|
||||
playlists.add(playlist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 응답 파싱 중 오류 발생", e);
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 ID(암호화된 userId)를 사용하여 플레이리스트의 트랙 목록을 조회합니다.
|
||||
*
|
||||
* @param sessionId 세션 ID (암호화된 userId)
|
||||
* @param playlistId 플레이리스트 ID
|
||||
* @return Track 목록 응답
|
||||
*/
|
||||
public VibeTrackResponse getVibeTracks(String sessionId, String playlistId) {
|
||||
try {
|
||||
// 1. sessionId(암호화된 userId)를 복호화
|
||||
String userId;
|
||||
try {
|
||||
userId = encryptionUtil.decrypt(sessionId);
|
||||
log.info("세션 ID 복호화 성공 - userId: {}", userId);
|
||||
} catch (Exception e) {
|
||||
log.error("세션 ID 복호화 실패", e);
|
||||
return VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("유효하지 않은 세션 ID입니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
// 2. Redis에서 userId로 쿠키 조회
|
||||
Map<String, String> cookies = vibeCookieService.getCookieByUserId(userId);
|
||||
|
||||
if (cookies == null || cookies.isEmpty()) {
|
||||
log.warn("쿠키를 찾을 수 없음 - userId: {}", userId);
|
||||
return VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.")
|
||||
.build();
|
||||
}
|
||||
|
||||
// 3. Track API 호출
|
||||
List<VibeTrackResponse.Track> tracks = fetchVibeTracks(cookies, playlistId);
|
||||
|
||||
return VibeTrackResponse.builder()
|
||||
.success(true)
|
||||
.message("트랙 목록 조회 성공")
|
||||
.totalCount(tracks.size())
|
||||
.tracks(tracks)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 트랙 목록 조회 중 오류 발생", e);
|
||||
return VibeTrackResponse.builder()
|
||||
.success(false)
|
||||
.message("트랙 목록 조회 중 오류가 발생했습니다: " + e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibe API를 호출하여 트랙 목록을 조회합니다.
|
||||
*
|
||||
* @param cookies 네이버 로그인 쿠키
|
||||
* @param playlistId 플레이리스트 ID
|
||||
* @return 트랙 목록
|
||||
*/
|
||||
private List<VibeTrackResponse.Track> fetchVibeTracks(Map<String, String> cookies, String playlistId) {
|
||||
List<VibeTrackResponse.Track> tracks = new ArrayList<>();
|
||||
|
||||
String trackApiUrl = String.format(VIBE_TRACK_API_URL, playlistId);
|
||||
|
||||
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(trackApiUrl);
|
||||
getRequest.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
getRequest.setHeader("Referer", "https://vibe.naver.com/");
|
||||
getRequest.setHeader("Accept", "application/json");
|
||||
|
||||
// 쿠키 헤더 추가
|
||||
StringBuilder cookieHeader = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||
if (cookieHeader.length() > 0) {
|
||||
cookieHeader.append("; ");
|
||||
}
|
||||
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||
|
||||
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||
int statusCode = response.getCode();
|
||||
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||
|
||||
log.info("Vibe Track API 응답 코드: {}", statusCode);
|
||||
log.info("Vibe Track API 응답 본문 길이: {} bytes", responseBody.length());
|
||||
|
||||
if (statusCode == 200) {
|
||||
// JSON 응답 파싱
|
||||
tracks = parseVibeTrackResponse(responseBody);
|
||||
log.info("파싱된 트랙 개수: {}", tracks.size());
|
||||
} else {
|
||||
log.error("Vibe Track API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe 트랙 목록 조회 중 오류 발생", e);
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibe Track API 응답을 파싱합니다.
|
||||
*
|
||||
* @param responseBody JSON 응답 본문
|
||||
* @return 트랙 목록
|
||||
*/
|
||||
private List<VibeTrackResponse.Track> parseVibeTrackResponse(String responseBody) {
|
||||
List<VibeTrackResponse.Track> tracks = new ArrayList<>();
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseBody);
|
||||
JsonNode resultNode = root.path("response").path("result");
|
||||
|
||||
if (resultNode.isObject()) {
|
||||
JsonNode tracksNode = resultNode.path("tracks");
|
||||
|
||||
if (tracksNode.isArray()) {
|
||||
for (JsonNode trackNode : tracksNode) {
|
||||
// Artists 파싱
|
||||
List<VibeTrackResponse.Artist> artists = new ArrayList<>();
|
||||
JsonNode artistsNode = trackNode.path("artists");
|
||||
if (artistsNode.isArray()) {
|
||||
for (JsonNode artistNode : artistsNode) {
|
||||
VibeTrackResponse.Artist artist = VibeTrackResponse.Artist.builder()
|
||||
.artistId(artistNode.path("artistId").asLong())
|
||||
.artistName(artistNode.path("artistName").asText())
|
||||
.imageUrl(artistNode.path("imageUrl").asText(null))
|
||||
.build();
|
||||
artists.add(artist);
|
||||
}
|
||||
}
|
||||
|
||||
// Album 파싱
|
||||
JsonNode albumNode = trackNode.path("album");
|
||||
VibeTrackResponse.Album album = null;
|
||||
if (albumNode.isObject()) {
|
||||
album = VibeTrackResponse.Album.builder()
|
||||
.albumId(albumNode.path("albumId").asLong())
|
||||
.albumTitle(albumNode.path("albumTitle").asText())
|
||||
.releaseDate(albumNode.path("releaseDate").asText(null))
|
||||
.imageUrl(albumNode.path("imageUrl").asText(null))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Track 생성
|
||||
VibeTrackResponse.Track track = VibeTrackResponse.Track.builder()
|
||||
.trackId(trackNode.path("trackId").asLong())
|
||||
.trackTitle(trackNode.path("trackTitle").asText())
|
||||
.playTime(trackNode.path("playTime").asText(null))
|
||||
.artists(artists)
|
||||
.album(album)
|
||||
.hasLyric(trackNode.path("hasLyric").asBoolean(false))
|
||||
.isStreaming(trackNode.path("isStreaming").asBoolean(false))
|
||||
.build();
|
||||
|
||||
tracks.add(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Vibe Track 응답 파싱 중 오류 발생", e);
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,12 @@ public class AuthenticationConfig {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setAllowedOrigins(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://10.20.20.23:8080"
|
||||
));
|
||||
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
|
||||
corsConfiguration.setAllowedHeaders(List.of("Authorization","Content-Type"));
|
||||
corsConfiguration.setAllowedHeaders(List.of("Authorization","Content-Type", "X-Session-Id"));
|
||||
corsConfiguration.setAllowCredentials(true);
|
||||
corsConfiguration.setExposedHeaders(List.of(
|
||||
"Content-Disposition",
|
||||
|
||||
@@ -37,7 +37,7 @@ public class MongodbConfig {
|
||||
String encodePassword = URLEncoder.encode(password, StandardCharsets.UTF_8);
|
||||
String auth = username.isEmpty() ? "" : String.format("%s:%s@",username, encodePassword);
|
||||
String connection;
|
||||
connection = String.format("mongodb://%s%s/?authSource=%s", auth, businessLogHost, db);
|
||||
connection = String.format("mongodb://%s%s/%s?authSource=%s", auth, businessLogHost, db, db);
|
||||
|
||||
MongoClientSettings settings = MongoClientSettings.builder()
|
||||
.applyConnectionString(new ConnectionString(connection))
|
||||
|
||||
@@ -112,8 +112,8 @@ excel:
|
||||
################################################################################################################################################################################################
|
||||
mongodb:
|
||||
host: 140.238.22.48:27017
|
||||
username: bcjang
|
||||
password: dlawls05081)
|
||||
username: myListBridge
|
||||
password: "mdmadkr135y"
|
||||
db: myListBridge
|
||||
|
||||
|
||||
@@ -129,3 +129,19 @@ redis:
|
||||
sync-timeout: 30000
|
||||
ssl: false
|
||||
abort-connect: false
|
||||
|
||||
|
||||
################################################################################################################################################################################################
|
||||
# Papago Translation API (Naver)
|
||||
################################################################################################################################################################################################
|
||||
papago:
|
||||
client-id: c2epv5w4h0
|
||||
client-secret: bsyQWOmFhxyHsHahWEwtN9Rfb1jXQEAvhPsZJpnu
|
||||
|
||||
|
||||
################################################################################################################################################################################################
|
||||
# Vibe 암호화 설정
|
||||
################################################################################################################################################################################################
|
||||
vibe:
|
||||
encryption:
|
||||
secret-key: MyListBridgeSecretKey1234567890
|
||||
|
||||
Reference in New Issue
Block a user