diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java index 1bf5020..aeb4f04 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java @@ -10,4 +10,7 @@ public interface ParagraphRepository extends JpaRepository { List findByFairytaleIdOrderByPageAsc(Long fairytaleId); + + /** 특정 페이지의 문장들 조회 — 단어장 추출 시 페이지 단위 본문 로드 */ + List findByFairytaleIdAndPage(Long fairytaleId, int page); } diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java b/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java new file mode 100644 index 0000000..40eb4c5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java @@ -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.*; + +/** + * 동화 페이지에서 추출된 어려운 단어 항목. + * + *

중복 정책: first-occurrence-wins — 같은 동화 안에서 같은 단어가 + * 여러 페이지에 등장해도 최초로 추출된 페이지 1개 row만 저장한다. + * UNIQUE(fairytale_id, word)로 강제하며, race condition은 + * {@code DataIntegrityViolationException} catch로 처리한다.

+ */ +@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; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java new file mode 100644 index 0000000..f77257b --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java @@ -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); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java new file mode 100644 index 0000000..f518fbe --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java @@ -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; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java new file mode 100644 index 0000000..679cd6c --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java @@ -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); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java new file mode 100644 index 0000000..e82622d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java @@ -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 { + + /** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */ + boolean existsByFairytaleIdAndWord(Long fairytaleId, String word); + + /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */ + List findByFairytaleIdOrderByPageNoAsc(Long fairytaleId); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java new file mode 100644 index 0000000..3c56239 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java @@ -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 sentences); + + /** + * 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순. + * + * @param userId 요청자 (소유권 검증용) + * @param fairytaleId 동화 ID + */ + List getVocab(Long userId, Long fairytaleId); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java new file mode 100644 index 0000000..0411f2e --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -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 저장. + * + *

처리 흐름:

+ *
    + *
  1. FastAPI 호출 → 단어/풀이 1회에 추출
  2. + *
  3. 응답 비어있으면 NO_DIFFICULT_WORD
  4. + *
  5. 실패하면 EXTRACTION_FAILED (예외 전파 X, fail-open)
  6. + *
  7. 이미 단어장에 있으면 DUPLICATE (first-occurrence-wins)
  8. + *
  9. 저장 시도 → race condition으로 UNIQUE 위반이면 RACE_SKIPPED
  10. + *
  11. 정상 저장이면 SAVED
  12. + *
