diff --git a/src/main/java/com/domain/api/SpotifyController.java b/src/main/java/com/domain/api/SpotifyController.java index 706bdf5..9335a4e 100644 --- a/src/main/java/com/domain/api/SpotifyController.java +++ b/src/main/java/com/domain/api/SpotifyController.java @@ -1,9 +1,11 @@ package com.domain.api; import com.domain.request.SpotifyAddTracksRequest; +import com.domain.request.SpotifyOAuthCallbackRequest; import com.domain.request.SpotifyTokenRequest; import com.domain.request.VibeToSpotifySearchRequest; import com.domain.response.SpotifyAddTracksResponse; +import com.domain.response.SpotifyAuthResponse; import com.domain.response.SpotifyPlaylistResponse; import com.domain.response.VibeToSpotifySearchResponse; import com.domain.service.SpotifyService; @@ -23,28 +25,87 @@ public class SpotifyController { private final SpotifyService spotifyService; + /** - * Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다. + * Spotify OAuth2.0 콜백 처리: 토큰을 저장하고 세션 ID를 반환합니다. * - * @param request Spotify Access Token - * @return Spotify 플레이리스트 목록 + * @param request OAuth 콜백 요청 (토큰 정보 포함) + * @return 인증 응답 (세션 ID 포함) */ - @PostMapping("/playlists") - @Operation(summary = "Spotify 플레이리스트 조회", description = "Spotify Access Token으로 사용자의 플레이리스트를 조회합니다.") - public ResponseEntity getSpotifyPlaylists(@RequestBody SpotifyTokenRequest request) { - log.info("Spotify 플레이리스트 조회 요청"); + @PostMapping("/auth/callback") + @Operation(summary = "Spotify OAuth 콜백 처리", description = "OAuth2.0 인증 후 받은 토큰을 저장하고 세션 ID를 반환합니다.") + public ResponseEntity handleOAuthCallback(@RequestBody SpotifyOAuthCallbackRequest request) { + log.info("Spotify OAuth 콜백 처리 요청"); // 입력 검증 if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { return ResponseEntity.badRequest() - .body(SpotifyPlaylistResponse.builder() + .body(SpotifyAuthResponse.builder() .success(false) .message("Access Token이 필요합니다.") .build()); } + if (request.getRefreshToken() == null || request.getRefreshToken().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(SpotifyAuthResponse.builder() + .success(false) + .message("Refresh Token이 필요합니다.") + .build()); + } + + if (request.getExpiresIn() == null) { + return ResponseEntity.badRequest() + .body(SpotifyAuthResponse.builder() + .success(false) + .message("Expires In이 필요합니다.") + .build()); + } + try { - SpotifyPlaylistResponse response = spotifyService.getUserPlaylists(request.getAccessToken()); + SpotifyAuthResponse response = spotifyService.handleOAuthCallback(request); + + if (response.isSuccess()) { + log.info("Spotify OAuth 콜백 처리 성공 - 세션 ID 생성 완료"); + return ResponseEntity.ok(response); + } else { + log.warn("Spotify OAuth 콜백 처리 실패 - 메시지: {}", response.getMessage()); + return ResponseEntity.status(401).body(response); + } + + } catch (Exception e) { + log.error("Spotify OAuth 콜백 처리 중 예외 발생", e); + return ResponseEntity.internalServerError() + .body(SpotifyAuthResponse.builder() + .success(false) + .message("서버 오류가 발생했습니다: " + e.getMessage()) + .build()); + } + } + + /** + * 세션 ID를 사용하여 사용자의 플레이리스트를 조회합니다. + * + * @param sessionId 세션 ID (헤더) + * @return Spotify 플레이리스트 목록 + */ + @GetMapping("/playlists") + @Operation(summary = "Spotify 플레이리스트 조회", description = "세션 ID로 사용자의 플레이리스트를 조회합니다.") + public ResponseEntity getSpotifyPlaylists( + @RequestHeader("X-Session-Id") String sessionId) { + log.info("Spotify 플레이리스트 조회 요청 - 세션 ID: {}", sessionId); + + // 입력 검증 + if (sessionId == null || sessionId.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(SpotifyPlaylistResponse.builder() + .success(false) + .message("세션 ID가 필요합니다.") + .build()); + } + + try { + SpotifyPlaylistResponse response = spotifyService.getUserPlaylistsWithSession(sessionId); if (response.isSuccess()) { log.info("Spotify 플레이리스트 조회 성공 - 플레이리스트 개수: {}", @@ -66,22 +127,25 @@ public class SpotifyController { } /** - * 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다. + * 세션 ID를 사용하여 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다. * - * @param request 트랙 추가 요청 (access_token, playlist_id, track_uris 등) + * @param sessionId 세션 ID (헤더) + * @param request 트랙 추가 요청 (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()); + public ResponseEntity addTracksToPlaylist( + @RequestHeader("X-Session-Id") String sessionId, + @RequestBody SpotifyAddTracksRequest request) { + log.info("Spotify 트랙 추가 요청 - 플레이리스트 ID: {}, 세션 ID: {}", request.getPlaylistId(), sessionId); // 입력 검증 - if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { + if (sessionId == null || sessionId.trim().isEmpty()) { return ResponseEntity.badRequest() .body(SpotifyAddTracksResponse.builder() .success(false) - .message("Access Token이 필요합니다.") + .message("세션 ID가 필요합니다.") .build()); } @@ -104,7 +168,7 @@ public class SpotifyController { } try { - SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylist(request); + SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylistWithSession(sessionId, request); if (response.isSuccess()) { log.info("Spotify 트랙 추가 성공 - 플레이리스트 ID: {}, 추가된 트랙 수: {}", @@ -126,25 +190,27 @@ public class SpotifyController { } /** - * Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다. + * 세션 ID를 사용하여 Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다. * + * @param sessionId 세션 ID (헤더) * @param request Vibe 트랙 정보 리스트 (가수, 제목) * @return Spotify 검색 결과 (track_uri 포함) */ @PostMapping("/search/vibe-tracks") @Operation(summary = "Vibe 트랙 Spotify 검색", description = "Vibe에서 받은 트랙 정보(가수, 제목)를 Spotify에서 검색하여 track_uri를 반환합니다.") public ResponseEntity searchVibeTracksInSpotify( + @RequestHeader("X-Session-Id") String sessionId, @RequestBody VibeToSpotifySearchRequest request) { - log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}", - request.getTracks() != null ? request.getTracks().size() : 0); + log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}, 세션 ID: {}", + request.getTracks() != null ? request.getTracks().size() : 0, sessionId); // 입력 검증 - if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) { + if (sessionId == null || sessionId.trim().isEmpty()) { return ResponseEntity.badRequest() .body(VibeToSpotifySearchResponse.builder() .success(false) - .message("Access Token이 필요합니다.") + .message("세션 ID가 필요합니다.") .build()); } @@ -157,7 +223,7 @@ public class SpotifyController { } try { - VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibe(request); + VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibeWithSession(sessionId, request); if (response.isSuccess()) { log.info("Vibe 트랙 Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}", diff --git a/src/main/java/com/domain/request/SpotifyOAuthCallbackRequest.java b/src/main/java/com/domain/request/SpotifyOAuthCallbackRequest.java new file mode 100644 index 0000000..1b88bcf --- /dev/null +++ b/src/main/java/com/domain/request/SpotifyOAuthCallbackRequest.java @@ -0,0 +1,39 @@ +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 SpotifyOAuthCallbackRequest { + + /** + * OAuth2.0 인증 후 받은 토큰 정보 + */ + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("scope") + private String scope; + + /** + * 사용자 식별을 위한 암호화된 userId (선택) + * 없으면 Spotify API로 사용자 정보 조회 + */ + @JsonProperty("user_id") + private String userId; +} diff --git a/src/main/java/com/domain/response/SpotifyAuthResponse.java b/src/main/java/com/domain/response/SpotifyAuthResponse.java new file mode 100644 index 0000000..b68690b --- /dev/null +++ b/src/main/java/com/domain/response/SpotifyAuthResponse.java @@ -0,0 +1,37 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SpotifyAuthResponse { + + private boolean success; + private String message; + + /** + * 암호화된 세션 ID (userId 암호화) + */ + @JsonProperty("session_id") + private String sessionId; + + /** + * 토큰 만료 시간 (초) + */ + @JsonProperty("expires_in") + private Integer expiresIn; + + /** + * Spotify 사용자 ID + */ + @JsonProperty("spotify_user_id") + private String spotifyUserId; +} diff --git a/src/main/java/com/domain/service/SpotifyService.java b/src/main/java/com/domain/service/SpotifyService.java index 0af479b..a9fcd1e 100644 --- a/src/main/java/com/domain/service/SpotifyService.java +++ b/src/main/java/com/domain/service/SpotifyService.java @@ -1,8 +1,10 @@ package com.domain.service; import com.domain.request.SpotifyAddTracksRequest; +import com.domain.request.SpotifyOAuthCallbackRequest; import com.domain.request.VibeToSpotifySearchRequest; import com.domain.response.SpotifyAddTracksResponse; +import com.domain.response.SpotifyAuthResponse; import com.domain.response.SpotifyPlaylistResponse; import com.domain.response.VibeToSpotifySearchResponse; import com.fasterxml.jackson.databind.JsonNode; @@ -34,6 +36,8 @@ public class SpotifyService { private final ObjectMapper objectMapper; private final TranslationService translationService; + private final SpotifyTokenService spotifyTokenService; + private final com.global.util.EncryptionUtil encryptionUtil; 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"; @@ -366,8 +370,200 @@ public class SpotifyService { } } + /** + * OAuth2.0 콜백 처리: 토큰을 Redis에 저장하고 암호화된 세션 ID를 반환합니다. + * + * @param request OAuth 콜백 요청 (토큰 정보 포함) + * @return 인증 응답 (세션 ID 포함) + */ + public SpotifyAuthResponse handleOAuthCallback(SpotifyOAuthCallbackRequest request) { + try { + // 1. Spotify API로 사용자 ID 조회 + String spotifyUserId = getUserId(request.getAccessToken()); + if (spotifyUserId == null) { + return SpotifyAuthResponse.builder() + .success(false) + .message("Spotify 사용자 정보 조회 실패") + .build(); + } + + log.info("Spotify OAuth 콜백 처리 - Spotify User ID: {}", spotifyUserId); + + // 2. userId로 사용 (요청에 userId가 있으면 사용, 없으면 spotifyUserId 사용) + String userId = request.getUserId() != null && !request.getUserId().trim().isEmpty() + ? request.getUserId() + : spotifyUserId; + + // 3. Redis에 토큰 저장 + spotifyTokenService.saveToken( + userId, + request.getAccessToken(), + request.getRefreshToken(), + request.getExpiresIn(), + request.getScope() + ); + + // 4. userId를 암호화하여 sessionId 생성 + String sessionId = encryptionUtil.encrypt(userId); + log.info("Spotify 세션 ID 생성 완료 - userId: {}", userId); + + return SpotifyAuthResponse.builder() + .success(true) + .message("인증 성공") + .sessionId(sessionId) + .expiresIn(request.getExpiresIn()) + .spotifyUserId(spotifyUserId) + .build(); + + } catch (Exception e) { + log.error("Spotify OAuth 콜백 처리 중 오류 발생", e); + return SpotifyAuthResponse.builder() + .success(false) + .message("인증 처리 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * 세션 ID(암호화된 userId)를 사용하여 플레이리스트를 조회합니다. + * + * @param sessionId 암호화된 세션 ID + * @return 플레이리스트 목록 응답 + */ + public SpotifyPlaylistResponse getUserPlaylistsWithSession(String sessionId) { + try { + // 1. sessionId 복호화 + String userId; + try { + userId = encryptionUtil.decrypt(sessionId); + log.info("세션 ID 복호화 성공 - userId: {}", userId); + } catch (Exception e) { + log.error("세션 ID 복호화 실패", e); + return SpotifyPlaylistResponse.builder() + .success(false) + .message("유효하지 않은 세션 ID입니다.") + .build(); + } + + // 2. Redis에서 Access Token 조회 + String accessToken = spotifyTokenService.getAccessToken(userId); + if (accessToken == null) { + log.warn("Access Token을 찾을 수 없음 - userId: {}", userId); + return SpotifyPlaylistResponse.builder() + .success(false) + .message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.") + .build(); + } + + // 3. 플레이리스트 조회 + return getUserPlaylists(accessToken); + + } catch (Exception e) { + log.error("Spotify 플레이리스트 조회 중 오류 발생", e); + return SpotifyPlaylistResponse.builder() + .success(false) + .message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * 세션 ID를 사용하여 트랙을 플레이리스트에 추가합니다. + * + * @param sessionId 암호화된 세션 ID + * @param request 트랙 추가 요청 + * @return 트랙 추가 응답 + */ + public SpotifyAddTracksResponse addTracksToPlaylistWithSession(String sessionId, SpotifyAddTracksRequest request) { + try { + // 1. sessionId 복호화 + String userId; + try { + userId = encryptionUtil.decrypt(sessionId); + log.info("세션 ID 복호화 성공 - userId: {}", userId); + } catch (Exception e) { + log.error("세션 ID 복호화 실패", e); + return SpotifyAddTracksResponse.builder() + .success(false) + .message("유효하지 않은 세션 ID입니다.") + .build(); + } + + // 2. Redis에서 Access Token 조회 + String accessToken = spotifyTokenService.getAccessToken(userId); + if (accessToken == null) { + log.warn("Access Token을 찾을 수 없음 - userId: {}", userId); + return SpotifyAddTracksResponse.builder() + .success(false) + .message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.") + .build(); + } + + // 3. request에 accessToken 설정 + request.setAccessToken(accessToken); + + // 4. 트랙 추가 + return addTracksToPlaylist(request); + + } catch (Exception e) { + log.error("Spotify 트랙 추가 중 오류 발생", e); + return SpotifyAddTracksResponse.builder() + .success(false) + .message("트랙 추가 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + + /** + * 세션 ID를 사용하여 Vibe 트랙을 Spotify에서 검색합니다. + * + * @param sessionId 암호화된 세션 ID + * @param request Vibe 트랙 정보 리스트 + * @return Spotify 검색 결과 + */ + public VibeToSpotifySearchResponse searchTracksFromVibeWithSession(String sessionId, VibeToSpotifySearchRequest request) { + try { + // 1. sessionId 복호화 + String userId; + try { + userId = encryptionUtil.decrypt(sessionId); + log.info("세션 ID 복호화 성공 - userId: {}", userId); + } catch (Exception e) { + log.error("세션 ID 복호화 실패", e); + return VibeToSpotifySearchResponse.builder() + .success(false) + .message("유효하지 않은 세션 ID입니다.") + .build(); + } + + // 2. Redis에서 Access Token 조회 + String accessToken = spotifyTokenService.getAccessToken(userId); + if (accessToken == null) { + log.warn("Access Token을 찾을 수 없음 - userId: {}", userId); + return VibeToSpotifySearchResponse.builder() + .success(false) + .message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.") + .build(); + } + + // 3. request에 accessToken 설정 + request.setAccessToken(accessToken); + + // 4. 검색 실행 + return searchTracksFromVibe(request); + + } catch (Exception e) { + log.error("Vibe 트랙 Spotify 검색 중 오류 발생", e); + return VibeToSpotifySearchResponse.builder() + .success(false) + .message("검색 중 오류가 발생했습니다: " + e.getMessage()) + .build(); + } + } + /** * 기존 플레이리스트에 트랙을 추가합니다. + * Spotify API 제한으로 인해 100개씩 나눠서 추가합니다. * * @param accessToken Spotify Access Token * @param playlistId 플레이리스트 ID @@ -375,6 +571,40 @@ public class SpotifyService { * @return 추가된 트랙 개수 */ private int addTracksToExistingPlaylist(String accessToken, String playlistId, List trackUris) { + try { + final int BATCH_SIZE = 100; // Spotify API 제한: 한 번에 최대 100개 + int totalAdded = 0; + + // trackUris를 100개씩 나눠서 처리 + for (int i = 0; i < trackUris.size(); i += BATCH_SIZE) { + int endIndex = Math.min(i + BATCH_SIZE, trackUris.size()); + List batchUris = trackUris.subList(i, endIndex); + + log.info("트랙 추가 중... ({}/{}) - 현재 배치: {} 개", + endIndex, trackUris.size(), batchUris.size()); + + int addedInBatch = addTracksBatch(accessToken, playlistId, batchUris); + totalAdded += addedInBatch; + } + + log.info("트랙 추가 완료 - 총 {} 개", totalAdded); + return totalAdded; + + } catch (Exception e) { + log.error("트랙 추가 중 오류 발생", e); + throw new RuntimeException("트랙 추가 실패", e); + } + } + + /** + * 배치 단위로 트랙을 플레이리스트에 추가합니다. + * + * @param accessToken Spotify Access Token + * @param playlistId 플레이리스트 ID + * @param trackUris 트랙 URI 목록 (최대 100개) + * @return 추가된 트랙 개수 + */ + private int addTracksBatch(String accessToken, String playlistId, List trackUris) { try { String addTracksUrl = String.format(SPOTIFY_ADD_TRACKS_URL, playlistId); @@ -402,21 +632,21 @@ public class SpotifyService { int statusCode = response.getCode(); String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - log.info("Spotify Add Tracks API 응답 코드: {}", statusCode); + log.debug("Spotify Add Tracks API 응답 코드: {}", statusCode); if (statusCode == 201) { - log.info("트랙 추가 성공 - {} 개", trackUris.size()); + log.debug("배치 트랙 추가 성공 - {} 개", trackUris.size()); return trackUris.size(); } else { - log.error("트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody); + log.error("배치 트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody); throw new RuntimeException("트랙 추가 실패: " + responseBody); } } } } catch (Exception e) { - log.error("트랙 추가 중 오류 발생", e); - throw new RuntimeException("트랙 추가 실패", e); + log.error("배치 트랙 추가 중 오류 발생", e); + throw new RuntimeException("배치 트랙 추가 실패", e); } } diff --git a/src/main/java/com/domain/service/SpotifyTokenService.java b/src/main/java/com/domain/service/SpotifyTokenService.java new file mode 100644 index 0000000..e348424 --- /dev/null +++ b/src/main/java/com/domain/service/SpotifyTokenService.java @@ -0,0 +1,135 @@ +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.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpotifyTokenService { + + private final RedisTemplate redisTemplate; + + private static final String SPOTIFY_TOKEN_PREFIX = "spotify:token:"; + + /** + * userId를 키로 Spotify 토큰 정보를 Redis에 저장합니다. + * + * @param userId 사용자 아이디 + * @param accessToken Access Token + * @param refreshToken Refresh Token + * @param expiresIn 만료 시간 (초) + * @param scope 권한 범위 + */ + public void saveToken(String userId, String accessToken, String refreshToken, Integer expiresIn, String scope) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + + Map tokenData = new HashMap<>(); + tokenData.put("access_token", accessToken); + tokenData.put("refresh_token", refreshToken); + tokenData.put("scope", scope); + tokenData.put("expires_in", String.valueOf(expiresIn)); + + redisTemplate.opsForHash().putAll(key, tokenData); + + // 토큰 만료 시간보다 조금 짧게 설정 (안전 마진 60초) + long ttlSeconds = expiresIn > 60 ? expiresIn - 60 : expiresIn; + redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS); + + log.info("Spotify 토큰 저장 완료 - userId: {}, 유효기간: {}초", userId, ttlSeconds); + } + + /** + * userId로 Access Token을 조회합니다. + * + * @param userId 사용자 아이디 + * @return Access Token (없으면 null) + */ + public String getAccessToken(String userId) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + + Object token = redisTemplate.opsForHash().get(key, "access_token"); + if (token == null) { + log.info("Redis에 저장된 Spotify 토큰 없음 - userId: {}", userId); + return null; + } + + log.debug("Spotify Access Token 조회 - userId: {}", userId); + return (String) token; + } + + /** + * userId로 Refresh Token을 조회합니다. + * + * @param userId 사용자 아이디 + * @return Refresh Token (없으면 null) + */ + public String getRefreshToken(String userId) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + + Object token = redisTemplate.opsForHash().get(key, "refresh_token"); + if (token == null) { + log.info("Redis에 저장된 Spotify Refresh Token 없음 - userId: {}", userId); + return null; + } + + return (String) token; + } + + /** + * userId로 모든 토큰 정보를 조회합니다. + * + * @param userId 사용자 아이디 + * @return 토큰 정보 맵 (없으면 null) + */ + @SuppressWarnings("unchecked") + public Map getTokenData(String userId) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + + Map entries = redisTemplate.opsForHash().entries(key); + if (entries.isEmpty()) { + log.info("Redis에 저장된 Spotify 토큰 데이터 없음 - userId: {}", userId); + return null; + } + + log.debug("Spotify 토큰 데이터 조회 - userId: {}", userId); + return (Map) (Map) entries; + } + + /** + * userId의 토큰을 삭제합니다. + * + * @param userId 사용자 아이디 + */ + public void deleteToken(String userId) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + redisTemplate.delete(key); + log.info("Spotify 토큰 삭제 완료 - userId: {}", userId); + } + + /** + * Access Token만 갱신합니다. + * + * @param userId 사용자 아이디 + * @param accessToken 새로운 Access Token + * @param expiresIn 만료 시간 (초) + */ + public void updateAccessToken(String userId, String accessToken, Integer expiresIn) { + String key = SPOTIFY_TOKEN_PREFIX + userId; + + redisTemplate.opsForHash().put(key, "access_token", accessToken); + redisTemplate.opsForHash().put(key, "expires_in", String.valueOf(expiresIn)); + + // TTL 갱신 + long ttlSeconds = expiresIn > 60 ? expiresIn - 60 : expiresIn; + redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS); + + log.info("Spotify Access Token 갱신 완료 - userId: {}, 유효기간: {}초", userId, ttlSeconds); + } +}