Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -15,5 +15,4 @@ public interface RefreshTokenRepository extends MongoRepository<RefreshToken, St

void deleteByUserId(String userId);

void deleteByTokenId(String tokenId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import com.linglevel.api.bookmark.exception.BookmarksException;
import com.linglevel.api.bookmark.repository.WordBookmarkRepository;
import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.dto.WordSearchResponse;
import com.linglevel.api.word.entity.Word;
import com.linglevel.api.word.entity.WordVariant;
import com.linglevel.api.word.repository.WordRepository;
import com.linglevel.api.word.service.WordService;
import com.linglevel.api.word.service.WordVariantService;
Expand All @@ -19,12 +19,10 @@
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable;
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
Expand Down Expand Up @@ -58,12 +56,9 @@ public Page<BookmarkedWordResponse> 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);
Expand All @@ -79,50 +74,36 @@ 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<String> 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) {
// 북마크 해제
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));
Expand Down Expand Up @@ -159,4 +140,34 @@ private Page<BookmarkedWordResponse> convertToBookmarkedWordResponseDirect(Page<

return new PageImpl<>(responses, bookmarks.getPageable(), bookmarks.getTotalElements());
}
}

private String resolveFirstOriginalForm(WordSearchResponse wordSearchResponse) {
List<String> 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<String> originalForms) {
if (wordBookmarkRepository.existsByUserIdAndWord(userId, wordStr)) {
return wordStr;
}

for (String originalForm : originalForms) {
if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) {
return originalForm;
Comment thread
SolfE marked this conversation as resolved.
}
}

throw new BookmarksException(BookmarksErrorCode.WORD_BOOKMARK_NOT_FOUND);
}