+ */ + @Override + @Transactional + public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences) { + Optional 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); + return VocabExtractionResult.raceSkipped(); + } + } + + /** + * 본인 동화 누적 단어장 조회. + * 동화 소유권 검증 후 페이지 순서로 반환. + */ + @Override + public List 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)); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java new file mode 100644 index 0000000..37bf114 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java @@ -0,0 +1,48 @@ +package com.capstone.kkumteul.domain.vocab.service.dto; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; + +/** + * VocabService 처리 결과를 나타내는 service-layer 값 객체. + * + *

web/dto, kafka/message 패키지에 의존하지 않는다. + * Controller나 KafkaListener는 이 결과를 자기 layer DTO로 변환해 사용한다.

+ * + * @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); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java new file mode 100644 index 0000000..ff29bf5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java @@ -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. + * + *

운영 환경에서는 {@link InternalApiSecurityConfig}와 함께 등록되지 않으므로 노출되지 않는다.

+ */ +@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> process( + @Valid @RequestBody InternalVocabProcessReq req + ) { + List paragraphs = paragraphRepository.findByFairytaleIdAndPage( + req.getFairytaleId(), req.getPageNo() + ); + if (paragraphs.isEmpty()) { + throw new ParagraphNotFoundForVocabException(); + } + List 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) { + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java new file mode 100644 index 0000000..c007ed4 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java @@ -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>> getVocab( + @AuthUser User user, + @PathVariable Long fairytaleId + ) { + List entries = vocabService.getVocab(user.getId(), fairytaleId); + return ResponseEntity.status(HttpStatus.OK) + .body(SuccessResponse.ok(entries)); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java new file mode 100644 index 0000000..be37c8e --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java @@ -0,0 +1,16 @@ +package com.capstone.kkumteul.domain.vocab.web.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class InternalVocabProcessReq { + + @NotNull + private Long fairytaleId; + + @NotNull + @Min(1) + private Integer pageNo; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java new file mode 100644 index 0000000..0ce0442 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java @@ -0,0 +1,20 @@ +package com.capstone.kkumteul.domain.vocab.web.dto; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; + +import java.util.List; + +public record WordEntryRes( + Long wordEntryId, + int pageNo, + String word, + String meaning +) { + public static WordEntryRes from(WordEntry entry) { + return new WordEntryRes(entry.getId(), entry.getPageNo(), entry.getWord(), entry.getMeaning()); + } + + public static List listOf(List entries) { + return entries.stream().map(WordEntryRes::from).toList(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java b/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java new file mode 100644 index 0000000..88e09be --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java @@ -0,0 +1,79 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.global.client.dto.VocabExtractRequest; +import com.capstone.kkumteul.global.client.dto.VocabExtractResponse; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * FastAPI(AI 서버)의 단어장 추출 엔드포인트 호출 클라이언트. + * + *

POST {fastApiBaseUrl}/vocab/extract 호출 → {word, meaning} JSON 응답. + * timeout/5xx에 한해 1회 retry, 4xx는 즉시 실패. + * 호출자에 예외를 전파하지 않고 {@code Optional.empty()}로 fail-open.

+ */ +@Slf4j +@Service +public class VocabExtractClient { + + private final RestTemplate vocabRestTemplate; + private final String fastApiBaseUrl; + + public VocabExtractClient( + @Qualifier("vocabRestTemplate") RestTemplate vocabRestTemplate, + @Value("${fastapi.base-url:http://localhost:8000}") String fastApiBaseUrl + ) { + this.vocabRestTemplate = vocabRestTemplate; + this.fastApiBaseUrl = fastApiBaseUrl; + } + + /** + * 부팅 시 FastAPI 헬스체크를 한 번 호출해 cold-start 비용을 미리 흡수. + * 실패해도 부팅은 계속 (WARN만 남김). + */ + @PostConstruct + public void warmup() { + try { + vocabRestTemplate.getForObject(fastApiBaseUrl + "/health", String.class); + log.info("FastAPI warmup 성공: {}", fastApiBaseUrl); + } catch (RestClientException e) { + log.warn("FastAPI warmup 실패 (서버 미기동 가능): {}", e.getMessage()); + } + } + + /** + * 3문장에서 어려운 단어 1개와 풀이를 추출. + * timeout/5xx 시 1회 retry. 모두 실패하면 {@code Optional.empty()}. + */ + public Optional extract(List sentences) { + for (int attempt = 1; attempt <= 2; attempt++) { + try { + VocabExtractResponse response = vocabRestTemplate.postForObject( + fastApiBaseUrl + "/vocab/extract", + new VocabExtractRequest(sentences), + VocabExtractResponse.class + ); + return Optional.ofNullable(response); + } catch (ResourceAccessException | HttpServerErrorException retryable) { + log.warn("vocab extract 일시적 실패 attempt={}: {}", attempt, retryable.getMessage()); + } catch (RestClientException e) { + HttpStatusCode status = (e instanceof org.springframework.web.client.HttpStatusCodeException hse) + ? hse.getStatusCode() : null; + log.warn("vocab extract 실패 (status={}): {}", status, e.getMessage()); + return Optional.empty(); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java new file mode 100644 index 0000000..19ff9f7 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java @@ -0,0 +1,13 @@ +package com.capstone.kkumteul.global.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class VocabExtractRequest { + + private List sentences; +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java new file mode 100644 index 0000000..eb4dad8 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.global.client.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class VocabExtractResponse { + + /** LLM이 선택한 어려운 단어. 없으면 null 또는 빈 문자열. */ + private String word; + + /** 유아 눈높이 풀이. */ + private String meaning; +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java b/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java new file mode 100644 index 0000000..6888fca --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java @@ -0,0 +1,35 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +/** + * dev 프로필 한정 — {@code /internal/**} 경로를 인증 없이 통과시키는 SecurityFilterChain. + * + *

운영(prod) 프로필에선 이 빈이 등록되지 않으므로 + * 기존 {@link SecurityConfig}의 {@code .anyRequest().authenticated()}가 그대로 적용된다.

+ * + *

{@link com.capstone.kkumteul.global.jwt.JwtTokenFilter}는 {@code @Component}로 + * 모든 요청에 적용되지만, 토큰이 없으면 SecurityContext에 인증 객체를 설정하지 않으므로 + * permitAll된 {@code /internal/**}은 정상 통과된다.

+ */ +@Configuration +@Profile("dev") +public class InternalApiSecurityConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain internalApiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/internal/**") + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java index 4d7ecea..9e0cf3e 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java @@ -1,9 +1,12 @@ package com.capstone.kkumteul.global.config; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; +import java.time.Duration; + @Configuration public class RestTemplateConfig { @@ -11,4 +14,17 @@ public class RestTemplateConfig { public RestTemplate restTemplate() { return new RestTemplate(); } + + /** + * 단어장 추출용 RestTemplate. + * 페이지당 1회 호출로 응답 빠르게 받아야 하므로 짧은 timeout 적용. + * (기본 restTemplate은 GraphService 등 그래프 추출에 쓰여 timeout 길어도 무방) + */ + @Bean + public RestTemplate vocabRestTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ofSeconds(4)) + .build(); + } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..dec2ce8 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,8 @@ +# dev 프로필 전용 설정. +# 활성화 방법: -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev +# +# dev 프로필일 때 추가 동작: +# - InternalApiSecurityConfig 등록 → /internal/** 경로 인증 면제 +# - InternalVocabController 등록 → POST /internal/vocab/process 시연용 API 노출 +# +# 운영 환경에서는 이 프로필을 활성화하지 말 것.