diff --git a/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java b/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java index 2b9d90a0..d702cabc 100644 --- a/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java @@ -15,5 +15,4 @@ public interface RefreshTokenRepository extends MongoRepository getBookmarkedWords(String userId, int page, } } - @Transactional public void addWordBookmark(String userId, String wordStr) { var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); - - // 첫 번째 원형 사용 (대부분의 경우 하나만 반환됨) - String originalForm = wordSearchResponse.getResults().get(0).getOriginalForm(); + String originalForm = resolveFirstOriginalForm(wordSearchResponse); if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { throw new BookmarksException(BookmarksErrorCode.WORD_ALREADY_BOOKMARKED); @@ -79,29 +74,16 @@ public void addWordBookmark(String userId, String wordStr) { log.info("Bookmark added: userId={}, word={}", userId, originalForm); } - @Transactional public void removeWordBookmark(String userId, String wordStr) { - // 단어의 원형 찾기 (언어 중립적) - String originalForm = wordVariantService.getOriginalForm(wordStr); - - if (!wordVariantService.exists(originalForm)) { - throw new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND); - } - - if (!wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { - throw new BookmarksException(BookmarksErrorCode.WORD_BOOKMARK_NOT_FOUND); - } + List originalForms = wordVariantService.getOriginalForms(wordStr); + String bookmarkedWord = resolveBookmarkedWord(userId, wordStr, originalForms); - wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); + wordBookmarkRepository.deleteByUserIdAndWord(userId, bookmarkedWord); } - @Transactional public boolean toggleWordBookmark(String userId, String wordStr) { var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); - - // 첫 번째 원형 사용 (대부분의 경우 하나만 반환됨) - String originalForm = wordSearchResponse.getResults().get(0).getOriginalForm(); - + String originalForm = resolveFirstOriginalForm(wordSearchResponse); boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm); if (isBookmarked) { @@ -109,20 +91,19 @@ public boolean toggleWordBookmark(String userId, String wordStr) { wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); log.info("Bookmark removed: userId={}, word={}", userId, originalForm); return false; - } else { - // 북마크 추가 - WordBookmark bookmark = WordBookmark.builder() - .userId(userId) - .word(originalForm) - .bookmarkedAt(LocalDateTime.now()) - .build(); - wordBookmarkRepository.save(bookmark); - log.info("Bookmark added: userId={}, word={}", userId, originalForm); - return true; } + + // 북마크 추가 + WordBookmark bookmark = WordBookmark.builder() + .userId(userId) + .word(originalForm) + .bookmarkedAt(LocalDateTime.now()) + .build(); + wordBookmarkRepository.save(bookmark); + log.info("Bookmark added: userId={}, word={}", userId, originalForm); + return true; } - @Transactional public boolean toggleWordBookmarkById(String userId, String wordId) { Word word = wordRepository.findById(wordId) .orElseThrow(() -> new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND)); @@ -159,4 +140,34 @@ private Page convertToBookmarkedWordResponseDirect(Page< return new PageImpl<>(responses, bookmarks.getPageable(), bookmarks.getTotalElements()); } -} \ No newline at end of file + + private String resolveFirstOriginalForm(WordSearchResponse wordSearchResponse) { + List originalForms = extractDistinctOriginalForms(wordSearchResponse); + if (originalForms.isEmpty()) { + throw new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND); + } + + return originalForms.get(0); + } + + private String resolveBookmarkedWord(String userId, String wordStr, List originalForms) { + if (wordBookmarkRepository.existsByUserIdAndWord(userId, wordStr)) { + return wordStr; + } + + for (String originalForm : originalForms) { + if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { + return originalForm; + } + } + + throw new BookmarksException(BookmarksErrorCode.WORD_BOOKMARK_NOT_FOUND); + } + + private List extractDistinctOriginalForms(WordSearchResponse wordSearchResponse) { + return wordSearchResponse.getResults().stream() + .map(result -> result.getOriginalForm()) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java index 64becff9..08948d3d 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java @@ -1,23 +1,7 @@ package com.linglevel.api.content.article.repository; import com.linglevel.api.content.article.entity.Article; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; - -import java.util.List; public interface ArticleRepository extends MongoRepository, ArticleRepositoryCustom { - - // 제목 또는 작가로 키워드 검색 - @Query("{'$or': [{'title': {'$regex': ?0, '$options': 'i'}}, {'author': {'$regex': ?0, '$options': 'i'}}]}") - Page
findByTitleOrAuthorContaining(String keyword, Pageable pageable); - - // 태그와 키워드 모두 적용 - @Query("{'$and': [{'tags': {'$in': ?0}}, {'$or': [{'title': {'$regex': ?1, '$options': 'i'}}, {'author': {'$regex': ?1, '$options': 'i'}}]}]}") - Page
findByTagsInAndTitleOrAuthorContaining(List tags, String keyword, Pageable pageable); - - // 태그 목록으로 필터링 - Page
findByTagsIn(List tags, Pageable pageable); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java b/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java index a042d308..3ea117bb 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java +++ b/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java @@ -13,8 +13,6 @@ import java.util.Optional; public interface ChunkRepository extends MongoRepository { - Page findByChapterId(String chapterId, Pageable pageable); - Page findByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel, Pageable pageable); Optional findFirstByChapterIdOrderByChunkNumberAsc(String chapterId); @@ -58,4 +56,4 @@ public interface ChunkRepository extends MongoRepository { }) List findChunkCountsByChapterIds(List chapterIds); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java index ff41db8d..846e8542 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java @@ -29,10 +29,8 @@ Page findByCustomContentIdAndIsDeletedFalseOrderByChapterNum Optional findByIdAndCustomContentIdAndIsDeletedFalse(String id, String customContentId); - long countByCustomContentIdAndIsDeletedFalse(String customContentId); - Optional findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId); // V2 Progress: Count chunks by difficulty level long countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(String customContentId, DifficultyLevel difficultyLevel); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java index a4fb758c..0ff13c1f 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java @@ -13,11 +13,7 @@ public interface CustomContentRepository extends MongoRepository findByUserIdAndIsDeletedFalse(String userId, Pageable pageable); - Optional findByIdAndUserIdAndIsDeletedFalse(String id, String userId); - Optional findByIdAndIsDeletedFalse(String id); - Optional findByContentRequestIdAndIsDeletedFalse(String contentRequestId); - Optional findByOriginUrlAndIsDeletedFalse(String originUrl); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java index 86a5566f..4f5ca62a 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java @@ -18,11 +18,5 @@ public interface UserCustomContentRepository extends MongoRepository findByUserId(String userId); - long countByCustomContentId(String customContentId); - - Optional findByUserIdAndContentRequestId(String userId, String contentRequestId); - - Optional findByContentRequestId(String contentRequestId); - boolean existsByUserIdAndCustomContentId(String userId, String customContentId); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java b/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java index f60cd06b..e2d8923c 100644 --- a/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java +++ b/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java @@ -15,17 +15,6 @@ public interface UserStudyReportRepository extends MongoRepository findByCurrentStreakGreaterThan(int currentStreak); - /** - * 최적 타이밍 알림을 위한 사용자 조회 - * lastLearningTimestamp가 특정 시간 범위 내에 있고, 활성 스트릭을 가진 사용자를 찾습니다. - * - * @param startTime 시작 시간 (23시간 전) - * @param endTime 종료 시간 (24시간 전) - * @return 해당 조건을 만족하는 사용자 리포트 목록 - */ - @Query("{ 'lastLearningTimestamp': { $gte: ?0, $lt: ?1 }, 'currentStreak': { $gt: 0 } }") - List findUsersForOptimalTimingReminder(Instant startTime, Instant endTime); - /** * 이탈 유저 복귀 알림을 위한 사용자 조회 (currentStreak = 0) * 마지막 학습 시간이 특정 범위 내에 있는 이탈 유저를 찾습니다. diff --git a/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java b/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java index 11670b07..c4a7be3a 100644 --- a/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java +++ b/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java @@ -16,9 +16,7 @@ public interface TicketTransactionRepository extends MongoRepository findByUserIdAndStatusOrderByCreatedAtDesc(String userId, TransactionStatus status, Pageable pageable); - List findByReservationId(String reservationId); - Optional findByReservationIdAndStatus(String reservationId, TransactionStatus status); List findByUserIdAndAmountAndCreatedAtBetween(String userId, Integer amount, LocalDateTime startDateTime, LocalDateTime endDateTime); -} \ 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 91947af6..f0ee8848 100644 --- a/src/main/java/com/linglevel/api/word/entity/Word.java +++ b/src/main/java/com/linglevel/api/word/entity/Word.java @@ -26,13 +26,9 @@ @Document(collection = "words") @CompoundIndexes({ @CompoundIndex( - name = "word_language_pair_idx", - def = "{'word': 1, 'sourceLanguageCode': 1, 'targetLanguageCode': 1}", + name = "word_target_source_language_unique_idx", + def = "{'word': 1, 'targetLanguageCode': 1, 'sourceLanguageCode': 1}", unique = true - ), - @CompoundIndex( - name = "word_target_language_idx", - def = "{'word': 1, 'targetLanguageCode': 1}" ) }) public class Word { @@ -41,7 +37,7 @@ public class Word { /** * 원형 단어 (예: "pretty", "see", "run") - * 복합 unique index의 일부 (word + sourceLanguageCode + targetLanguageCode) + * 복합 unique index의 일부 (word + targetLanguageCode + sourceLanguageCode) */ private String word; @@ -73,4 +69,4 @@ public class Word { @Builder.Default private Boolean isEssential = false; -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/entity/WordVariant.java b/src/main/java/com/linglevel/api/word/entity/WordVariant.java index adbdcd67..b84babd8 100644 --- a/src/main/java/com/linglevel/api/word/entity/WordVariant.java +++ b/src/main/java/com/linglevel/api/word/entity/WordVariant.java @@ -4,7 +4,6 @@ import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.index.CompoundIndex; -import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import java.util.List; @@ -20,10 +19,9 @@ public class WordVariant { @Id private String id; - @Indexed private String word; private String originalForm; private List variantTypes; -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java b/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java index 774a5061..172562d4 100644 --- a/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java +++ b/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java @@ -12,6 +12,7 @@ public enum WordsErrorCode { WORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "Word already exists."), WORD_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "Word not found with id."), WORD_ANALYSIS_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis is temporarily delayed. Please try again."), + WORD_ANALYSIS_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis failed. Please try again."), INVALID_WORD_FORMAT(HttpStatus.BAD_REQUEST, "Word contains invalid characters (spaces, tabs, newlines, or special characters are not allowed)."), WORD_TOO_LONG(HttpStatus.BAD_REQUEST, "Word is too long (maximum 50 characters)."), SAME_SOURCE_TARGET_LANGUAGE(HttpStatus.BAD_REQUEST, "Source and target languages cannot be the same."); diff --git a/src/main/java/com/linglevel/api/word/exception/WordsException.java b/src/main/java/com/linglevel/api/word/exception/WordsException.java index 076eb1ee..f322abf0 100644 --- a/src/main/java/com/linglevel/api/word/exception/WordsException.java +++ b/src/main/java/com/linglevel/api/word/exception/WordsException.java @@ -13,4 +13,10 @@ public WordsException(WordsErrorCode errorCode) { this.errorCode = errorCode; this.status = errorCode.getStatus(); } + + public WordsException(WordsErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } } diff --git a/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java b/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java index b9d59250..64f31d61 100644 --- a/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java @@ -10,6 +10,4 @@ public interface InvalidWordRepository extends MongoRepository { Optional findByWord(String word); - - boolean existsByWord(String word); } diff --git a/src/main/java/com/linglevel/api/word/repository/WordRepository.java b/src/main/java/com/linglevel/api/word/repository/WordRepository.java index 7d68eca1..3038f581 100644 --- a/src/main/java/com/linglevel/api/word/repository/WordRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/WordRepository.java @@ -35,8 +35,6 @@ Optional findByWordAndTargetLanguageCode( @Query("{'word': {$regex: ?0, $options: 'i'}}") Page findByWordContainingIgnoreCase(String word, Pageable pageable); - boolean existsByWord(String word); - /** * isEssential 필드로 필터링 */ @@ -51,9 +49,4 @@ Optional findByWordAndTargetLanguageCode( * 필수 단어 중 특정 target 언어로 필터링 */ List findAllByIsEssentialAndTargetLanguageCode(Boolean isEssential, LanguageCode targetLanguageCode); - - /** - * 필수 단어를 페이징으로 조회 - */ - Page findAllByIsEssential(Boolean isEssential, Pageable pageable); -} \ No newline at end of file +} diff --git a/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java b/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java index 3e4edbca..99f2ef23 100644 --- a/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java @@ -9,8 +9,6 @@ @Repository public interface WordVariantRepository extends MongoRepository { - Optional findByWord(String word); - List findAllByWord(String word); List findByWordIn(List words); 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 8fbfb3f0..c5fa1c4e 100644 --- a/src/main/java/com/linglevel/api/word/service/WordAiService.java +++ b/src/main/java/com/linglevel/api/word/service/WordAiService.java @@ -223,9 +223,11 @@ public List analyzeWord(String word, String targetLanguage) log.info("✅ AI analysis completed for '{}': {} result(s) - {}", word, mergedResults.size(), summary); return mergedResults; + } catch (WordsException e) { + throw e; } catch (Exception e) { log.error("Failed to analyze word '{}' with AI (target: {})", word, targetLanguage, e); - throw new RuntimeException("AI word analysis failed for word: " + word, e); + throw new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED, e); } } diff --git a/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java new file mode 100644 index 00000000..744edcfe --- /dev/null +++ b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java @@ -0,0 +1,268 @@ +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.repository.InvalidWordRepository; +import com.linglevel.api.word.repository.WordRepository; +import com.linglevel.api.word.repository.WordVariantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WordPersistenceService { + + private final WordRepository wordRepository; + private final WordVariantRepository wordVariantRepository; + private final InvalidWordRepository invalidWordRepository; + + @Transactional + public List saveAnalysisResults( + String word, + List analysisResults, + Optional cachedInvalidWord + ) { + List savedVariants = new ArrayList<>(); + for (WordAnalysisResult analysisResult : analysisResults) { + WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); + savedVariants.add(savedVariant); + } + + cachedInvalidWord.ifPresent(invalidWord -> { + invalidWordRepository.delete(invalidWord); + log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)", + word, invalidWord.getAttemptCount()); + }); + + return savedVariants; + } + + @Transactional + public List forceSaveAnalysisResults( + String word, + LanguageCode targetLanguage, + boolean overwrite, + boolean deleteVariants, + List analysisResults + ) { + if (overwrite) { + deleteExistingWords(word, targetLanguage, deleteVariants); + } + + List savedVariants = new ArrayList<>(); + for (WordAnalysisResult analysisResult : analysisResults) { + WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); + savedVariants.add(savedVariant); + } + + return savedVariants; + } + + @Transactional + public Word saveWord(WordAnalysisResult analysisResult) { + Word newWord = convertAnalysisResultToWord(analysisResult); + return wordRepository.save(newWord); + } + + private WordVariant saveWordFromAnalysis(String word, WordAnalysisResult analysisResult) { + String originalForm = analysisResult.getOriginalForm(); + LanguageCode sourceLanguageCode = analysisResult.getSourceLanguageCode(); + LanguageCode targetLanguageCode = analysisResult.getTargetLanguageCode(); + + wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( + originalForm, sourceLanguageCode, targetLanguageCode + ).orElseGet(() -> { + Word newWord = convertAnalysisResultToWord(analysisResult); + Word savedWord = wordRepository.save(newWord); + log.info("Saved new word: {} ({} -> {})", originalForm, sourceLanguageCode, targetLanguageCode); + + saveWordVariants(savedWord); + + return savedWord; + }); + + Optional existingVariant = wordVariantRepository.findByWordAndOriginalForm(word, originalForm); + if (existingVariant.isPresent()) { + log.info("Variant already exists: {} -> {}", word, originalForm); + return existingVariant.get(); + } + + List variantTypes = analysisResult.getVariantTypes() != null && !analysisResult.getVariantTypes().isEmpty() + ? analysisResult.getVariantTypes() + : List.of(VariantType.ORIGINAL_FORM); + + WordVariant inputVariant = createVariant(word, originalForm, variantTypes); + wordVariantRepository.save(inputVariant); + log.info("Saved input variant: {} -> {} ({})", word, originalForm, variantTypes); + + return inputVariant; + } + + private void saveWordVariants(Word word) { + List variants = new ArrayList<>(); + + RelatedForms relatedForms = word.getRelatedForms(); + if (relatedForms == null) { + return; + } + + if (relatedForms.getConjugations() != null) { + var conj = relatedForms.getConjugations(); + if (conj.getPast() != null && !conj.getPast().equals(word.getWord())) { + variants.add(createVariant(conj.getPast(), word.getWord(), List.of(VariantType.PAST_TENSE))); + } + if (conj.getPastParticiple() != null && !conj.getPastParticiple().equals(word.getWord())) { + variants.add(createVariant(conj.getPastParticiple(), word.getWord(), List.of(VariantType.PAST_PARTICIPLE))); + } + if (conj.getPresentParticiple() != null && !conj.getPresentParticiple().equals(word.getWord())) { + variants.add(createVariant(conj.getPresentParticiple(), word.getWord(), List.of(VariantType.PRESENT_PARTICIPLE))); + } + if (conj.getThirdPerson() != null && !conj.getThirdPerson().equals(word.getWord())) { + variants.add(createVariant(conj.getThirdPerson(), word.getWord(), List.of(VariantType.THIRD_PERSON))); + } + } + + if (relatedForms.getComparatives() != null) { + var comp = relatedForms.getComparatives(); + if (comp.getComparative() != null && !comp.getComparative().equals(word.getWord())) { + variants.add(createVariant(comp.getComparative(), word.getWord(), List.of(VariantType.COMPARATIVE))); + } + if (comp.getSuperlative() != null && !comp.getSuperlative().equals(word.getWord())) { + variants.add(createVariant(comp.getSuperlative(), word.getWord(), List.of(VariantType.SUPERLATIVE))); + } + } + + if (relatedForms.getPlural() != null) { + var plural = relatedForms.getPlural(); + if (plural.getPlural() != null && !plural.getPlural().equals(word.getWord())) { + variants.add(createVariant(plural.getPlural(), word.getWord(), List.of(VariantType.PLURAL))); + } + } + + if (!variants.isEmpty()) { + List uniqueVariants = variants.stream() + .collect(Collectors.toMap( + WordVariant::getWord, + variant -> variant, + (existing, replacement) -> { + List mergedTypes = new ArrayList<>(existing.getVariantTypes()); + replacement.getVariantTypes().forEach(type -> { + if (!mergedTypes.contains(type)) { + mergedTypes.add(type); + } + }); + existing.setVariantTypes(mergedTypes); + return existing; + } + )) + .values() + .stream() + .toList(); + + List variantWords = uniqueVariants.stream() + .map(WordVariant::getWord) + .collect(Collectors.toList()); + + List existingVariants = wordVariantRepository.findByWordIn(variantWords); + List existingWords = existingVariants.stream() + .map(WordVariant::getWord) + .toList(); + + List newVariants = uniqueVariants.stream() + .filter(variant -> !existingWords.contains(variant.getWord())) + .collect(Collectors.toList()); + + if (!newVariants.isEmpty()) { + wordVariantRepository.saveAll(newVariants); + newVariants.forEach(variant -> + log.info("Saved variant: {} -> {} ({})", variant.getWord(), variant.getOriginalForm(), variant.getVariantTypes()) + ); + } + } + } + + @Transactional + public void saveInvalidWord(String word) { + Optional existingInvalidWord = invalidWordRepository.findByWord(word); + + if (existingInvalidWord.isPresent()) { + InvalidWord invalidWord = existingInvalidWord.get(); + invalidWord.setAttemptCount(invalidWord.getAttemptCount() + 1); + invalidWordRepository.save(invalidWord); + log.info("Updated invalid word '{}' attempt count: {}", word, invalidWord.getAttemptCount()); + return; + } + + InvalidWord invalidWord = InvalidWord.builder() + .word(word) + .attemptedAt(LocalDateTime.now()) + .attemptCount(1) + .build(); + invalidWordRepository.save(invalidWord); + log.info("Cached invalid word '{}' permanently (attempt 1/3)", word); + } + + private void deleteExistingWords(String word, LanguageCode targetLanguage, boolean deleteVariants) { + List existingVariants = wordVariantRepository.findAllByWord(word); + if (existingVariants.isEmpty()) { + return; + } + + for (WordVariant variant : existingVariants) { + String originalForm = variant.getOriginalForm(); + wordRepository.findByWordAndTargetLanguageCode( + originalForm, targetLanguage + ).ifPresent(wordToDelete -> { + wordRepository.delete(wordToDelete); + log.info("Deleted Word: {} (targetLanguage={})", originalForm, targetLanguage); + }); + } + + if (deleteVariants) { + wordVariantRepository.deleteAll(existingVariants); + log.info("Deleted {} existing WordVariants for word '{}' (complete reset)", existingVariants.size(), word); + return; + } + + log.info("Kept {} existing WordVariants for word '{}' (only Word deleted)", existingVariants.size(), word); + } + + private Word convertAnalysisResultToWord(WordAnalysisResult result) { + RelatedForms relatedForms = RelatedForms.builder() + .conjugations(result.getConjugations()) + .comparatives(result.getComparatives()) + .plural(result.getPlural()) + .build(); + + return Word.builder() + .word(result.getOriginalForm()) + .sourceLanguageCode(result.getSourceLanguageCode()) + .targetLanguageCode(result.getTargetLanguageCode()) + .summary(result.getSummary()) + .meanings(result.getMeanings()) + .relatedForms(relatedForms) + .build(); + } + + private WordVariant createVariant(String variantWord, String originalForm, List types) { + return WordVariant.builder() + .word(variantWord) + .originalForm(originalForm) + .variantTypes(types) + .build(); + } +} 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 79f1d815..3a9235cb 100644 --- a/src/main/java/com/linglevel/api/word/service/WordService.java +++ b/src/main/java/com/linglevel/api/word/service/WordService.java @@ -14,13 +14,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -33,6 +30,7 @@ public class WordService { private final InvalidWordRepository invalidWordRepository; private final WordAiService wordAiService; private final WordSingleFlightRedisCoordinator singleFlightCoordinator; + private final WordPersistenceService wordPersistenceService; public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) { List wordVariants = getOrCreateWordEntities(word, targetLanguage); @@ -58,8 +56,7 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC wordVariant.getOriginalForm(), targetLanguage.getCode() ); - Word newWord = convertAnalysisResultToWord(analysisResults.get(0)); - return wordRepository.save(newWord); + return wordPersistenceService.saveWord(analysisResults.get(0)); }, () -> wordRepository.findByWordAndTargetLanguageCode( wordVariant.getOriginalForm(), @@ -86,7 +83,6 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC .build(); } - @Transactional(noRollbackFor = WordsException.class) public List getOrCreateWordEntities(String word, LanguageCode targetLanguage) { // 1. WordVariant에서 검색 (변형 형태인지 확인) List existingVariants = wordVariantRepository.findAllByWord(word); @@ -118,17 +114,9 @@ public List getOrCreateWordEntities(String word, LanguageCode targe () -> { List analysisResults = analyzeWordAndUpdateInvalidCache( word, - targetLanguage, - cachedInvalidWord + targetLanguage ); - - List savedVariants = new ArrayList<>(); - for (WordAnalysisResult analysisResult : analysisResults) { - WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); - savedVariants.add(savedVariant); - } - - return savedVariants; + return wordPersistenceService.saveAnalysisResults(word, analysisResults, cachedInvalidWord); }, () -> findWordVariantsAfterSingleFlight(word, invalidAttemptCountBeforeSingleFlight) ); @@ -158,219 +146,19 @@ private Optional> findWordVariantsAfterSingleFlight( private void cacheInvalidWordIfMeaningless(String word, WordsException e) { if (e.getErrorCode() == WordsErrorCode.WORD_IS_MEANINGLESS) { log.warn("AI classified word '{}' as meaningless. Updating invalid-word cache.", word, e); - saveInvalidWord(word); + wordPersistenceService.saveInvalidWord(word); } } private List analyzeWordAndUpdateInvalidCache( String word, - LanguageCode targetLanguage, - Optional cachedInvalidWord + LanguageCode targetLanguage ) { - List analysisResults; try { - analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode()); + return wordAiService.analyzeWord(word, targetLanguage.getCode()); } catch (WordsException e) { cacheInvalidWordIfMeaningless(word, e); throw e; - } catch (RuntimeException e) { - log.warn("AI call failed for word '{}'. Caching as invalid word to prevent retries.", word, e); - saveInvalidWord(word); - throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); - } - - cachedInvalidWord.ifPresent(invalidWord -> { - invalidWordRepository.delete(invalidWord); - log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)", - word, invalidWord.getAttemptCount()); - }); - - return analysisResults; - } - - - @Transactional - public WordVariant saveWordFromAnalysis(String word, WordAnalysisResult analysisResult) { - // 1. 원형 단어가 해당 언어 쌍으로 이미 존재하는지 확인 - String originalForm = analysisResult.getOriginalForm(); - LanguageCode sourceLanguageCode = analysisResult.getSourceLanguageCode(); - LanguageCode targetLanguageCode = analysisResult.getTargetLanguageCode(); - - wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( - originalForm, sourceLanguageCode, targetLanguageCode - ).orElseGet(() -> { - // 해당 언어 쌍으로 번역된 Word가 없으면 새로 저장 - Word newWord = convertAnalysisResultToWord(analysisResult); - Word savedWord = wordRepository.save(newWord); - log.info("Saved new word: {} ({} -> {})", originalForm, sourceLanguageCode, targetLanguageCode); - - // 변형 형태들을 WordVariant에 저장 (언어 중립적) - saveWordVariants(savedWord); - - return savedWord; - }); - - // 2. 입력 단어를 WordVariant에 저장 (언어 중립적, 중복 체크) - // 중요: word와 originalForm 둘 다 체크해야 함 (homograph 대응) - Optional existingVariant = wordVariantRepository.findByWordAndOriginalForm(word, originalForm); - if (existingVariant.isPresent()) { - log.info("Variant already exists: {} -> {}", word, originalForm); - return existingVariant.get(); - } - - List variantTypes = analysisResult.getVariantTypes() != null && !analysisResult.getVariantTypes().isEmpty() - ? analysisResult.getVariantTypes() - : List.of(VariantType.ORIGINAL_FORM); - - WordVariant inputVariant = createVariant(word, originalForm, variantTypes); - wordVariantRepository.save(inputVariant); - log.info("Saved input variant: {} -> {} ({})", word, originalForm, variantTypes); - - return inputVariant; - } - - private Word convertAnalysisResultToWord(WordAnalysisResult result) { - // RelatedForms 구성 - RelatedForms relatedForms = RelatedForms.builder() - .conjugations(result.getConjugations()) - .comparatives(result.getComparatives()) - .plural(result.getPlural()) - .build(); - - return Word.builder() - .word(result.getOriginalForm()) - .sourceLanguageCode(result.getSourceLanguageCode()) - .targetLanguageCode(result.getTargetLanguageCode()) - .summary(result.getSummary()) - .meanings(result.getMeanings()) // AI의 Meaning을 그대로 저장 - .relatedForms(relatedForms) - .build(); - } - - @Transactional - public void saveWordVariants(Word word) { - List variants = new ArrayList<>(); - - RelatedForms relatedForms = word.getRelatedForms(); - if (relatedForms == null) { - return; - } - - // 동사 변형 저장 - if (relatedForms.getConjugations() != null) { - var conj = relatedForms.getConjugations(); - if (conj.getPast() != null && !conj.getPast().equals(word.getWord())) { - variants.add(createVariant(conj.getPast(), word.getWord(), List.of(VariantType.PAST_TENSE))); - } - if (conj.getPastParticiple() != null && !conj.getPastParticiple().equals(word.getWord())) { - variants.add(createVariant(conj.getPastParticiple(), word.getWord(), List.of(VariantType.PAST_PARTICIPLE))); - } - if (conj.getPresentParticiple() != null && !conj.getPresentParticiple().equals(word.getWord())) { - variants.add(createVariant(conj.getPresentParticiple(), word.getWord(), List.of(VariantType.PRESENT_PARTICIPLE))); - } - if (conj.getThirdPerson() != null && !conj.getThirdPerson().equals(word.getWord())) { - variants.add(createVariant(conj.getThirdPerson(), word.getWord(), List.of(VariantType.THIRD_PERSON))); - } - } - - // 형용사/부사 변형 저장 - if (relatedForms.getComparatives() != null) { - var comp = relatedForms.getComparatives(); - if (comp.getComparative() != null && !comp.getComparative().equals(word.getWord())) { - variants.add(createVariant(comp.getComparative(), word.getWord(), List.of(VariantType.COMPARATIVE))); - } - if (comp.getSuperlative() != null && !comp.getSuperlative().equals(word.getWord())) { - variants.add(createVariant(comp.getSuperlative(), word.getWord(), List.of(VariantType.SUPERLATIVE))); - } - } - - // 명사 복수형 저장 - if (relatedForms.getPlural() != null) { - var plural = relatedForms.getPlural(); - if (plural.getPlural() != null && !plural.getPlural().equals(word.getWord())) { - variants.add(createVariant(plural.getPlural(), word.getWord(), List.of(VariantType.PLURAL))); - } - } - - // N+1 문제 해결: 한 번에 조회 후 필터링 - if (!variants.isEmpty()) { - // 중복 제거 및 variantTypes 병합: 같은 단어가 여러 번 추가되는 경우 variantTypes를 합침 - List uniqueVariants = variants.stream() - .collect(Collectors.toMap( - WordVariant::getWord, - variant -> variant, - (existing, replacement) -> { - // 같은 단어인 경우 variantTypes를 병합 - List mergedTypes = new ArrayList<>(existing.getVariantTypes()); - replacement.getVariantTypes().forEach(type -> { - if (!mergedTypes.contains(type)) { - mergedTypes.add(type); - } - }); - existing.setVariantTypes(mergedTypes); - return existing; - } - )) - .values() - .stream() - .collect(Collectors.toList()); - - List variantWords = uniqueVariants.stream() - .map(WordVariant::getWord) - .collect(Collectors.toList()); - - // 이미 존재하는 variant들을 한 번의 쿼리로 조회 - List existingVariants = wordVariantRepository.findByWordIn(variantWords); - List existingWords = existingVariants.stream() - .map(WordVariant::getWord) - .collect(Collectors.toList()); - - // 새로운 variant만 필터링하여 배치 저장 - List newVariants = uniqueVariants.stream() - .filter(variant -> !existingWords.contains(variant.getWord())) - .collect(Collectors.toList()); - - if (!newVariants.isEmpty()) { - wordVariantRepository.saveAll(newVariants); - newVariants.forEach(variant -> - log.info("Saved variant: {} -> {} ({})", variant.getWord(), variant.getOriginalForm(), variant.getVariantTypes()) - ); - } - } - } - - private WordVariant createVariant(String variantWord, String originalForm, List types) { - return WordVariant.builder() - .word(variantWord) - .originalForm(originalForm) - .variantTypes(types) - .build(); - } - - /** - * 유효하지 않은 단어를 캐시에 저장 (AI 재호출 방지) - */ - @Transactional - public void saveInvalidWord(String word) { - Optional existingInvalidWord = invalidWordRepository.findByWord(word); - - if (existingInvalidWord.isPresent()) { - // 이미 존재하면 시도 횟수만 증가 - InvalidWord invalidWord = existingInvalidWord.get(); - invalidWord.setAttemptCount(invalidWord.getAttemptCount() + 1); - invalidWordRepository.save(invalidWord); - log.info("Updated invalid word '{}' attempt count: {}", word, invalidWord.getAttemptCount()); - } else { - // 새로 저장 - LocalDateTime now = LocalDateTime.now(); - - InvalidWord invalidWord = InvalidWord.builder() - .word(word) - .attemptedAt(now) - .attemptCount(1) - .build(); - invalidWordRepository.save(invalidWord); - log.info("Cached invalid word '{}' permanently (attempt 1/3)", word); } } @@ -392,14 +180,12 @@ private WordResponse convertToResponse(Word word, boolean isBookmarked, List existingVariants = wordVariantRepository.findAllByWord(word); - if (!existingVariants.isEmpty()) { - // 관련된 원형 단어들의 Word 엔티티 삭제 - for (WordVariant variant : existingVariants) { - String originalForm = variant.getOriginalForm(); - wordRepository.findByWordAndTargetLanguageCode( - originalForm, targetLanguage - ).ifPresent(wordToDelete -> { - wordRepository.delete(wordToDelete); - log.info("Deleted Word: {} (targetLanguage={})", originalForm, targetLanguage); - }); - } - - if (deleteVariants) { - // Variant도 함께 삭제 (완전 초기화) - wordVariantRepository.deleteAll(existingVariants); - log.info("Deleted {} existing WordVariants for word '{}' (complete reset)", existingVariants.size(), word); - } else { - // Variant는 유지 (AI가 기존 관계 + 새로운 homograph 추가 가능) - log.info("Kept {} existing WordVariants for word '{}' (only Word deleted)", existingVariants.size(), word); - } - } - } - // AI로 재분석 log.info("Calling AI to re-analyze word '{}'...", word); List analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode()); // 분석 결과를 DB에 저장 (빈 결과는 WordAiService에서 예외 발생, overwrite=false면 중복 체크로 인해 새로운 것만 추가됨) - List savedVariants = new ArrayList<>(); - for (WordAnalysisResult analysisResult : analysisResults) { - WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); - savedVariants.add(savedVariant); - } + List savedVariants = wordPersistenceService.forceSaveAnalysisResults( + word, + targetLanguage, + overwrite, + deleteVariants, + analysisResults + ); log.info("Force re-analysis completed. Saved {} variants", savedVariants.size()); diff --git a/src/main/java/com/linglevel/api/word/service/WordVariantService.java b/src/main/java/com/linglevel/api/word/service/WordVariantService.java index bb4e39ce..ff85628f 100644 --- a/src/main/java/com/linglevel/api/word/service/WordVariantService.java +++ b/src/main/java/com/linglevel/api/word/service/WordVariantService.java @@ -1,18 +1,12 @@ package com.linglevel.api.word.service; -import com.linglevel.api.i18n.LanguageCode; -import com.linglevel.api.word.dto.WordAnalysisResult; 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.repository.WordVariantRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; /** * WordVariant 전용 서비스 (언어 중립적) @@ -27,61 +21,17 @@ public class WordVariantService { private final WordVariantRepository wordVariantRepository; - private final WordAiService wordAiService; /** - * 단어의 원형을 반환 (언어 중립적) + * 단어의 원형 후보들을 반환 (언어 중립적) * * @param word 검색할 단어 (변형 형태 가능) - * @return 원형 단어 + * @return 원형 단어 후보 목록 */ - @Transactional - public String getOriginalForm(String word) { - WordVariant variant = getOrCreateWordVariant(word); - return variant.getOriginalForm(); - } - - /** - * WordVariant 조회 또는 생성 - * DB에 없으면 AI로 분석하여 원형 찾기 - * - * @param word 검색할 단어 - * @return WordVariant - */ - @Transactional - public WordVariant getOrCreateWordVariant(String word) { - // 1. WordVariant에서 검색 - Optional variantOpt = wordVariantRepository.findByWord(word); - if (variantOpt.isPresent()) { - return variantOpt.get(); - } - - // 2. DB에 없으면 AI 호출 (기본 언어 KO 사용 - 언어는 중요하지 않음, 원형만 필요) - log.info("WordVariant '{}' not found. Calling AI to find original form...", word); - List analysisResults = wordAiService.analyzeWord(word, LanguageCode.KO.getCode()); - - if (analysisResults.isEmpty()) { - log.warn("AI could not find original form for word '{}'", word); - throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); - } - // 3. 첫 번째 결과로 WordVariant 생성 및 저장 - WordAnalysisResult result = analysisResults.get(0); - WordVariant newVariant = WordVariant.builder() - .word(word) - .originalForm(result.getOriginalForm()) - .variantTypes(result.getVariantTypes()) - .build(); - - WordVariant savedVariant = wordVariantRepository.save(newVariant); - log.info("Saved new WordVariant: {} -> {} ({})", word, result.getOriginalForm(), result.getVariantTypes()); - - return savedVariant; - } - - /** - * 단어가 WordVariant에 존재하는지 확인 - */ - public boolean exists(String word) { - return wordVariantRepository.findByWord(word).isPresent(); + public List getOriginalForms(String word) { + return wordVariantRepository.findAllByWord(word).stream() + .map(WordVariant::getOriginalForm) + .distinct() + .toList(); } } diff --git a/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java b/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java new file mode 100644 index 00000000..e7314800 --- /dev/null +++ b/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java @@ -0,0 +1,85 @@ +package com.linglevel.api.bookmark.service; + +import com.linglevel.api.bookmark.repository.WordBookmarkRepository; +import com.linglevel.api.word.repository.WordRepository; +import com.linglevel.api.word.service.WordService; +import com.linglevel.api.word.service.WordVariantService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BookmarkServiceTest { + + @Mock + private WordBookmarkRepository wordBookmarkRepository; + + @Mock + private WordRepository wordRepository; + + @Mock + private WordVariantService wordVariantService; + + @Mock + private WordService wordService; + + @InjectMocks + private BookmarkService bookmarkService; + + @Test + @DisplayName("variant 원형 후보가 없어도 입력 단어 북마크가 있으면 삭제한다") + void removeWordBookmark_noVariantCandidate_deletesBookmarkByInputWord() { + // given + String userId = "user-1"; + String word = "run"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of()); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); + } + + @Test + @DisplayName("variant 원형 후보 중 실제 북마크된 단어를 찾아 삭제한다") + void removeWordBookmark_variantCandidates_deletesExistingBookmarkedOriginalForm() { + // given + String userId = "user-1"; + String word = "ran"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("run")); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(false); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, "run"); + } + + @Test + @DisplayName("입력 단어와 variant 원형 후보가 모두 북마크되어 있으면 입력 단어를 우선 삭제한다") + void removeWordBookmark_exactBookmarkExists_deletesInputWordBeforeVariantCandidate() { + // given + String userId = "user-1"; + String word = "saw"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("see", "saw")); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); + } +} diff --git a/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java b/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java index fed3fef8..424335a1 100644 --- a/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java @@ -3,6 +3,7 @@ import com.linglevel.api.word.dto.PartOfSpeech; import com.linglevel.api.word.dto.VariantType; import com.linglevel.api.word.dto.WordAnalysisResult; +import com.linglevel.api.word.exception.WordsErrorCode; import com.linglevel.api.word.exception.WordsException; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Disabled; @@ -339,13 +340,12 @@ void testNonsensicalWord_shouldThrowException() { String targetLanguage = "KO"; // when & then - // AI가 의미 없는 단어에 대해 빈 배열을 반환하면 WordsException을 던짐 (RuntimeException으로 래핑됨) - RuntimeException exception = assertThrows(RuntimeException.class, () -> { + // AI가 의미 없는 단어에 대해 빈 배열을 반환하면 WordsException을 그대로 던짐 + WordsException exception = assertThrows(WordsException.class, () -> { wordAiService.analyzeWord(word, targetLanguage); }); - // RuntimeException의 cause가 WordsException인지 확인 - assertThat(exception.getCause()).isInstanceOf(WordsException.class); + assertThat(exception.getErrorCode()).isEqualTo(WordsErrorCode.WORD_IS_MEANINGLESS); } // ===== 실패 사례 기반 추가 테스트 ===== 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 9051a12c..4366c4be 100644 --- a/src/test/java/com/linglevel/api/word/service/WordServiceTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordServiceTest.java @@ -15,7 +15,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; @@ -55,14 +54,29 @@ class WordServiceTest { @Mock private WordSingleFlightRedisCoordinator singleFlightCoordinator; - @InjectMocks private WordService wordService; + private WordPersistenceService wordPersistenceService; private Word sampleWord; private String userId = "test-user-123"; @BeforeEach void setUp() { + wordPersistenceService = new WordPersistenceService( + wordRepository, + wordVariantRepository, + invalidWordRepository + ); + wordService = new WordService( + wordRepository, + wordBookmarkRepository, + wordVariantRepository, + invalidWordRepository, + wordAiService, + singleFlightCoordinator, + wordPersistenceService + ); + lenient().when(singleFlightCoordinator.execute(anyString(), any(LanguageCode.class), any(), any())) .thenAnswer(invocation -> { Supplier> lookup = invocation.getArgument(3); @@ -236,10 +250,26 @@ void setUp() { @DisplayName("단어 저장 시 모든 변형 형태를 WordVariant에 저장") void saveWordVariants_모든_변형_형태_저장() { // given + WordAnalysisResult analysisResult = WordAnalysisResult.builder() + .originalForm(sampleWord.getWord()) + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .sourceLanguageCode(sampleWord.getSourceLanguageCode()) + .targetLanguageCode(sampleWord.getTargetLanguageCode()) + .summary(sampleWord.getSummary()) + .meanings(sampleWord.getMeanings()) + .conjugations(sampleWord.getRelatedForms().getConjugations()) + .build(); + + when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( + sampleWord.getWord(), + sampleWord.getSourceLanguageCode(), + sampleWord.getTargetLanguageCode() + )).thenReturn(Optional.empty()); + when(wordRepository.save(any(Word.class))).thenReturn(sampleWord); when(wordVariantRepository.findByWordIn(anyList())).thenReturn(List.of()); // when - wordService.saveWordVariants(sampleWord); + wordPersistenceService.saveAnalysisResults("run", List.of(analysisResult), Optional.empty()); // then // 동사 변형 4개가 저장되어야 함 (past, pastParticiple, presentParticiple, thirdPerson) @@ -309,20 +339,22 @@ void getOrCreateWords_translationMissTimeout_returnsDomainTimeoutError() { } @Test - @DisplayName("AI 호출 실패가 발생하면 invalid 캐시에 반영") - void getOrCreateWords_aiRuntimeFailure_cachesInvalidWord() { + @DisplayName("AI 분석 실패는 invalid 캐시에 반영하지 않고 분석 실패 에러로 전파") + void getOrCreateWords_aiAnalysisFailure_doesNotCacheInvalidWord() { String word = "resilience"; + WordsException failure = new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED); when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())) - .thenThrow(new RuntimeException("bedrock failure")); + .thenThrow(failure); assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) - .isInstanceOf(WordsException.class) - .hasMessageContaining("meaningless"); + .isSameAs(failure) + .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) + .isEqualTo(WordsErrorCode.WORD_ANALYSIS_FAILED)); - verify(invalidWordRepository).save(any()); + verify(invalidWordRepository, never()).save(any()); } @Test diff --git a/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java b/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java new file mode 100644 index 00000000..eb91d66c --- /dev/null +++ b/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java @@ -0,0 +1,65 @@ +package com.linglevel.api.word.service; + +import com.linglevel.api.word.dto.VariantType; +import com.linglevel.api.word.entity.WordVariant; +import com.linglevel.api.word.repository.WordVariantRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WordVariantServiceTest { + + @Mock + private WordVariantRepository wordVariantRepository; + + @InjectMocks + private WordVariantService wordVariantService; + + @Test + @DisplayName("기존 WordVariant가 여러 개 있으면 모두 원형 후보로 반환한다") + void getOriginalForms_existingVariants_returnsAllOriginalForms() { + // given + String word = "saw"; + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of( + WordVariant.builder() + .word(word) + .originalForm("see") + .variantTypes(List.of(VariantType.PAST_TENSE)) + .build(), + WordVariant.builder() + .word(word) + .originalForm("saw") + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .build() + )); + + // when + List originalForms = wordVariantService.getOriginalForms(word); + + // then + assertThat(originalForms).containsExactly("see", "saw"); + } + + @Test + @DisplayName("기존 WordVariant가 없으면 빈 원형 후보 목록을 반환한다") + void getOriginalForms_noExistingVariants_returnsEmptyList() { + // given + String word = "saw"; + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + + // when + List originalForms = wordVariantService.getOriginalForms(word); + + // then + assertThat(originalForms).isEmpty(); + } +}