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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
||||
import com.hlab.yanalyst.web.dto.CommentCardDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.util.UriComponentsBuilder;
|
||||
|
||||
@ -30,6 +31,8 @@ public class YoutubeCommentService {
|
||||
/** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */
|
||||
private static final int MAX_PAGES = 5;
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public List<CommentCardDto> fetchComments(String urlOrId) {
|
||||
String videoId = YoutubeVideoIdParser.parse(urlOrId);
|
||||
if (videoId == null) {
|
||||
@ -56,11 +59,10 @@ public class YoutubeCommentService {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class);
|
||||
} catch (HttpClientErrorException.Forbidden e) {
|
||||
if (result.isEmpty()) {
|
||||
throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다.");
|
||||
}
|
||||
break;
|
||||
} catch (RestClientResponseException e) {
|
||||
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
|
||||
if (!result.isEmpty()) break;
|
||||
throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString())));
|
||||
}
|
||||
|
||||
if (root == null || !root.has("items")) break;
|
||||
@ -84,4 +86,30 @@ public class YoutubeCommentService {
|
||||
|
||||
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