diff --git a/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java b/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java index ba9b40a..946651d 100644 --- a/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; import jakarta.validation.ConstraintViolationException; @@ -54,6 +55,16 @@ public ResponseEntity handleConstraintViolationException(Cons return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e) { + String specificError = String.format("%s: 올바르지 않은 값입니다", e.getName()); + + CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError); + log.warn("Type mismatch: {}", specificError); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } + @ExceptionHandler(OptimisticLockingFailureException.class) public ResponseEntity handleOptimisticLockingFailureException( OptimisticLockingFailureException e) { @@ -69,4 +80,4 @@ public ResponseEntity handleGenericException(Exception e) { return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java b/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java index 43d60de..6081b81 100644 --- a/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java +++ b/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java @@ -5,9 +5,11 @@ import com.linglevel.api.word.dto.EssentialWordsStatsResponse; import com.linglevel.api.word.dto.Oxford3000InitResponse; import com.linglevel.api.word.dto.WordSearchResponse; +import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; import com.linglevel.api.word.service.Oxford3000Service; import com.linglevel.api.word.service.WordService; +import com.linglevel.api.word.validator.WordValidator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -31,12 +34,15 @@ @Slf4j @Tag(name = "Words Admin", description = "단어 관리 API (관리자 전용)") @SecurityRequirement(name = "adminApiKey") +@PreAuthorize("hasRole('ADMIN')") public class WordsAdminController { private final WordService wordService; private final Oxford3000Service oxford3000Service; + private final WordValidator wordValidator; + @Operation(summary = "단어 강제 재분석", description = """ 관리자 전용: 단어를 AI로 강제 재분석합니다. @@ -69,7 +75,13 @@ public ResponseEntity forceAnalyzeWord( log.info("Admin force-analyze: word='{}', targetLanguage={}, overwrite={}, deleteVariants={}", word, targetLanguage, overwrite, deleteVariants); - WordSearchResponse response = wordService.forceReanalyzeWord(word, targetLanguage, overwrite, deleteVariants); + if (targetLanguage == LanguageCode.EN) { + throw new WordsException(WordsErrorCode.SAME_SOURCE_TARGET_LANGUAGE); + } + + String validatedWord = wordValidator.validateAndPreprocess(word); + WordSearchResponse response = wordService.forceReanalyzeWord(validatedWord, targetLanguage, overwrite, + deleteVariants); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/linglevel/api/word/controller/WordsController.java b/src/main/java/com/linglevel/api/word/controller/WordsController.java index ebc218e..28e99cc 100644 --- a/src/main/java/com/linglevel/api/word/controller/WordsController.java +++ b/src/main/java/com/linglevel/api/word/controller/WordsController.java @@ -2,10 +2,11 @@ import com.linglevel.api.auth.jwt.JwtClaims; import com.linglevel.api.common.dto.ExceptionResponse; +import com.linglevel.api.common.ratelimit.annotation.RateLimit; +import com.linglevel.api.common.ratelimit.annotation.RateLimit.KeyType; import com.linglevel.api.i18n.LanguageCode; import com.linglevel.api.word.dto.WordSearchRequest; import com.linglevel.api.word.dto.WordSearchResponse; -import com.linglevel.api.word.entity.Word; import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; import com.linglevel.api.word.service.WordService; @@ -21,17 +22,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; -import org.springframework.data.mongodb.core.mapping.Language; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import com.linglevel.api.common.ratelimit.annotation.RateLimit; -import com.linglevel.api.common.ratelimit.annotation.RateLimit.KeyType; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/words") @@ -71,4 +64,4 @@ public ResponseEntity handleWordsException(WordsException e) return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java b/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java index 986cefb..cce8855 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java +++ b/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.linglevel.api.i18n.LanguageCode; +import com.linglevel.api.word.model.Meaning; +import com.linglevel.api.word.model.RelatedForms; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; diff --git a/src/main/java/com/linglevel/api/word/dto/WordResponse.java b/src/main/java/com/linglevel/api/word/dto/WordResponse.java index 16be1c3..e6f87f2 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordResponse.java +++ b/src/main/java/com/linglevel/api/word/dto/WordResponse.java @@ -1,6 +1,8 @@ package com.linglevel.api.word.dto; import com.linglevel.api.i18n.LanguageCode; +import com.linglevel.api.word.model.Meaning; +import com.linglevel.api.word.model.RelatedForms; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -46,4 +48,4 @@ public class WordResponse { @Schema(description = "필수 단어 여부 (예: Oxford 3000)", example = "true") private Boolean isEssential; -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/entity/Word.java b/src/main/java/com/linglevel/api/word/entity/Word.java index 28b2bdf..abbbd93 100644 --- a/src/main/java/com/linglevel/api/word/entity/Word.java +++ b/src/main/java/com/linglevel/api/word/entity/Word.java @@ -1,8 +1,8 @@ package com.linglevel.api.word.entity; import com.linglevel.api.i18n.LanguageCode; -import com.linglevel.api.word.dto.Meaning; -import com.linglevel.api.word.dto.RelatedForms; +import com.linglevel.api.word.model.Meaning; +import com.linglevel.api.word.model.RelatedForms; import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.index.CompoundIndex; diff --git a/src/main/java/com/linglevel/api/word/mapper/WordResponseMapper.java b/src/main/java/com/linglevel/api/word/mapper/WordResponseMapper.java new file mode 100644 index 0000000..66bdfba --- /dev/null +++ b/src/main/java/com/linglevel/api/word/mapper/WordResponseMapper.java @@ -0,0 +1,34 @@ +package com.linglevel.api.word.mapper; + +import com.linglevel.api.word.dto.VariantType; +import com.linglevel.api.word.dto.WordResponse; +import com.linglevel.api.word.dto.WordSearchResponse; +import com.linglevel.api.word.entity.Word; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class WordResponseMapper { + + public WordResponse toWordResponse(Word word, boolean bookmarked, List variantTypes, + String originalForm) { + return WordResponse.builder() + .id(word.getId()) + .originalForm(originalForm) + .variantTypes(variantTypes) + .sourceLanguageCode(word.getSourceLanguageCode()) + .targetLanguageCode(word.getTargetLanguageCode()) + .summary(word.getSummary()) + .meanings(word.getMeanings()) + .relatedForms(word.getRelatedForms()) + .bookmarked(bookmarked) + .isEssential(word.getIsEssential()) + .build(); + } + + public WordSearchResponse toWordSearchResponse(String searchedWord, List results) { + return WordSearchResponse.builder().searchedWord(searchedWord).results(results).build(); + } + +} diff --git a/src/main/java/com/linglevel/api/word/dto/Meaning.java b/src/main/java/com/linglevel/api/word/model/Meaning.java similarity index 92% rename from src/main/java/com/linglevel/api/word/dto/Meaning.java rename to src/main/java/com/linglevel/api/word/model/Meaning.java index 0bef978..e969c3c 100644 --- a/src/main/java/com/linglevel/api/word/dto/Meaning.java +++ b/src/main/java/com/linglevel/api/word/model/Meaning.java @@ -1,5 +1,6 @@ -package com.linglevel.api.word.dto; +package com.linglevel.api.word.model; +import com.linglevel.api.word.dto.PartOfSpeech; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -31,4 +32,4 @@ public class Meaning { @Schema(description = "예문 번역", example = "나는 매일 가게에서 그를 봅니다.") private String exampleTranslation; -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/dto/RelatedForms.java b/src/main/java/com/linglevel/api/word/model/RelatedForms.java similarity index 97% rename from src/main/java/com/linglevel/api/word/dto/RelatedForms.java rename to src/main/java/com/linglevel/api/word/model/RelatedForms.java index 3309a4e..280e33c 100644 --- a/src/main/java/com/linglevel/api/word/dto/RelatedForms.java +++ b/src/main/java/com/linglevel/api/word/model/RelatedForms.java @@ -1,4 +1,4 @@ -package com.linglevel.api.word.dto; +package com.linglevel.api.word.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/linglevel/api/word/service/WordAiService.java b/src/main/java/com/linglevel/api/word/service/WordAiService.java index 14f1d6b..8dfa4fe 100644 --- a/src/main/java/com/linglevel/api/word/service/WordAiService.java +++ b/src/main/java/com/linglevel/api/word/service/WordAiService.java @@ -3,6 +3,7 @@ import com.linglevel.api.word.dto.WordAnalysisResult; import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; +import com.linglevel.api.word.model.Meaning; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; @@ -313,12 +314,12 @@ private WordAnalysisResult[] filterInvalidEnumValues(WordAnalysisResult[] result } // 2. 유효한 PartOfSpeech를 가진 meanings만 필터링 - List originalMeanings = result.getMeanings(); - List validMeanings = new ArrayList<>(); + List originalMeanings = result.getMeanings(); + List validMeanings = new ArrayList<>(); if (originalMeanings != null) { int invalidCount = 0; - for (com.linglevel.api.word.dto.Meaning meaning : originalMeanings) { + for (Meaning meaning : originalMeanings) { if (meaning.getPartOfSpeech() != null) { validMeanings.add(meaning); } @@ -377,7 +378,7 @@ private WordAnalysisResult mergeResults(List results) { .collect(Collectors.toList()); // meanings 병합 (중복 제거 - partOfSpeech와 meaning이 같은 것은 제외) - List mergedMeanings = results.stream() + List mergedMeanings = results.stream() .flatMap(r -> r.getMeanings().stream()) .collect(Collectors.toMap(m -> m.getPartOfSpeech() + ":" + m.getMeaning(), m -> m, (existing, replacement) -> existing)) diff --git a/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java index ed38558..0a5a6f2 100644 --- a/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java +++ b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java @@ -1,12 +1,12 @@ package com.linglevel.api.word.service; import com.linglevel.api.i18n.LanguageCode; -import com.linglevel.api.word.dto.RelatedForms; import com.linglevel.api.word.dto.VariantType; import com.linglevel.api.word.dto.WordAnalysisResult; import com.linglevel.api.word.entity.InvalidWord; import com.linglevel.api.word.entity.Word; import com.linglevel.api.word.entity.WordVariant; +import com.linglevel.api.word.model.RelatedForms; import com.linglevel.api.word.repository.InvalidWordRepository; import com.linglevel.api.word.repository.WordRepository; import com.linglevel.api.word.repository.WordVariantRepository; diff --git a/src/main/java/com/linglevel/api/word/service/WordService.java b/src/main/java/com/linglevel/api/word/service/WordService.java index 4fbacf3..64223dd 100644 --- a/src/main/java/com/linglevel/api/word/service/WordService.java +++ b/src/main/java/com/linglevel/api/word/service/WordService.java @@ -8,6 +8,7 @@ import com.linglevel.api.word.entity.WordVariant; import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; +import com.linglevel.api.word.mapper.WordResponseMapper; import com.linglevel.api.word.repository.InvalidWordRepository; import com.linglevel.api.word.repository.WordRepository; import com.linglevel.api.word.repository.WordVariantRepository; @@ -38,6 +39,8 @@ public class WordService { private final WordPersistenceService wordPersistenceService; + private final WordResponseMapper wordResponseMapper; + public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) { List wordVariants = getOrCreateWordEntities(word, targetLanguage); @@ -63,13 +66,13 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, wordVariant.getOriginalForm()); - WordResponse response = convertToResponse(originalWord, isBookmarked, wordVariant.getVariantTypes(), - wordVariant.getOriginalForm()); + WordResponse response = wordResponseMapper.toWordResponse(originalWord, isBookmarked, + wordVariant.getVariantTypes(), wordVariant.getOriginalForm()); results.add(response); } - return WordSearchResponse.builder().searchedWord(word).results(results).build(); + return wordResponseMapper.toWordSearchResponse(word, results); } public List getOrCreateWordEntities(String word, LanguageCode targetLanguage) { @@ -137,22 +140,6 @@ private List analyzeWordAndUpdateInvalidCache(String word, L } } - private WordResponse convertToResponse(Word word, boolean isBookmarked, List variantTypes, - String originalForm) { - return WordResponse.builder() - .id(word.getId()) - .originalForm(originalForm) - .variantTypes(variantTypes) - .sourceLanguageCode(word.getSourceLanguageCode()) - .targetLanguageCode(word.getTargetLanguageCode()) - .summary(word.getSummary()) - .meanings(word.getMeanings()) // Meaning을 그대로 사용 - .relatedForms(word.getRelatedForms()) - .bookmarked(isBookmarked) - .isEssential(word.getIsEssential()) - .build(); - } - /** * 관리자 전용: 단어를 AI로 강제 재분석 * @param word 재분석할 단어 @@ -195,13 +182,12 @@ public WordSearchResponse forceReanalyzeWord(String word, LanguageCode targetLan .findByWordAndTargetLanguageCode(wordVariant.getOriginalForm(), targetLanguage) .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); - WordResponse response = convertToResponse(originalWord, false, // 어드민 API이므로 - // 북마크 체크하지 않음 + WordResponse response = wordResponseMapper.toWordResponse(originalWord, false, wordVariant.getVariantTypes(), wordVariant.getOriginalForm()); results.add(response); } - return WordSearchResponse.builder().searchedWord(word).results(results).build(); + return wordResponseMapper.toWordSearchResponse(word, results); } } diff --git a/src/test/java/com/linglevel/api/common/handler/GlobalExceptionHandlerTest.java b/src/test/java/com/linglevel/api/common/handler/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..2046879 --- /dev/null +++ b/src/test/java/com/linglevel/api/common/handler/GlobalExceptionHandlerTest.java @@ -0,0 +1,31 @@ +package com.linglevel.api.common.handler; + +import com.linglevel.api.common.dto.ExceptionResponse; +import com.linglevel.api.i18n.LanguageCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler globalExceptionHandler = new GlobalExceptionHandler(); + + @Test + @DisplayName("요청 파라미터 타입 변환 실패는 400 Bad Request로 응답") + void handleMethodArgumentTypeMismatchException_returnsBadRequest() { + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException("INVALID", + LanguageCode.class, "targetLanguage", null, null); + + ResponseEntity response = globalExceptionHandler + .handleMethodArgumentTypeMismatchException(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("targetLanguage: 올바르지 않은 값입니다"); + } + +} diff --git a/src/test/java/com/linglevel/api/word/controller/WordsAdminControllerTest.java b/src/test/java/com/linglevel/api/word/controller/WordsAdminControllerTest.java new file mode 100644 index 0000000..0a14044 --- /dev/null +++ b/src/test/java/com/linglevel/api/word/controller/WordsAdminControllerTest.java @@ -0,0 +1,68 @@ +package com.linglevel.api.word.controller; + +import com.linglevel.api.i18n.LanguageCode; +import com.linglevel.api.word.dto.WordSearchResponse; +import com.linglevel.api.word.exception.WordsErrorCode; +import com.linglevel.api.word.exception.WordsException; +import com.linglevel.api.word.service.Oxford3000Service; +import com.linglevel.api.word.service.WordService; +import com.linglevel.api.word.validator.WordValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WordsAdminControllerTest { + + @Mock + private WordService wordService; + + @Mock + private Oxford3000Service oxford3000Service; + + private WordsAdminController wordsAdminController; + + @BeforeEach + void setUp() { + wordsAdminController = new WordsAdminController(wordService, oxford3000Service, new WordValidator()); + } + + @Test + @DisplayName("강제 재분석 단어는 일반 조회 API와 동일하게 정규화 후 서비스로 전달") + void forceAnalyzeWord_normalizesWordBeforeServiceCall() { + WordSearchResponse expectedResponse = WordSearchResponse.builder() + .searchedWord("run") + .results(List.of()) + .build(); + when(wordService.forceReanalyzeWord("run", LanguageCode.KO, true, false)).thenReturn(expectedResponse); + + ResponseEntity response = wordsAdminController.forceAnalyzeWord("Run!", LanguageCode.KO, + true, false); + + assertThat(response.getBody()).isSameAs(expectedResponse); + verify(wordService).forceReanalyzeWord("run", LanguageCode.KO, true, false); + } + + @Test + @DisplayName("강제 재분석 대상 언어가 영어면 서비스 호출 전 차단") + void forceAnalyzeWord_rejectsEnglishTargetLanguage() { + assertThatThrownBy(() -> wordsAdminController.forceAnalyzeWord("run", LanguageCode.EN, false, false)) + .isInstanceOfSatisfying(WordsException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(WordsErrorCode.SAME_SOURCE_TARGET_LANGUAGE)); + + verify(wordService, never()).forceReanalyzeWord("run", LanguageCode.EN, false, false); + } + +} diff --git a/src/test/java/com/linglevel/api/word/service/WordServiceTest.java b/src/test/java/com/linglevel/api/word/service/WordServiceTest.java index bf1769d..ba86de6 100644 --- a/src/test/java/com/linglevel/api/word/service/WordServiceTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordServiceTest.java @@ -8,6 +8,9 @@ import com.linglevel.api.word.entity.WordVariant; import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; +import com.linglevel.api.word.mapper.WordResponseMapper; +import com.linglevel.api.word.model.Meaning; +import com.linglevel.api.word.model.RelatedForms; import com.linglevel.api.word.repository.InvalidWordRepository; import com.linglevel.api.word.repository.WordRepository; import com.linglevel.api.word.repository.WordVariantRepository; @@ -57,6 +60,8 @@ class WordServiceTest { private WordPersistenceService wordPersistenceService; + private WordResponseMapper wordResponseMapper; + private Word sampleWord; private String userId = "test-user-123"; @@ -65,8 +70,10 @@ class WordServiceTest { void setUp() { wordPersistenceService = new WordPersistenceService(wordRepository, wordVariantRepository, invalidWordRepository); + wordResponseMapper = new WordResponseMapper(); wordService = new WordService(wordRepository, wordBookmarkRepository, wordVariantRepository, - invalidWordRepository, wordAiService, singleFlightCoordinator, wordPersistenceService); + invalidWordRepository, wordAiService, singleFlightCoordinator, wordPersistenceService, + wordResponseMapper); lenient().when(singleFlightCoordinator.execute(anyString(), any(LanguageCode.class), any(), any())) .thenAnswer(invocation -> {