private List<String> extractDistinctOriginalForms(WordSearchResponse wordSearchResponse) {
return wordSearchResponse.getResults().stream()
.map(result -> result.getOriginalForm())
.distinct()
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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<Article, String>, ArticleRepositoryCustom {

// 제목 또는 작가로 키워드 검색
@Query("{'$or': [{'title': {'$regex': ?0, '$options': 'i'}}, {'author': {'$regex': ?0, '$options': 'i'}}]}")
Page<Article> findByTitleOrAuthorContaining(String keyword, Pageable pageable);

// 태그와 키워드 모두 적용
@Query("{'$and': [{'tags': {'$in': ?0}}, {'$or': [{'title': {'$regex': ?1, '$options': 'i'}}, {'author': {'$regex': ?1, '$options': 'i'}}]}]}")
Page<Article> findByTagsInAndTitleOrAuthorContaining(List<String> tags, String keyword, Pageable pageable);

// 태그 목록으로 필터링
Page<Article> findByTagsIn(List<String> tags, Pageable pageable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
import java.util.Optional;

public interface ChunkRepository extends MongoRepository<Chunk, String> {
Page<Chunk> findByChapterId(String chapterId, Pageable pageable);

Page<Chunk> findByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel, Pageable pageable);

Optional<Chunk> findFirstByChapterIdOrderByChunkNumberAsc(String chapterId);
Expand Down Expand Up @@ -58,4 +56,4 @@ public interface ChunkRepository extends MongoRepository<Chunk, String> {
})
List<ChunkCountByLevelDto> findChunkCountsByChapterIds(List<String> chapterIds);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ Page<CustomContentChunk> findByCustomContentIdAndIsDeletedFalseOrderByChapterNum

Optional<CustomContentChunk> findByIdAndCustomContentIdAndIsDeletedFalse(String id, String customContentId);

long countByCustomContentIdAndIsDeletedFalse(String customContentId);

Optional<CustomContentChunk> findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId);

// V2 Progress: Count chunks by difficulty level
long countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(String customContentId, DifficultyLevel difficultyLevel);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ public interface CustomContentRepository extends MongoRepository<CustomContent,

Page<CustomContent> findByUserIdAndIsDeletedFalse(String userId, Pageable pageable);

Optional<CustomContent> findByIdAndUserIdAndIsDeletedFalse(String id, String userId);

Optional<CustomContent> findByIdAndIsDeletedFalse(String id);

Optional<CustomContent> findByContentRequestIdAndIsDeletedFalse(String contentRequestId);

Optional<CustomContent> findByOriginUrlAndIsDeletedFalse(String originUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,5 @@ public interface UserCustomContentRepository extends MongoRepository<UserCustomC

List<UserCustomContent> findByUserId(String userId);

long countByCustomContentId(String customContentId);

Optional<UserCustomContent> findByUserIdAndContentRequestId(String userId, String contentRequestId);

Optional<UserCustomContent> findByContentRequestId(String contentRequestId);

boolean existsByUserIdAndCustomContentId(String userId, String customContentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,6 @@ public interface UserStudyReportRepository extends MongoRepository<UserStudyRepo

List<UserStudyReport> findByCurrentStreakGreaterThan(int currentStreak);

/**
* 최적 타이밍 알림을 위한 사용자 조회
* lastLearningTimestamp가 특정 시간 범위 내에 있고, 활성 스트릭을 가진 사용자를 찾습니다.
*
* @param startTime 시작 시간 (23시간 전)
* @param endTime 종료 시간 (24시간 전)
* @return 해당 조건을 만족하는 사용자 리포트 목록
*/
@Query("{ 'lastLearningTimestamp': { $gte: ?0, $lt: ?1 }, 'currentStreak': { $gt: 0 } }")
List<UserStudyReport> findUsersForOptimalTimingReminder(Instant startTime, Instant endTime);

/**
* 이탈 유저 복귀 알림을 위한 사용자 조회 (currentStreak = 0)
* 마지막 학습 시간이 특정 범위 내에 있는 이탈 유저를 찾습니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ public interface TicketTransactionRepository extends MongoRepository<TicketTrans

Page<TicketTransaction> findByUserIdAndStatusOrderByCreatedAtDesc(String userId, TransactionStatus status, Pageable pageable);

List<TicketTransaction> findByReservationId(String reservationId);

Optional<TicketTransaction> findByReservationIdAndStatus(String reservationId, TransactionStatus status);

List<TicketTransaction> findByUserIdAndAmountAndCreatedAtBetween(String userId, Integer amount, LocalDateTime startDateTime, LocalDateTime endDateTime);
}
}
12 changes: 4 additions & 8 deletions src/main/java/com/linglevel/api/word/entity/Word.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,7 +37,7 @@ public class Word {

/**
* 원형 단어 (예: "pretty", "see", "run")
* 복합 unique index의 일부 (word + sourceLanguageCode + targetLanguageCode)
* 복합 unique index의 일부 (word + targetLanguageCode + sourceLanguageCode)
*/
private String word;

Expand Down Expand Up @@ -73,4 +69,4 @@ public class Word {

@Builder.Default
private Boolean isEssential = false;
}
}
4 changes: 1 addition & 3 deletions src/main/java/com/linglevel/api/word/entity/WordVariant.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,10 +19,9 @@ public class WordVariant {
@Id
private String id;

@Indexed
private String word;

private String originalForm;

private List<VariantType> variantTypes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,4 @@
public interface InvalidWordRepository extends MongoRepository<InvalidWord, String> {

Optional<InvalidWord> findByWord(String word);

boolean existsByWord(String word);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ Optional<Word> findByWordAndTargetLanguageCode(
@Query("{'word': {$regex: ?0, $options: 'i'}}")
Page<Word> findByWordContainingIgnoreCase(String word, Pageable pageable);

boolean existsByWord(String word);

/**
* isEssential 필드로 필터링
*/
Expand All @@ -51,9 +49,4 @@ Optional<Word> findByWordAndTargetLanguageCode(
* 필수 단어 중 특정 target 언어로 필터링
*/
List<Word> findAllByIsEssentialAndTargetLanguageCode(Boolean isEssential, LanguageCode targetLanguageCode);

/**
* 필수 단어를 페이징으로 조회
*/
Page<Word> findAllByIsEssential(Boolean isEssential, Pageable pageable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

@Repository
public interface WordVariantRepository extends MongoRepository<WordVariant, String> {
Optional<WordVariant> findByWord(String word);

List<WordVariant> findAllByWord(String word);

List<WordVariant> findByWordIn(List<String> words);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,11 @@ public List<WordAnalysisResult> 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);
}
}

Expand Down
Loading
Loading