Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ public enum ErrorCode {
SMS_TOO_MANY_ATTEMPTS(HttpStatus.TOO_MANY_REQUESTS, "인증 시도 횟수를 초과하였습니다. 잠시 후 다시 시도해주세요.", "SMS-006"),

// RECIPE
AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI API rate limit exceeded", "RECIPE-25"),
AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI API rate limit exceeded(토큰 부족)", "RECIPE-25"),
USER_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "USER AI 사용 exceeded(1분 후 다시 시도)", "RECIPE-26"),

// ==============================
// 500 INTERNAL_SERVER_ERROR
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.cookeep.cookeep.domain.recipe.application;

import com.cookeep.cookeep.common.exception.AppException;
import com.cookeep.cookeep.common.exception.ErrorCode;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class AiRateLimitService {

// 유저 요청 기록 (아이디로 구분)
private final Map<Long, List<Long>> requestLogs = new ConcurrentHashMap<>();

// 제한 설정
private static final int LIMIT = 3; // 3회/m
private static final long WINDOW = 60_000; // 1분

public void validate(Long userId) {
long now = System.currentTimeMillis();

requestLogs.putIfAbsent(userId, new ArrayList<>());
List<Long> logs = requestLogs.get(userId);

// 오래된 요청 제거 (1분 이전)
logs.removeIf(time -> now - time > WINDOW);

// 제한 초과
if (logs.size() >= LIMIT) {
throw new AppException(ErrorCode.USER_RATE_LIMIT_EXCEEDED);
}

// 현재 요청 기록
logs.add(now);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public class AiRecipeService {
private final ConsumptionReportService consumptionReportService;
private final DailyRecipeRepository dailyRecipeRepository;
private final WeeklyGoalService weeklyGoalService;
private final AiRateLimitService rateLimitService;

// sessionId 유무에 따라 신규/재요청 로직 분기
public AiRecipeResponseDto generateRecipe(Long userId, AiRecipeRequestDto request) {
Expand Down Expand Up @@ -130,6 +131,9 @@ private AiRecipeResponseDto generateInitialRecipe(Long userId, AiRecipeRequestDt
// 메시지 db에 저장
saveInitialUserMessage(session, request);

// RateLimit 검증
rateLimitService.validate(userId);

// 3. AI 레시피 생성 (이름 + 단위만 전달, AI가 quantity 생성)
GeminiRecipeResponseDto aiResponse = geminiService.generateRecipe(
enrichedIngredients,
Expand Down Expand Up @@ -190,6 +194,9 @@ public AiRecipeResponseDto regenerateRecipe(Long userId, Long sessionId) {
// 4. 재요청 메시지 저장 (role=USER, RETRY_REQUEST)
saveSimpleUserMessage(session, MessageType.RETRY_REQUEST);

// RateLimit 검증
rateLimitService.validate(userId);

// 5. AI 호출 (제외 리스트 포함)
GeminiRecipeResponseDto aiResponse = geminiService.generateRecipeWithExclusion(
ingredients,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.cookeep.cookeep.api.dto.response.AiRecipeAdoptResponseDto;
import com.cookeep.cookeep.common.exception.AppException;
import com.cookeep.cookeep.common.exception.ErrorCode;
import com.cookeep.cookeep.domain.cookie.application.CookieService;
import com.cookeep.cookeep.domain.dailyrecipe.dao.DailyRecipeRepository;
import com.cookeep.cookeep.domain.ingredient.common.domain.Storage;
Expand Down Expand Up @@ -37,6 +38,7 @@
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willDoNothing;
Expand All @@ -58,6 +60,7 @@ public class AiRecipeServiceTest {
@Mock private ConsumptionReportService consumptionReportService;
@Mock private DailyRecipeRepository dailyRecipeRepository;
@Mock private WeeklyGoalService weeklyGoalService;
@Mock private AiRateLimitService rateLimitService;

@Spy
private ObjectMapper objectMapper = new ObjectMapper();
Expand Down Expand Up @@ -126,6 +129,8 @@ void setUp() throws Exception {
given(weeklyGoalService.handleGoalProgress(anyLong(), any())).willReturn(false);
// 기본값: 쿠키 지급 false
given(cookieService.grantDailyCookie(anyLong(), any())).willReturn(false);
// 기본값: Rate Limit 통과
willDoNothing().given(rateLimitService).validate(anyLong());
}

// 유통기한 임박(leftDays=0) 재료 생성 헬퍼
Expand Down Expand Up @@ -315,4 +320,174 @@ class BothGoals {
}
}

@Nested
@DisplayName("Rate Limit - AiRateLimitService 연동")
class RateLimitIntegration {

// regenerateRecipe 테스트에 필요한 세션 세팅 헬퍼
private void stubRegenerateSession() throws Exception {
// userIngredientIds에 실제 JSON을 넣어야 readIngredientsFromSession이 동작
String ingredientsJson = """
[{"ingredientId":1,"name":"양파","quantity":null,"unit":"개"}]
""";
session = AiSession.builder()
.userId(1L)
.difficulty(Difficulty.EASY)
.attemptNumber(1)
.isCompleted(false)
.userIngredientIds(ingredientsJson)
.ingredientIdsJson("[1]")
.build();

given(aiSessionRepository.findByIdAndUserId(anyLong(), eq(1L)))
.willReturn(Optional.of(session));
given(aiSessionRepository.save(any(AiSession.class))).willAnswer(inv -> inv.getArgument(0));
given(aiMessageRepository.findAllBySessionIdAndRoleAi(anyLong())).willReturn(List.of());
}

private void stubGeminiAndYoutube() {
// GeminiRecipeResponseDto 스텁
var ingredientsDto = new com.cookeep.cookeep.domain.recipe.dto.GeminiRecipeResponseDto();
// ObjectMapper로 직접 만들기 어려우므로 geminiService 자체를 스텁
com.cookeep.cookeep.domain.recipe.dto.GeminiRecipeResponseDto response =
buildValidGeminiResponse();

given(geminiService.generateRecipeWithExclusion(anyList(), any(), anyList()))
.willReturn(response);
given(youtubeSearchService.searchVideos(anyList())).willReturn(List.of());
given(aiMessageRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
given(aiSessionRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
}

// 공통 헬퍼

/**
* 리플렉션 없이 GeminiRecipeResponseDto를 ObjectMapper로 생성.
* VALID_AI_RESPONSE_JSON을 재활용합니다.
*/
private com.cookeep.cookeep.domain.recipe.dto.GeminiRecipeResponseDto buildValidGeminiResponse() {
try {
return objectMapper.readValue(
VALID_AI_RESPONSE_JSON,
com.cookeep.cookeep.domain.recipe.dto.GeminiRecipeResponseDto.class
);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

// 검증: rateLimitService.validate()가 호출되는지 확인

@Test
@DisplayName("regenerateRecipe 호출 시 rateLimitService.validate(userId)가 1회 호출된다")
void regenerate_호출시_validate_1회() throws Exception {
stubRegenerateSession();
stubGeminiAndYoutube();

aiRecipeService.regenerateRecipe(1L, 10L);

verify(rateLimitService, times(1)).validate(1L);
}

@Test
@DisplayName("regenerateRecipe에서 Rate Limit 초과 시 AI가 호출되지 않는다")
void regenerate_RateLimit_초과시_Gemini_미호출() throws Exception {
stubRegenerateSession();

// validate가 예외를 던지도록 설정
doThrow(new AppException(ErrorCode.USER_RATE_LIMIT_EXCEEDED))
.when(rateLimitService).validate(1L);

assertThatThrownBy(() -> aiRecipeService.regenerateRecipe(1L, 10L))
.isInstanceOf(AppException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_RATE_LIMIT_EXCEEDED);

// Rate Limit에 걸렸으므로 Gemini는 절대 호출되면 안 됨
verify(geminiService, never()).generateRecipeWithExclusion(anyList(), any(), anyList());
}

@Test
@DisplayName("regenerateRecipe Rate Limit 초과 시 AI_RATE_LIMIT_EXCEEDED 에러코드를 반환한다")
void regenerate_RateLimit_에러코드_확인() throws Exception {
stubRegenerateSession();

doThrow(new AppException(ErrorCode.USER_RATE_LIMIT_EXCEEDED))
.when(rateLimitService).validate(1L);

assertThatThrownBy(() -> aiRecipeService.regenerateRecipe(1L, 10L))
.isInstanceOf(AppException.class)
.satisfies(ex -> {
AppException appEx = (AppException) ex;
assertThat(appEx.getErrorCode()).isEqualTo(ErrorCode.USER_RATE_LIMIT_EXCEEDED);
});
}

@Test
@DisplayName("이미 완료된 세션이면 Rate Limit 검증 전에 예외가 발생한다")
void 완료된세션_RateLimit_미호출() throws Exception {
stubRegenerateSession();
session.complete(); // isCompleted = true

// 완료된 세션이므로 SESSION_ALREADY_COMPLETED 예외 발생
assertThatThrownBy(() -> aiRecipeService.regenerateRecipe(1L, 10L))
.isInstanceOf(AppException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.SESSION_ALREADY_COMPLETED);

// Rate Limit 검증까지 도달하지 않으므로 validate 미호출
verify(rateLimitService, never()).validate(anyLong());
}

@Test
@DisplayName("재시도 횟수 초과 시 Rate Limit 검증 전에 예외가 발생한다")
void 재시도횟수_초과_RateLimit_미호출() throws Exception {
stubRegenerateSession();

// attemptNumber를 MAX_RETRY_COUNT(5) 이상으로 설정
session = AiSession.builder()
.userId(1L)
.difficulty(Difficulty.EASY)
.attemptNumber(5) // MAX_RETRY_COUNT = 5
.isCompleted(false)
.userIngredientIds("[{\"ingredientId\":1,\"name\":\"양파\",\"quantity\":null,\"unit\":\"개\"}]")
.ingredientIdsJson("[1]")
.build();
given(aiSessionRepository.findByIdAndUserId(anyLong(), eq(1L)))
.willReturn(Optional.of(session));

assertThatThrownBy(() -> aiRecipeService.regenerateRecipe(1L, 10L))
.isInstanceOf(AppException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.AI_RECIPE_CHANGE_LIMIT_EXCEEDED);

verify(rateLimitService, never()).validate(anyLong());
}

@Test
@DisplayName("서로 다른 유저는 Rate Limit이 독립적으로 적용된다")
void 서로다른_유저_RateLimit_독립() throws Exception {
stubRegenerateSession();
stubGeminiAndYoutube();

// 유저2 세션 추가 세팅
AiSession session2 = AiSession.builder()
.userId(2L)
.difficulty(Difficulty.EASY)
.attemptNumber(1)
.isCompleted(false)
.userIngredientIds("[{\"ingredientId\":1,\"name\":\"양파\",\"quantity\":null,\"unit\":\"개\"}]")
.ingredientIdsJson("[1]")
.build();
given(aiSessionRepository.findByIdAndUserId(anyLong(), eq(2L)))
.willReturn(Optional.of(session2));

// 유저1: 통과
// 유저2: 통과
aiRecipeService.regenerateRecipe(1L, 10L);
aiRecipeService.regenerateRecipe(2L, 10L);

// 각 유저에 대해 독립적으로 validate 1회씩 호출됨
verify(rateLimitService, times(1)).validate(1L);
verify(rateLimitService, times(1)).validate(2L);
}
}

}