fix(comment-cards): YouTube API 오류를 reason별로 구분 처리
403을 무조건 '댓글 비활성화'로 표시하던 문제 수정. error.errors[0].reason을 파싱해 commentsDisabled/quotaExceeded/기타(keyInvalid 등)를 각각 안내하고, 비-403 오류가 opaque 500으로 새던 것도 의미있는 메시지로 노출. 단위 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0ee343ceca
commit
6f59554b6d
@ -1,12 +1,13 @@
|
|||||||
package com.hlab.yanalyst.service;
|
package com.hlab.yanalyst.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
||||||
import com.hlab.yanalyst.web.dto.CommentCardDto;
|
import com.hlab.yanalyst.web.dto.CommentCardDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpClientErrorException;
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
@ -30,6 +31,8 @@ public class YoutubeCommentService {
|
|||||||
/** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */
|
/** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */
|
||||||
private static final int MAX_PAGES = 5;
|
private static final int MAX_PAGES = 5;
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
public List<CommentCardDto> fetchComments(String urlOrId) {
|
public List<CommentCardDto> fetchComments(String urlOrId) {
|
||||||
String videoId = YoutubeVideoIdParser.parse(urlOrId);
|
String videoId = YoutubeVideoIdParser.parse(urlOrId);
|
||||||
if (videoId == null) {
|
if (videoId == null) {
|
||||||
@ -56,11 +59,10 @@ public class YoutubeCommentService {
|
|||||||
JsonNode root;
|
JsonNode root;
|
||||||
try {
|
try {
|
||||||
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class);
|
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class);
|
||||||
} catch (HttpClientErrorException.Forbidden e) {
|
} catch (RestClientResponseException e) {
|
||||||
if (result.isEmpty()) {
|
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
|
||||||
throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다.");
|
if (!result.isEmpty()) break;
|
||||||
}
|
throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString())));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root == null || !root.has("items")) break;
|
if (root == null || !root.has("items")) break;
|
||||||
@ -84,4 +86,30 @@ public class YoutubeCommentService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */
|
||||||
|
private static String extractReason(String body) {
|
||||||
|
if (body == null || body.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode errs = MAPPER.readTree(body).path("error").path("errors");
|
||||||
|
if (errs.isArray() && errs.size() > 0) {
|
||||||
|
String reason = errs.get(0).path("reason").asText(null);
|
||||||
|
if (reason != null && !reason.isBlank()) return reason;
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// 본문이 JSON이 아니면 reason 없음
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YouTube 오류 reason → 사용자용 안내 메시지. */
|
||||||
|
private static String messageFor(String reason) {
|
||||||
|
if ("commentsDisabled".equals(reason)) {
|
||||||
|
return "이 영상은 댓글이 비활성화되어 있습니다.";
|
||||||
|
}
|
||||||
|
if ("quotaExceeded".equals(reason) || "dailyLimitExceeded".equals(reason) || "rateLimitExceeded".equals(reason)) {
|
||||||
|
return "YouTube API 일일 쿼터를 초과했습니다. 잠시 후 다시 시도해주세요.";
|
||||||
|
}
|
||||||
|
return "YouTube API 호출에 실패했습니다" + (reason != null ? " (" + reason + ")" : "") + ".";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.hlab.yanalyst.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class YoutubeCommentServiceTest {
|
||||||
|
|
||||||
|
private YoutubeCommentService newService(RestTemplate rt) {
|
||||||
|
YoutubeQuotaGuard guard = mock(YoutubeQuotaGuard.class);
|
||||||
|
when(guard.tryConsume(anyLong())).thenReturn(true);
|
||||||
|
YoutubeCommentService svc = new YoutubeCommentService(rt, guard);
|
||||||
|
ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY");
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClientErrorException apiError(HttpStatus status, String reason) {
|
||||||
|
String body = "{\"error\":{\"code\":" + status.value()
|
||||||
|
+ ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}";
|
||||||
|
return HttpClientErrorException.create(status, status.getReasonPhrase(),
|
||||||
|
HttpHeaders.EMPTY, body.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentsDisabled_throwsDisabledMessage() {
|
||||||
|
RestTemplate rt = mock(RestTemplate.class);
|
||||||
|
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
|
||||||
|
.thenThrow(apiError(HttpStatus.FORBIDDEN, "commentsDisabled"));
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
|
||||||
|
assertTrue(ex.getMessage().contains("비활성화"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quotaExceeded_throwsQuotaMessage_notDisabled() {
|
||||||
|
RestTemplate rt = mock(RestTemplate.class);
|
||||||
|
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
|
||||||
|
.thenThrow(apiError(HttpStatus.FORBIDDEN, "quotaExceeded"));
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
|
||||||
|
assertTrue(ex.getMessage().contains("쿼터"), ex.getMessage());
|
||||||
|
assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidKey_throwsMeaningfulMessage_notDisabled() {
|
||||||
|
RestTemplate rt = mock(RestTemplate.class);
|
||||||
|
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
|
||||||
|
.thenThrow(apiError(HttpStatus.BAD_REQUEST, "keyInvalid"));
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
|
||||||
|
assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage());
|
||||||
|
assertTrue(ex.getMessage().contains("keyInvalid"), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user