-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 동화 페이지 단어장 추출 도메인 구현 #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4602f09
78384b7
9532216
da8b575
89baf44
84c0e66
8aae78c
719be99
c940070
1ffe403
b5f0559
a5d5477
8fd9fa3
f3b3a8a
589813f
2fbfce0
4c3f547
75d504d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.capstone.kkumteul.domain.vocab.entity; | ||
|
|
||
| import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; | ||
| import com.capstone.kkumteul.global.entity.BaseEntity; | ||
| import jakarta.persistence.*; | ||
| import lombok.*; | ||
|
|
||
| /** | ||
| * 동화 페이지에서 추출된 어려운 단어 항목. | ||
| * | ||
| * <p>중복 정책: <b>first-occurrence-wins</b> — 같은 동화 안에서 같은 단어가 | ||
| * 여러 페이지에 등장해도 최초로 추출된 페이지 1개 row만 저장한다. | ||
| * UNIQUE(fairytale_id, word)로 강제하며, race condition은 | ||
| * {@code DataIntegrityViolationException} catch로 처리한다.</p> | ||
| */ | ||
| @Entity | ||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Table(name = "word_entry", uniqueConstraints = { | ||
| @UniqueConstraint(name = "uk_word_entry_fairytale_word", columnNames = {"fairytale_id", "word"}) | ||
| }, indexes = { | ||
| @Index(name = "idx_word_entry_fairytale", columnList = "fairytale_id") | ||
| }) | ||
| public class WordEntry extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(name = "word_entry_id") | ||
| private Long id; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "fairytale_id", nullable = false) | ||
| private Fairytale fairytale; | ||
|
|
||
| @Column(name = "page_no", nullable = false) | ||
| private int pageNo; | ||
|
|
||
| @Column(nullable = false, length = 100) | ||
| private String word; | ||
|
|
||
| @Column(nullable = false, columnDefinition = "TEXT") | ||
| private String meaning; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.capstone.kkumteul.domain.vocab.exception; | ||
|
|
||
| import com.capstone.kkumteul.global.exception.BaseException; | ||
|
|
||
| public class ParagraphNotFoundForVocabException extends BaseException { | ||
| public ParagraphNotFoundForVocabException() { | ||
| super(VocabErrorCode.PARAGRAPH_NOT_FOUND_FOR_VOCAB); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.capstone.kkumteul.domain.vocab.exception; | ||
|
|
||
| import com.capstone.kkumteul.global.response.code.BaseResponseCode; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| import static com.capstone.kkumteul.global.constant.StaticValue.*; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public enum VocabErrorCode implements BaseResponseCode { | ||
|
|
||
| PARAGRAPH_NOT_FOUND_FOR_VOCAB("VOCAB_404_1", NOT_FOUND, "해당 페이지의 본문을 찾을 수 없습니다."), | ||
| VOCAB_FORBIDDEN("VOCAB_403_1", FORBIDDEN, "본인 동화의 단어장만 조회할 수 있습니다."), | ||
| VOCAB_EXTRACT_FAILED("VOCAB_500_1", INTERNAL_SERVER_ERROR, "단어장 추출에 실패했습니다."); | ||
|
|
||
| private final String code; | ||
| private final int httpStatus; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.capstone.kkumteul.domain.vocab.exception; | ||
|
|
||
| import com.capstone.kkumteul.global.exception.BaseException; | ||
|
|
||
| public class VocabForbiddenException extends BaseException { | ||
| public VocabForbiddenException() { | ||
| super(VocabErrorCode.VOCAB_FORBIDDEN); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.capstone.kkumteul.domain.vocab.repository; | ||
|
|
||
| import com.capstone.kkumteul.domain.vocab.entity.WordEntry; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Repository | ||
| public interface WordEntryRepository extends JpaRepository<WordEntry, Long> { | ||
|
|
||
| /** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */ | ||
| boolean existsByFairytaleIdAndWord(Long fairytaleId, String word); | ||
|
|
||
| /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */ | ||
| List<WordEntry> findByFairytaleIdOrderByPageNoAsc(Long fairytaleId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.capstone.kkumteul.domain.vocab.service; | ||
|
|
||
| import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; | ||
| import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 단어장 추출/조회 서비스. | ||
| */ | ||
| public interface VocabService { | ||
|
|
||
| /** | ||
| * 페이지(3문장 단위)에서 어려운 단어 1개를 추출하고 누적 단어장에 저장. | ||
| * | ||
| * @param fairytaleId 동화 ID | ||
| * @param pageNo 페이지 번호 (1-base) | ||
| * @param sentences 해당 페이지의 문장들 (보통 3개) | ||
| * @return 처리 결과 (저장됨 / 중복 / 단어 없음 / 추출 실패 / race skip) | ||
| */ | ||
| VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List<String> sentences); | ||
|
|
||
| /** | ||
| * 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순. | ||
| * | ||
| * @param userId 요청자 (소유권 검증용) | ||
| * @param fairytaleId 동화 ID | ||
| */ | ||
| List<WordEntryRes> getVocab(Long userId, Long fairytaleId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package com.capstone.kkumteul.domain.vocab.service; | ||
|
|
||
| import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; | ||
| import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; | ||
| import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; | ||
| import com.capstone.kkumteul.domain.vocab.entity.WordEntry; | ||
| import com.capstone.kkumteul.domain.vocab.exception.VocabForbiddenException; | ||
| import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; | ||
| import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; | ||
| import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; | ||
| import com.capstone.kkumteul.global.client.VocabExtractClient; | ||
| import com.capstone.kkumteul.global.client.dto.VocabExtractResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.dao.DataIntegrityViolationException; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Objects; | ||
| import java.util.Optional; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class VocabServiceImpl implements VocabService { | ||
|
|
||
| private final WordEntryRepository wordEntryRepository; | ||
| private final VocabExtractClient vocabExtractClient; | ||
| private final FairytaleRepository fairytaleRepository; | ||
|
|
||
| /** | ||
| * 페이지 3문장 → LLM으로 단어 추출 → 풀이 생성 → DB 저장. | ||
| * | ||
| * <p>처리 흐름:</p> | ||
| * <ol> | ||
| * <li>FastAPI 호출 → 단어/풀이 1회에 추출</li> | ||
| * <li>응답 비어있으면 NO_DIFFICULT_WORD</li> | ||
| * <li>실패하면 EXTRACTION_FAILED (예외 전파 X, fail-open)</li> | ||
| * <li>이미 단어장에 있으면 DUPLICATE (first-occurrence-wins)</li> | ||
| * <li>저장 시도 → race condition으로 UNIQUE 위반이면 RACE_SKIPPED</li> | ||
| * <li>정상 저장이면 SAVED</li> | ||
| * </ol> | ||
| */ | ||
| @Override | ||
| @Transactional | ||
| public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List<String> sentences) { | ||
| Optional<VocabExtractResponse> extracted = vocabExtractClient.extract(sentences); | ||
| if (extracted.isEmpty()) { | ||
| return VocabExtractionResult.extractionFailed(); | ||
| } | ||
|
|
||
| VocabExtractResponse response = extracted.get(); | ||
| String word = response.getWord(); | ||
| String meaning = response.getMeaning(); | ||
| if (word == null || word.isBlank() || meaning == null || meaning.isBlank()) { | ||
| return VocabExtractionResult.noDifficultWord(); | ||
| } | ||
|
|
||
| if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { | ||
| return VocabExtractionResult.duplicate(); | ||
| } | ||
|
|
||
| Fairytale fairytale = fairytaleRepository.findById(fairytaleId) | ||
| .orElseThrow(FairytaleNotFoundException::new); | ||
| WordEntry entry = WordEntry.builder() | ||
| .fairytale(fairytale) | ||
| .pageNo(pageNo) | ||
| .word(word) | ||
| .meaning(meaning) | ||
| .build(); | ||
|
|
||
| try { | ||
| WordEntry saved = wordEntryRepository.save(entry); | ||
| return VocabExtractionResult.saved(saved); | ||
| } catch (DataIntegrityViolationException e) { | ||
| log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 특정 사용자가 단어장을 생성하고 있을 때, 해당 동화의 단어장 생성 자체를 locking 해서 다른 사용자가 단어장을 생성하지 못하도록 막는걸까요?
위 로직으로 생성하게 됩니다. 경쟁상태라고 표현한 이유가 궁금합니다.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 한 동화에서 단어 저장 요청이 동시에 오게되었을때를 대비한 catch문이라고 이해했습니다.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 같은 동화에서 같은 단어가 여러 페이지에 등장하는 경우(예: "용궁"이 페이지1, 페이지2에 둘 다 나옴)를 가정한 것 입니다. 두 페이지의 단어 추출 처리가 동시에 들어오면 둘 다 existsBy 체크는 통과하는데 INSERT 시점에 UNIQUE(fairytale_id, word) 제약 때문에 한쪽이 위반이 나기때문에, 그걸 catch로 받아 RACE_SKIPPED 처리한 안전망(?)으로 작성한 것입니다 즉 다른 사용자를 막는 락이 아닌 위의 예를 말한 것 입니디 경쟁상태라고 표현한 이유는 두 트랜잭션이 같은 값을 insert 했을 때 경쟁하는 상황을 말한 것입니다. |
||
| return VocabExtractionResult.raceSkipped(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 본인 동화 누적 단어장 조회. | ||
| * 동화 소유권 검증 후 페이지 순서로 반환. | ||
| */ | ||
| @Override | ||
| public List<WordEntryRes> getVocab(Long userId, Long fairytaleId) { | ||
| Fairytale fairytale = fairytaleRepository.findById(fairytaleId) | ||
| .orElseThrow(FairytaleNotFoundException::new); | ||
| Objects.requireNonNull(fairytale.getUser(), "Fairytale.user는 null이 될 수 없음"); | ||
| if (!fairytale.getUser().getId().equals(userId)) { | ||
| throw new VocabForbiddenException(); | ||
| } | ||
| return WordEntryRes.listOf(wordEntryRepository.findByFairytaleIdOrderByPageNoAsc(fairytaleId)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package com.capstone.kkumteul.domain.vocab.service.dto; | ||
|
|
||
| import com.capstone.kkumteul.domain.vocab.entity.WordEntry; | ||
|
|
||
| /** | ||
| * VocabService 처리 결과를 나타내는 service-layer 값 객체. | ||
| * | ||
| * <p>web/dto, kafka/message 패키지에 의존하지 않는다. | ||
| * Controller나 KafkaListener는 이 결과를 자기 layer DTO로 변환해 사용한다.</p> | ||
| * | ||
| * @param status 처리 결과 상태 | ||
| * @param entry 성공 시 저장된 엔티티, 그 외엔 null | ||
| */ | ||
| public record VocabExtractionResult(Status status, WordEntry entry) { | ||
|
|
||
| public enum Status { | ||
| /** 새 단어가 추출되어 정상 저장됨 */ | ||
| SAVED, | ||
| /** 추출됐지만 같은 단어가 이미 단어장에 존재함 (first-occurrence-wins) */ | ||
| DUPLICATE, | ||
| /** LLM이 어려운 단어를 찾지 못함 (해당 페이지에 어려운 단어 없음) */ | ||
| NO_DIFFICULT_WORD, | ||
| /** LLM 호출 실패 또는 응답 파싱 실패 */ | ||
| EXTRACTION_FAILED, | ||
| /** 동시 INSERT race condition으로 인해 다른 트랜잭션이 먼저 저장 */ | ||
| RACE_SKIPPED | ||
| } | ||
|
|
||
| public static VocabExtractionResult saved(WordEntry entry) { | ||
| return new VocabExtractionResult(Status.SAVED, entry); | ||
| } | ||
|
|
||
| public static VocabExtractionResult duplicate() { | ||
| return new VocabExtractionResult(Status.DUPLICATE, null); | ||
| } | ||
|
|
||
| public static VocabExtractionResult noDifficultWord() { | ||
| return new VocabExtractionResult(Status.NO_DIFFICULT_WORD, null); | ||
| } | ||
|
|
||
| public static VocabExtractionResult extractionFailed() { | ||
| return new VocabExtractionResult(Status.EXTRACTION_FAILED, null); | ||
| } | ||
|
|
||
| public static VocabExtractionResult raceSkipped() { | ||
| return new VocabExtractionResult(Status.RACE_SKIPPED, null); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.capstone.kkumteul.domain.vocab.web.controller; | ||
|
|
||
| import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; | ||
| import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; | ||
| import com.capstone.kkumteul.domain.vocab.exception.ParagraphNotFoundForVocabException; | ||
| import com.capstone.kkumteul.domain.vocab.service.VocabService; | ||
| import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; | ||
| import com.capstone.kkumteul.domain.vocab.web.dto.InternalVocabProcessReq; | ||
| import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; | ||
| import com.capstone.kkumteul.global.response.SuccessResponse; | ||
| import jakarta.annotation.PostConstruct; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** | ||
| * dev 프로필 한정 — Kafka 없이 단어장 추출 비즈니스 로직을 단독 호출하기 위한 시연/테스트용 API. | ||
| * | ||
| * <p>운영 환경에서는 {@link InternalApiSecurityConfig}와 함께 등록되지 않으므로 노출되지 않는다.</p> | ||
| */ | ||
| @Slf4j | ||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/internal/vocab") | ||
| @Profile("dev") | ||
| public class InternalVocabController { | ||
|
|
||
| private final ParagraphRepository paragraphRepository; | ||
| private final VocabService vocabService; | ||
|
|
||
| @PostConstruct | ||
| public void announce() { | ||
| log.info("[DEV] /internal/vocab/process registered (dev profile active)"); | ||
| } | ||
|
|
||
| @PostMapping("/process") | ||
| public ResponseEntity<SuccessResponse<ProcessRes>> process( | ||
| @Valid @RequestBody InternalVocabProcessReq req | ||
| ) { | ||
| List<Paragraph> paragraphs = paragraphRepository.findByFairytaleIdAndPage( | ||
| req.getFairytaleId(), req.getPageNo() | ||
| ); | ||
| if (paragraphs.isEmpty()) { | ||
| throw new ParagraphNotFoundForVocabException(); | ||
| } | ||
| List<String> sentences = paragraphs.stream().map(Paragraph::getText).toList(); | ||
|
|
||
| VocabExtractionResult result = vocabService.processSentences( | ||
| req.getFairytaleId(), req.getPageNo(), sentences | ||
| ); | ||
|
|
||
| WordEntryRes wordRes = result.entry() == null ? null : WordEntryRes.from(result.entry()); | ||
| return ResponseEntity.status(HttpStatus.OK) | ||
| .body(SuccessResponse.ok(new ProcessRes(result.status().name(), wordRes))); | ||
| } | ||
|
|
||
| public record ProcessRes(String status, WordEntryRes word) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.capstone.kkumteul.domain.vocab.web.controller; | ||
|
|
||
| import com.capstone.kkumteul.domain.user.entity.User; | ||
| import com.capstone.kkumteul.domain.vocab.service.VocabService; | ||
| import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; | ||
| import com.capstone.kkumteul.global.response.SuccessResponse; | ||
| import com.capstone.kkumteul.global.security.AuthUser; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/api/fairytales") | ||
| public class VocabController { | ||
|
|
||
| private final VocabService vocabService; | ||
|
|
||
| /** 본인 동화의 누적 단어장 조회 (페이지 순). */ | ||
| @GetMapping("/{fairytaleId}/vocab") | ||
| public ResponseEntity<SuccessResponse<List<WordEntryRes>>> getVocab( | ||
| @AuthUser User user, | ||
| @PathVariable Long fairytaleId | ||
| ) { | ||
| List<WordEntryRes> entries = vocabService.getVocab(user.getId(), fairytaleId); | ||
| return ResponseEntity.status(HttpStatus.OK) | ||
| .body(SuccessResponse.ok(entries)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spring Data JPA를 사용하지 않은 이유가 궁금합니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
적용해서 반영하겠습니다