diff --git a/.gitignore b/.gitignore index bba9ce9..d2b4a92 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ src/main/resources/application.properties ### Claude Code ### .omc +.claude/ diff --git a/build.gradle b/build.gradle index d2b05bd..8a2b888 100644 --- a/build.gradle +++ b/build.gradle @@ -29,19 +29,46 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.kafka:spring-kafka' + testImplementation 'org.springframework.kafka:spring-kafka-test' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.awaitility:awaitility:4.2.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // ArchUnit + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + + // .env 파일 로딩 + implementation 'me.paulschwarz:spring-dotenv:4.0.0' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + excludeTags 'kafka-broker' + } +} + +tasks.register('kafkaBrokerTest', Test) { + description = 'Run kafka-broker tagged tests (EmbeddedKafka 또는 실제 broker 의존)' + group = 'verification' + useJUnitPlatform { + includeTags 'kafka-broker' + } + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + shouldRunAfter test } diff --git a/docker-compose.yml b/docker-compose.yml index 438a563..47cf218 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,19 @@ services: - "8080" depends_on: - fastapi + - redis + networks: + - kkumteul + + redis: + image: redis:latest + container_name: redis + restart: always + command: redis-server --appendonly yes + volumes: + - redis-data:/data + expose: + - "6379" networks: - kkumteul @@ -42,3 +55,6 @@ services: networks: kkumteul: driver: bridge + +volumes: + redis-data: diff --git a/src/main/java/com/capstone/kkumteul/KkumteulApplication.java b/src/main/java/com/capstone/kkumteul/KkumteulApplication.java index 0c47d23..41b5660 100644 --- a/src/main/java/com/capstone/kkumteul/KkumteulApplication.java +++ b/src/main/java/com/capstone/kkumteul/KkumteulApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableCaching public class KkumteulApplication { public static void main(String[] args) { diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java index 9a41fca..9888ca2 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java @@ -5,6 +5,9 @@ import jakarta.persistence.*; import lombok.*; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @@ -36,6 +39,11 @@ public class Fairytale extends BaseEntity { @Column(nullable = false) private Background background; - @Column(columnDefinition = "TEXT") + @Column(columnDefinition = "TEXT", nullable = false) private String content; + + @Builder.Default + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List paragraphs = new ArrayList<>(); + } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java index b012c69..8d11764 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java @@ -25,4 +25,12 @@ public class Paragraph extends BaseEntity { @Column(nullable = false, columnDefinition = "TEXT") private String text; + + //nullable 제약은 추후 + @Column + private String imageUrl; + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java index ad82647..4c3abb1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java @@ -4,7 +4,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; -import static com.capstone.kkumteul.global.constant.StaticValue.*; +import static com.capstone.kkumteul.global.constant.StaticValue.NOT_FOUND; @Getter @AllArgsConstructor 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/fairytale/service/FairytaleCheckService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java new file mode 100644 index 0000000..8725643 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java @@ -0,0 +1,12 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +public interface FairytaleCheckService { + + void markVocabDone(Long fairytaleId, int page); + + void markImageDone(Long fairytaleId, int page); + + boolean isBothDone(Long fairytaleId, int page); + + void markTotalPages(Long fairytaleId, int totalPages); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java new file mode 100644 index 0000000..5988c92 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -0,0 +1,141 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; +import com.capstone.kkumteul.domain.fairytale.web.dto.SseEventRes; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FairytaleCheckServiceImpl implements FairytaleCheckService { + + private final RedisTemplate redisTemplate; + private final SseService sseService; + private final WordEntryRepository wordEntryRepository; + private final ParagraphRepository paragraphRepository; + + @Value("${vocab.fallback-threshold-seconds:300}") + private long vocabFallbackThresholdSeconds; + + private static final String VOCAB_KEY = "vocab:%d:%d"; + private static final String IMAGE_KEY = "image:%d:%d"; + private static final String TOTAL_KEY = "total:%d"; + private static final String SENT_KEY = "sent:%d"; + private static final String DONE = "done"; + + @Override + public void markVocabDone(Long fairytaleId, int page) { + redisTemplate.opsForValue().set(String.format(VOCAB_KEY, fairytaleId, page), DONE); + log.info("[VOCAB DONE] fairytaleId={}, page={}", fairytaleId, page); + checkAndSend(fairytaleId, page); + } + + @Override + public void markImageDone(Long fairytaleId, int page) { + redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE); + log.info("[IMAGE DONE] fairytaleId={}, page={}", fairytaleId, page); + forceVocabIfStale(fairytaleId, page); + checkAndSend(fairytaleId, page); + } + + /** + * image done 시점에 vocab 마커가 없고 paragraph 생성 후 임계 초과면 빈 vocab으로 강제 mark. + * AI Producer가 vocab_extracted를 누락한 경우의 SSE hang을 방지한다. + */ + private void forceVocabIfStale(Long fairytaleId, int page) { + String vocabKey = String.format(VOCAB_KEY, fairytaleId, page); + if (redisTemplate.opsForValue().get(vocabKey) != null) return; + + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); + if (paragraphs.isEmpty()) return; + + LocalDateTime created = paragraphs.getFirst().getCreatedAt(); + if (created == null) return; + long ageSeconds = Duration.between(created, LocalDateTime.now()).getSeconds(); + if (ageSeconds < vocabFallbackThresholdSeconds) return; + + log.warn("vocab fallback fired fairytaleId={}, page={}, ageSeconds={}", fairytaleId, page, ageSeconds); + redisTemplate.opsForValue().set(vocabKey, DONE); + } + + @Override + public boolean isBothDone(Long fairytaleId, int page) { + String vocabStatus = redisTemplate.opsForValue().get(String.format(VOCAB_KEY, fairytaleId, page)); + String imageStatus = redisTemplate.opsForValue().get(String.format(IMAGE_KEY, fairytaleId, page)); + return DONE.equals(vocabStatus) && DONE.equals(imageStatus); + } + + //sse전송 + private void checkAndSend(Long fairytaleId, int page) { + boolean both = isBothDone(fairytaleId, page); + log.info("[CHECK] fairytaleId={}, page={}, isBothDone={}", fairytaleId, page, both); + if (!both) return; + + Optional wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page); + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); + + if (paragraphs.isEmpty()) { + sseService.sendToClient(fairytaleId, "error", "문단 데이터 없음"); + log.warn("SSE 발송 실패 - 문단 없음 fairytaleId={}, page={}", fairytaleId, page); + return; + } + + Paragraph paragraph = paragraphs.getFirst(); + List sentences = List.of(paragraph.getText().split("\n")); + SseEventRes.Vocabulary vocab = wordEntry + .map(w -> new SseEventRes.Vocabulary(w.getWord(), w.getMeaning())) + .orElse(null); + + SseEventRes event = new SseEventRes( + fairytaleId, + page, + sentences, + vocab, + paragraph.getImageUrl() + ); + + log.info("[PAGE_CONTENT SEND] fairytaleId={}, page={}", fairytaleId, page); + sseService.sendToClient(fairytaleId, "page_content", event); + + redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page)); + redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page)); + + Long sent = redisTemplate.opsForValue().increment(String.format(SENT_KEY, fairytaleId)); + log.info("[SENT COUNT] fairytaleId={}, sent={}", fairytaleId, sent); + checkAndSendDone(fairytaleId, sent); + } + + @Override + public void markTotalPages(Long fairytaleId, int totalPages) { + redisTemplate.opsForValue().set(String.format(TOTAL_KEY, fairytaleId), String.valueOf(totalPages)); + log.info("[TOTAL SET] fairytaleId={}, totalPages={}", fairytaleId, totalPages); + String sentStr = redisTemplate.opsForValue().get(String.format(SENT_KEY, fairytaleId)); + long sent = sentStr == null ? 0L : Long.parseLong(sentStr); + checkAndSendDone(fairytaleId, sent); + } + + private void checkAndSendDone(Long fairytaleId, Long sent) { + String totalStr = redisTemplate.opsForValue().get(String.format(TOTAL_KEY, fairytaleId)); + if (totalStr == null) return; + + if (sent >= Long.parseLong(totalStr)) { + sseService.sendToClient(fairytaleId, "done", String.valueOf(fairytaleId)); + redisTemplate.delete(String.format(TOTAL_KEY, fairytaleId)); + redisTemplate.delete(String.format(SENT_KEY, fairytaleId)); + log.info("SSE done 전송 fairytaleId={}", fairytaleId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java new file mode 100644 index 0000000..8b69ebc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java @@ -0,0 +1,8 @@ +package com.capstone.kkumteul.domain.fairytale.service.sse; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +public interface SseService { + SseEmitter subscribe(Long fairytaleId); + void sendToClient(Long fairytaleId, String eventName, Object data); +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java new file mode 100644 index 0000000..7266bf1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java @@ -0,0 +1,50 @@ +package com.capstone.kkumteul.domain.fairytale.service.sse; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class SseServiceImpl implements SseService { + private final Map sseEmittersMap = new ConcurrentHashMap<>(); + + public SseEmitter subscribe(Long fairytaleId) { + long timeout = 1000L * 60 * 60; + + SseEmitter emitter = new SseEmitter(timeout); + sseEmittersMap.put(fairytaleId, emitter); + + emitter.onCompletion(() -> sseEmittersMap.remove(fairytaleId)); //complete시 콜백함수 + emitter.onTimeout(() -> sseEmittersMap.remove(fairytaleId)); //타임아웃시 삭제 + emitter.onError(e -> { + log.error("SSE 에러 fairytaleId={}", fairytaleId, e); + sseEmittersMap.remove(fairytaleId); + }); //전송중 에러시 삭제 + + //연결 성공시 + sendToClient(fairytaleId, "connect", "sse connect..."); + + return emitter; + } + + public void sendToClient(Long fairytaleId, String eventName, Object data) { + SseEmitter emitter = sseEmittersMap.get(fairytaleId); + if (emitter == null) return; + try { + emitter.send(SseEmitter.event() + .name(eventName) + .data(data)); + if ("done".equals(eventName)) { + emitter.complete(); //sse 스트림 종료 + } + } catch (IOException e) { + log.error("SSE 전송 실패 fairytaleId={}", fairytaleId, e); + sseEmittersMap.remove(fairytaleId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index 8780372..0373c1b 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -3,25 +3,46 @@ import com.capstone.kkumteul.domain.fairytale.entity.Island; import com.capstone.kkumteul.domain.fairytale.service.FairytaleService; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleDetailRes; +import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleListRes; +import com.capstone.kkumteul.domain.kafka.service.EventService; import com.capstone.kkumteul.domain.user.entity.User; import com.capstone.kkumteul.global.response.SuccessResponse; import com.capstone.kkumteul.global.security.AuthUser; + +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor -@RequestMapping("/api/fairytales") +@RequestMapping("/fairytales") public class FairytaleController { private final FairytaleService fairytaleService; + private final SseService sseService; + private final EventService eventService; + + @PostMapping + public ResponseEntity> createFairytale( + @AuthUser User user, + @Valid @RequestBody FairytaleGenerateReq request + ) { + Long fairytaleId = eventService.createFairytaleMessageSend(user, request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(SuccessResponse.created(fairytaleId)); + } + @GetMapping("/my") public ResponseEntity>> getMyFairytales( @AuthUser User user, @@ -49,4 +70,9 @@ public ResponseEntity> getFairytaleDetail( FairytaleDetailRes res = fairytaleService.getFairytaleDetail(fairytaleId); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); } + + @GetMapping(value="/{fairytaleId}/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe(@PathVariable Long fairytaleId){ + return sseService.subscribe(fairytaleId); + } } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java new file mode 100644 index 0000000..386732a --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java @@ -0,0 +1,23 @@ +package com.capstone.kkumteul.domain.fairytale.web.dto; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FairytaleGenerateReq { + + @NotNull(message = "배경은 비어있을 수 없습니다.") + private final Background background; + + @NotNull(message = "등장인물 종류는 비어있을 수 없습니다.") + private final CharSpecies charSpecies; + + @NotNull(message = "교훈은 비어있을 수 없습니다.") + private final Morality morality; + +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java index 063148c..b5bca26 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java @@ -2,14 +2,18 @@ import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import java.util.List; + public record ParagraphRes( int page, - String text + List sentences, + String imageUrl ) { public static ParagraphRes from(Paragraph paragraph) { return new ParagraphRes( paragraph.getPage(), - paragraph.getText() + List.of(paragraph.getText().split("\n")), + paragraph.getImageUrl() ); } } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java new file mode 100644 index 0000000..7f6e98d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.domain.fairytale.web.dto; + +import java.util.List; + +public record SseEventRes( + Long fairytaleId, + int pageNo, + List text, + Vocabulary vocab, + String imageUrl +) { public record Vocabulary( + String word, + String meaning +){} +} diff --git a/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java b/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java index cd29c58..3cf97a6 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java @@ -1,10 +1,9 @@ package com.capstone.kkumteul.domain.game.entity; +import com.capstone.kkumteul.domain.game.exception.InvalidCategoryException; import lombok.AllArgsConstructor; import lombok.Getter; -import com.capstone.kkumteul.domain.game.exception.InvalidCategoryException; - import java.util.Arrays; @Getter diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java index 8bc1165..dddde85 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java @@ -18,7 +18,10 @@ public enum GameErrorCode implements BaseResponseCode { ALREADY_ANSWERED("GAME_409_2", CONFLICT, "이미 완료한 엣지입니다."), QUIZ_NOT_FOUND("GAME_404_4", NOT_FOUND, "퀴즈를 찾을 수 없습니다."), EDGE_NOT_FOUND("GAME_404_5", NOT_FOUND, "해당 관계를 찾을 수 없습니다."), - GAME_NOT_COMPLETED("GAME_404_6", NOT_FOUND, "완료된 게임이 없습니다."); + GAME_NOT_COMPLETED("GAME_404_6", NOT_FOUND, "완료된 게임이 없습니다."), + GAME_FORBIDDEN("GAME_403_1", FORBIDDEN, "본인 동화가 아닙니다."), + GRAPH_EXTRACT_FAILED("GAME_502_1", BAD_GATEWAY, "지식그래프 추출에 실패했습니다."), + INVALID_GRAPH_PAYLOAD("GAME_500_1", INTERNAL_SERVER_ERROR, "지식그래프 응답이 유효하지 않습니다."); private final String code; private final int httpStatus; diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java new file mode 100644 index 0000000..24216f6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class GameForbiddenException extends BaseException { + + public GameForbiddenException() { + super(GameErrorCode.GAME_FORBIDDEN); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java new file mode 100644 index 0000000..776c6db --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class GraphExtractFailedException extends BaseException { + + public GraphExtractFailedException() { + super(GameErrorCode.GRAPH_EXTRACT_FAILED); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java new file mode 100644 index 0000000..a2d569f --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class InvalidGraphPayloadException extends BaseException { + + public InvalidGraphPayloadException() { + super(GameErrorCode.INVALID_GRAPH_PAYLOAD); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java index 7aee940..cdf6f20 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java @@ -15,4 +15,6 @@ public interface GameService { EdgeDetailRes getEdgeDetail(Long userId, Long edgeId); GraphDetailRes getGraph(Long userId, Long fairytaleId); + + GameStatusRes getStatus(Long userId, Long fairytaleId); } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 68fe4c7..89b9439 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -1,23 +1,36 @@ package com.capstone.kkumteul.domain.game.service; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.game.entity.EdgeChoice; +import com.capstone.kkumteul.domain.game.entity.GameResult; import com.capstone.kkumteul.domain.game.entity.GraphEdge; import com.capstone.kkumteul.domain.game.entity.GraphNode; import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GameResult; -import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; -import com.capstone.kkumteul.domain.game.exception.*; +import com.capstone.kkumteul.domain.game.exception.AlreadyAnsweredException; +import com.capstone.kkumteul.domain.game.exception.EdgeNotFoundException; +import com.capstone.kkumteul.domain.game.exception.GameAlreadyCompletedException; +import com.capstone.kkumteul.domain.game.exception.GameForbiddenException; +import com.capstone.kkumteul.domain.game.exception.GameNotCompletedException; +import com.capstone.kkumteul.domain.game.exception.GraphNotFoundException; +import com.capstone.kkumteul.domain.game.exception.InvalidEdgeException; +import com.capstone.kkumteul.domain.game.exception.QuizNotFoundException; import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; import com.capstone.kkumteul.domain.game.repository.GameResultRepository; import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; -import com.capstone.kkumteul.domain.game.web.dto.*; +import com.capstone.kkumteul.domain.game.web.dto.ClassifyRes; +import com.capstone.kkumteul.domain.game.web.dto.EdgeDetailRes; +import com.capstone.kkumteul.domain.game.web.dto.GameStartRes; +import com.capstone.kkumteul.domain.game.web.dto.GameStatusRes; +import com.capstone.kkumteul.domain.game.web.dto.GraphDetailRes; +import com.capstone.kkumteul.domain.game.web.dto.QuizAnswerRes; +import com.capstone.kkumteul.domain.game.web.dto.QuizRes; import com.capstone.kkumteul.domain.user.entity.User; -import com.capstone.kkumteul.global.client.GraphService; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,19 +48,9 @@ public class GameServiceImpl implements GameService { private final GameResultRepository gameResultRepository; private final GameSessionManager sessionManager; private final EntityManager entityManager; - private final GraphService graphService; /** - * 게임 시작 — POST /game/start - * - *

처리 흐름:

- *
    - *
  1. 동화 존재 확인 (EntityManager.find → 없으면 404)
  2. - *
  3. game_results에서 (userId, fairytaleId) 조회 → completed=true면 409
  4. - *
  5. graph_nodes에서 fairytaleId로 그래프 존재 확인 → 없으면 404
  6. - *
  7. 기존 세션 제거 (뒤로 가기 후 재진입 시 처음부터 재시작)
  8. - *
  9. 노드/엣지 DB 조회 → 인메모리 세션에 캐싱
  10. - *
+ * 그래프는 Kafka consumer 가 비동기로 추출하므로, 본 메서드에서 동기 폴백 호출은 하지 않는다. */ @Override @Transactional @@ -66,10 +69,10 @@ public GameStartRes startGame(Long userId, Long fairytaleId) { } }); - // graph_nodes 테이블에서 fairytaleId로 그래프 존재 확인 → 없으면 FastAPI 호출 + // graph_nodes 테이블에서 fairytaleId 로 그래프 존재 확인 → 없으면 즉시 404 if (!graphNodeRepository.existsByFairytaleId(fairytaleId)) { - log.info("그래프 미존재 — FastAPI 추출 호출: fairytaleId={}", fairytaleId); - graphService.extractAndSave(fairytale, fairytale.getContent()); + log.warn("그래프 미존재 — fairytaleId={}", fairytaleId); + throw new GraphNotFoundException(); } // 기존 세션 제거 — 뒤로 가기 후 재진입 시 새 세션으로 처음부터 @@ -85,16 +88,6 @@ public GameStartRes startGame(Long userId, Long fairytaleId) { return GameStartRes.of(session.getSessionId(), session.getNodes()); } - /** - * 1단계 바구니 분류 — POST /game/classify - * - *

드래그할 때마다 즉시 호출. 세션 캐싱 데이터로 채점하므로 DB 조회 없음.

- *
    - *
  • 이미 정답 처리된 노드 재제출 → correct: true (멱등성 보장)
  • - *
  • 카테고리 한글 라벨("등장인물") → NodeCategory.fromLabel()로 변환 후 비교
  • - *
  • 전체 노드 분류 완료 시 stage_complete=true + 2단계 데이터(노드+카테고리+총 엣지 수) 반환
  • - *
- */ @Override public ClassifyRes classify(String sessionId, Long nodeId, String category) { // 세션 조회 + TTL 갱신 (없으면 404) @@ -121,17 +114,6 @@ public ClassifyRes classify(String sessionId, Long nodeId, String category) { return ClassifyRes.correct(false); } - /** - * 2단계 퀴즈 요청 — POST /game/quiz - * - *

두 노드 사이에 선을 그을 때 호출.

- *
    - *
  • 양방향 매칭으로 엣지 조회 (A→B든 B→A든 동일 엣지)
  • - *
  • 이미 정답 처리된 엣지면 409 (ALREADY_ANSWERED)
  • - *
  • 유효하지 않은 조합이면 400 (INVALID_EDGE)
  • - *
  • 보기 3개는 랜덤 셔플하여 반환
  • - *
- */ @Override public QuizRes requestQuiz(String sessionId, Long fromNodeId, Long toNodeId) { GameSession session = sessionManager.get(sessionId); @@ -155,14 +137,7 @@ public QuizRes requestQuiz(String sessionId, Long fromNodeId, Long toNodeId) { } /** - * 2단계 퀴즈 정답 제출 — POST /game/quiz/answer - * - *

choice_id(PK)로 정답 제출. 텍스트 매칭 대신 PK 비교로 안전 채점.

- *
    - *
  • 정답 시 description 반환 → 앱에서 관계 설명 모달 표시
  • - *
  • 오답 시 힌트 반환 (재시도 제한 없음 — 유아 대상)
  • - *
  • 모든 엣지 완료 시 game_results 자동 저장 + 완성된 그래프 반환
  • - *
+ * 텍스트 매칭이 아닌 choice_id(PK) 비교로 채점한다. */ @Override @Transactional @@ -179,18 +154,15 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh EdgeChoice selectedChoice = edgeChoiceRepository.findById(selectedChoiceId) .orElseThrow(QuizNotFoundException::new); - // 선택한 보기가 해당 엣지에 속하는지 검증 - if (!selectedChoice.getEdge().getId().equals(edgeId)) { + if (!selectedChoice.getEdge().getId().equals(edgeId) || !selectedChoice.isAnswer()) { return QuizAnswerRes.incorrect(); } - if (!selectedChoice.isAnswer()) { - return QuizAnswerRes.incorrect(); + // 정답 처리 — 엣지 완료 표시 (description 은 세션 캐시에서 읽음) + GameSession.SessionEdge edge = session.findEdge(edgeId); + if (edge == null) { + throw new InvalidEdgeException(); } - - // 정답 처리 — 엣지 완료 표시 - GraphEdge edge = graphEdgeRepository.findById(edgeId) - .orElseThrow(InvalidEdgeException::new); session.markEdgeCompleted(edgeId); // 모든 엣지 완료 → 2단계 종료 @@ -205,39 +177,20 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh return QuizAnswerRes.correct(edge.getDescription()); } - /** - * 3단계 엣지 상세 조회 — GET /game/edge - * - *

관계도 화면에서 선 클릭 시 호출. edge_id로 단건 조회 후 description 반환.

- *
    - *
  • edge_id → graph_edges 조회 (없으면 404)
  • - *
  • fromNode의 fairytale_id로 game_results 검증 → 본인 완료 데이터가 아니면 403
  • - *
- */ @Override public EdgeDetailRes getEdgeDetail(Long userId, Long edgeId) { GraphEdge edge = graphEdgeRepository.findById(edgeId) .orElseThrow(EdgeNotFoundException::new); - // fromNode → fairytale → game_results에서 해당 유저의 완료 여부 검증 - Long fairytaleId = edge.getFromNode().getFairytale().getId(); - validateGameCompleted(userId, fairytaleId); + Fairytale fairytale = edge.getFromNode().getFairytale(); + validateOwnedAndCompleted(userId, fairytale); return EdgeDetailRes.from(edge); } - /** - * 3단계 전체 관계도 조회 — GET /game/graph - * - *

동화 모음집에서 '관계도' 버튼 클릭 시 호출. 완성된 그래프(노드+엣지)를 반환.

- *
    - *
  • game_results에서 (userId, fairytaleId) 완료 검증 → 미완료/미존재 시 404
  • - *
  • graph_nodes + graph_edges 조회 후 반환
  • - *
- */ @Override public GraphDetailRes getGraph(Long userId, Long fairytaleId) { - validateGameCompleted(userId, fairytaleId); + validateGraphCompleted(userId, fairytaleId); List nodes = graphNodeRepository.findByFairytaleId(fairytaleId); List edges = graphEdgeRepository.findByFairytaleId(fairytaleId); @@ -246,19 +199,59 @@ public GraphDetailRes getGraph(Long userId, Long fairytaleId) { } /** - * 게임 완료 여부 검증 — 3단계 조회 API 공통. - * game_results에서 (userId, fairytaleId) 조합으로 completed=true인지 확인. - * 결과가 없거나 미완료면 GameNotCompletedException. + * 게임 완료 여부 조회 — GET /api/game/status + * + *

앱 진입 시 "동화 해설" 버튼 분기에 사용. fairytale 미존재만 404, 그 외에는 completed boolean 으로 반환한다.

*/ - private void validateGameCompleted(Long userId, Long fairytaleId) { - GameResult result = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + @Override + public GameStatusRes getStatus(Long userId, Long fairytaleId) { + Fairytale fairytale = entityManager.find(Fairytale.class, fairytaleId); + if (fairytale == null) { + throw new FairytaleNotFoundException(); + } + + boolean completed = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + .map(GameResult::isCompleted) + .orElse(false); + + return GameStatusRes.of(fairytaleId, completed); + } + + /** + * 본인 동화 + 게임 완료 여부 검증 — GET /game/edge 전용. + *

① 동화 소유권: fairytale.user.id != userId 면 {@link GameForbiddenException} (403). + * ② 완료 여부: game_results.completed != true 면 {@link GameNotCompletedException} (404).

+ */ + private void validateOwnedAndCompleted(Long userId, Fairytale fairytale) { + if (!fairytale.getUser().getId().equals(userId)) { + throw new GameForbiddenException(); + } + GameResult result = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytale.getId()) .orElseThrow(GameNotCompletedException::new); if (!result.isCompleted()) { throw new GameNotCompletedException(); } } - /** game_results INSERT — 2단계 완료 시 서버가 자동 저장 (앱 크래시 대비) */ + /** + * 완성된 그래프 조회 자격 검증 — GET /game/graph 전용. + *

game_results.completed != true (또는 row 없음) 인 경우를 모두 {@link GraphNotFoundException} (404 GRAPH_NOT_FOUND) 로 통일.

+ */ + private void validateGraphCompleted(Long userId, Long fairytaleId) { + boolean completed = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + .map(GameResult::isCompleted) + .orElse(false); + if (!completed) { + throw new GraphNotFoundException(); + } + } + + /** + * game_results INSERT — 2단계 완료 시 서버가 자동 저장 (앱 크래시 대비). + * + *

(userId, fairytaleId) UNIQUE 제약이 걸려 있어 동시 INSERT 시 한쪽은 {@link DataIntegrityViolationException} 을 던진다. + * race 패자는 INFO 로 흡수하고 정상 흐름으로 반환한다.

+ */ private void saveGameResult(GameSession session) { if (gameResultRepository.existsByUserIdAndFairytaleId(session.getUserId(), session.getFairytaleId())) { return; @@ -270,6 +263,11 @@ private void saveGameResult(GameSession session) { .fairytale(fairytale) .completed(true) .build(); - gameResultRepository.save(result); + try { + gameResultRepository.save(result); + } catch (DataIntegrityViolationException e) { + log.info("game result race 흡수 userId={}, fairytaleId={}", + session.getUserId(), session.getFairytaleId()); + } } } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java index a583a19..91ff62a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java @@ -7,10 +7,16 @@ import lombok.Getter; import java.time.LocalDateTime; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -@Getter public class GameSession { private final String sessionId; @@ -18,6 +24,7 @@ public class GameSession { private final Long fairytaleId; private final Map nodeMap; private final List edges; + private final Map edgeMap; private final Set classifiedNodeIds; private final Set completedEdgeIds; private final Map quizEdgeMap; @@ -35,12 +42,44 @@ public GameSession(Long userId, Long fairytaleId, List nodes, List(); + for (SessionEdge edge : this.edges) { + this.edgeMap.put(edge.getId(), edge); + } this.classifiedNodeIds = ConcurrentHashMap.newKeySet(); this.completedEdgeIds = ConcurrentHashMap.newKeySet(); this.quizEdgeMap = new ConcurrentHashMap<>(); this.lastActivity = LocalDateTime.now(); } + public String getSessionId() { + return sessionId; + } + + public Long getUserId() { + return userId; + } + + public Long getFairytaleId() { + return fairytaleId; + } + + public Collection getNodes() { + return Collections.unmodifiableCollection(nodeMap.values()); + } + + public List getEdges() { + return edges; + } + + public int getTotalEdges() { + return edges.size(); + } + + public SessionEdge findEdge(Long edgeId) { + return edgeMap.get(edgeId); + } + public void touch() { this.lastActivity = LocalDateTime.now(); } @@ -91,14 +130,6 @@ public Long getEdgeIdByQuizId(String quizId) { return quizEdgeMap.get(quizId); } - public int getTotalEdges() { - return edges.size(); - } - - public Collection getNodes() { - return nodeMap.values(); - } - @Getter public static class SessionNode { private final Long id; @@ -121,15 +152,22 @@ public static class SessionEdge { private final Long id; private final Long fromNodeId; private final Long toNodeId; + private final String description; - private SessionEdge(Long id, Long fromNodeId, Long toNodeId) { + private SessionEdge(Long id, Long fromNodeId, Long toNodeId, String description) { this.id = id; this.fromNodeId = fromNodeId; this.toNodeId = toNodeId; + this.description = description; } public static SessionEdge from(GraphEdge edge) { - return new SessionEdge(edge.getId(), edge.getFromNode().getId(), edge.getToNode().getId()); + return new SessionEdge( + edge.getId(), + edge.getFromNode().getId(), + edge.getToNode().getId(), + edge.getDescription() + ); } } } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java b/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java index 3fd5e4f..48620d5 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java @@ -72,4 +72,14 @@ public ResponseEntity> getGraph( GraphDetailRes res = gameService.getGraph(user.getId(), fairytaleId); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); } + + /** 동화 해설 진입 분기용 — 게임 완료 여부 조회 */ + @GetMapping("/status") + public ResponseEntity> getStatus( + @AuthUser User user, + @RequestParam("fairytale_id") Long fairytaleId + ) { + GameStatusRes res = gameService.getStatus(user.getId(), fairytaleId); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); + } } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java b/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java new file mode 100644 index 0000000..015f9fc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.domain.game.web.dto; + +/** + * GET /api/game/status 응답 DTO. + * 앱이 동화 모음집에서 "동화 해설" 진입 시 게임 시작 / 관계도 조회 분기에 사용한다. + */ +public record GameStatusRes( + Long fairytaleId, + boolean completed +) { + + public static GameStatusRes of(Long fairytaleId, boolean completed) { + return new GameStatusRes(fairytaleId, completed); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java new file mode 100644 index 0000000..e2903e9 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java @@ -0,0 +1,56 @@ +package com.capstone.kkumteul.domain.kafka.consumer; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleCompletedMessage; +import com.capstone.kkumteul.domain.kafka.dto.ImageMessage; +import com.capstone.kkumteul.global.client.GraphExtractTrigger; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FairytaleKafkaConsumer { + + private final ParagraphRepository paragraphRepository; + private final FairytaleCheckService fairytaleCheckService; + private final GraphExtractTrigger graphExtractTrigger; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "fairytale_done", groupId = "kkumteul-group") + public void consumeDone(String message) { + try { + FairytaleCompletedMessage msg = objectMapper.readValue(message, FairytaleCompletedMessage.class); + fairytaleCheckService.markTotalPages(msg.getFairytaleId(), msg.getTotalPages()); + graphExtractTrigger.triggerAsync(msg.getFairytaleId()); + } catch (Exception e) { + log.error("fairytale_done 처리 실패 message={}", message, e); + } + } + + @KafkaListener(topics = "fairytale_image", groupId = "kkumteul-group") + public void consumeImage(String message) { + try { + ImageMessage img = objectMapper.readValue(message, ImageMessage.class); + log.info("[IMAGE RECEIVED] fairytaleId={}, page={}", img.getFairytaleId(), img.getPageNo()); + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(img.getFairytaleId(), img.getPageNo()); + if (paragraphs.isEmpty()) { + log.warn("이미지 저장 실패 - 문단 없음 fairytaleId={}, page={}", img.getFairytaleId(), img.getPageNo()); + return; + } + Paragraph paragraph = paragraphs.getFirst(); + paragraph.updateImageUrl(img.getImageurl()); + paragraphRepository.save(paragraph); + fairytaleCheckService.markImageDone(img.getFairytaleId(), img.getPageNo()); + } catch (Exception e) { + log.error("fairytale_image 처리 실패 message={}", message, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java new file mode 100644 index 0000000..ccb1740 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java @@ -0,0 +1,14 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FairytaleCompletedMessage { + @JsonProperty("fairytale_id") + private Long fairytaleId; + @JsonProperty("total_pages") + private int totalPages; +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java new file mode 100644 index 0000000..5f737ba --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java @@ -0,0 +1,21 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class FairytaleGenerateMessage implements MessageInterface { + + private final Long userId; + + private final Long fairytaleId; + private final Background background; + private final CharSpecies charSpecies; + private final Morality morality; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java new file mode 100644 index 0000000..49a4c8d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java @@ -0,0 +1,12 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ImageMessage { + private Long fairytaleId; + private int pageNo; + private String imageurl; +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java new file mode 100644 index 0000000..fd733ce --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java @@ -0,0 +1,6 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +public interface MessageInterface { + + Long getUserId(); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java new file mode 100644 index 0000000..e29a6db --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java @@ -0,0 +1,29 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AI 서버가 발행하는 vocab_extracted 토픽의 Consumer-only 메시지. + * + *

설계상 {@code MessageInterface}를 구현하지 않는다 — Producer-side marker이고 + * 본 DTO는 Consumer 단에서만 사용한다. {@code userId}/{@code messageId}는 정보용 필드로, + * 트레이싱/로그에만 쓰인다 (dedup 키로 사용 X).

+ */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class VocabExtractedMessage { + + private Long fairytaleId; + private int pageNo; + private String word; + private String meaning; + private Long userId; + private String messageId; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java new file mode 100644 index 0000000..81938b1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -0,0 +1,64 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; +import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/* 동화 생성 이벤트 전파 */ + +@Service +@Profile("!dev") +@Slf4j(topic = "event") +@RequiredArgsConstructor +public class EventService { + + private final KafkaTemplate kafkaTemplate; + private final FairytaleRepository fairytaleRepository; + + @Value("${FAIRYTALE_GENERATION}") + private String FAIRYTALE_GENERATION; + + @Transactional + public Long createFairytaleMessageSend(User user, FairytaleGenerateReq request) { + + Fairytale created = Fairytale.builder() + .user(user) + .title("NONE") + .content("") + .morality(request.getMorality()) + .background(request.getBackground()) + .charSpecies(request.getCharSpecies()) + .build(); + + Fairytale saved = fairytaleRepository.save(created); + + FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() + .userId(user.getId()) + .fairytaleId(saved.getId()) + .background(request.getBackground()) + .charSpecies(request.getCharSpecies()) + .morality(request.getMorality()) + .build(); + + log.info("fairytale_generate userId={}, message={}", user.getId(), message); + + kafkaTemplate.send(FAIRYTALE_GENERATION, message) + .whenComplete((result, e) -> { + if (e != null) { + log.error("fairytale_generate failed", e); + } + }); + + return saved.getId(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java new file mode 100644 index 0000000..658c4f1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java @@ -0,0 +1,34 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +/** + * vocab_extracted 토픽 Consumer. + * + *

처리 실패는 그대로 throw해서 {@link com.capstone.kkumteul.global.config.KafkaConsumerConfig}의 + * DefaultErrorHandler + DLT recoverer에 위임한다 (재시도 후 DLT 직전 markVocabDone 호출).

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class VocabExtractedListener { + + private final VocabService vocabService; + + @KafkaListener( + topics = "${VOCAB_EXTRACTED:vocab_extracted}", + groupId = "${VOCAB_EXTRACTED_GROUP_ID:kkumteul-vocab}", + containerFactory = "vocabKafkaListenerContainerFactory" + ) + public void onMessage(VocabExtractedMessage message) { + VocabExtractionResult result = vocabService.processExtractedWord(message); + log.info("vocab_extracted processed fairytaleId={}, pageNo={}, status={}, messageId={}", + message.getFairytaleId(), message.getPageNo(), result.status(), message.getMessageId()); + } +} 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..b390ef7 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java @@ -0,0 +1,20 @@ +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; +import java.util.Optional; + +@Repository +public interface WordEntryRepository extends JpaRepository { + + /** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */ + boolean existsByFairytaleIdAndWord(Long fairytaleId, String word); + + /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */ + List findByFairytaleIdOrderByPageNoAsc(Long fairytaleId); + + Optional findByFairytaleIdAndPageNo(Long fairytaleId, int pageNo); +} 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..d01de3a --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java @@ -0,0 +1,37 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +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); + + /** + * AI 서버가 vocab_extracted 토픽으로 발행한 메시지를 처리. + * word가 null/blank이면 NO_DIFFICULT_WORD 처리. 모든 종착 분기에서 markVocabDone 호출 (SSE guarantee). + */ + VocabExtractionResult processExtractedWord(VocabExtractedMessage message); + + /** + * 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순. + * + * @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..04f5ba9 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -0,0 +1,150 @@ +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.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +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; + private final FairytaleCheckService fairytaleCheckService; + + /** + * 페이지 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); + log.info("extracted={}",extracted); + if (extracted.isEmpty()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.extractionFailed(); + } + + VocabExtractResponse response = extracted.get(); + String word = response.getWord(); + String meaning = response.getMeaning(); + if (word == null || word.isBlank() || meaning == null || meaning.isBlank()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.noDifficultWord(); + } + + if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + 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); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.saved(saved); + } catch (DataIntegrityViolationException e) { + log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word); + return VocabExtractionResult.raceSkipped(); + } + } + + /** + * AI Producer가 vocab_extracted 토픽에 발행한 메시지를 처리. + * + *

모든 종착 분기에서 {@link FairytaleCheckService#markVocabDone}을 호출해 SSE hang을 막는다. + * RACE_SKIPPED 분기에서도 호출하는 점이 Phase 1 {@link #processSentences}와 의도적으로 다르다.

+ */ + @Override + @Transactional + public VocabExtractionResult processExtractedWord(VocabExtractedMessage message) { + Long fairytaleId = message.getFairytaleId(); + int pageNo = message.getPageNo(); + String word = message.getWord(); + String meaning = message.getMeaning(); + + if (word == null || word.isBlank()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.noDifficultWord(); + } + + if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + 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); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.saved(saved); + } catch (DataIntegrityViolationException e) { + log.info("vocab race condition (extracted) fairytaleId={}, word={}, messageId={}", + fairytaleId, word, message.getMessageId()); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + 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..f990633 --- /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 = List.of(paragraphs.getFirst().getText().split("\n")); + + 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/GraphExtractTrigger.java b/src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java new file mode 100644 index 0000000..f9ad7fb --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java @@ -0,0 +1,64 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 동화 본문이 모두 저장된 직후 호출되는 비동기 그래프 추출 트리거. + * + *

Kafka {@code fairytale_done} 컨슈머에서 호출하며, FastAPI {@code /graph/extract} 호출과 그래프 저장은 + * {@link GraphService} 가 담당한다. 본 메서드는 {@code @Async("graphExtractExecutor")} 로 호출되어 + * 컨슈머 스레드를 점유하지 않는다.

+ * + *

실패는 동화 생성 흐름에 영향을 주지 않도록 try/catch 로 흡수하고 ERROR 로깅만 남긴다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GraphExtractTrigger { + + private final FairytaleRepository fairytaleRepository; + private final ParagraphRepository paragraphRepository; + private final GraphNodeRepository graphNodeRepository; + private final GraphService graphService; + + @Async("graphExtractExecutor") + public void triggerAsync(Long fairytaleId) { + try { + if (graphNodeRepository.existsByFairytaleId(fairytaleId)) { + log.info("그래프 이미 존재 — skip fairytaleId={}", fairytaleId); + return; + } + + Fairytale fairytale = fairytaleRepository.findById(fairytaleId).orElse(null); + if (fairytale == null) { + log.warn("그래프 추출 보류 - 동화 미존재 fairytaleId={}", fairytaleId); + return; + } + + List paragraphs = paragraphRepository.findByFairytaleIdOrderByPageAsc(fairytaleId); + if (paragraphs.isEmpty()) { + log.warn("그래프 추출 보류 - paragraphs 미존재 fairytaleId={}", fairytaleId); + return; + } + + String content = paragraphs.stream() + .map(Paragraph::getText) + .collect(Collectors.joining(" ")); + + graphService.extractAndSave(fairytale, content); + } catch (Exception e) { + log.error("그래프 추출 실패 fairytaleId={}", fairytaleId, e); + } + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java new file mode 100644 index 0000000..36e80e6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java @@ -0,0 +1,80 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.game.entity.EdgeChoice; +import com.capstone.kkumteul.domain.game.entity.GraphEdge; +import com.capstone.kkumteul.domain.game.entity.GraphNode; +import com.capstone.kkumteul.domain.game.entity.NodeCategory; +import com.capstone.kkumteul.domain.game.exception.InvalidGraphPayloadException; +import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; +import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; +import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; +import com.capstone.kkumteul.global.client.dto.GraphExtractResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +/** + * 지식그래프 추출 응답을 DB 에 저장하는 빈. + * + *

{@link GraphService} 와 별도 빈으로 분리되어 있는 이유는 Spring AOP self-invocation 함정을 피하기 위함이다. + * {@code GraphService} 내부에서 {@code @Transactional} 메서드를 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않는다. + * 외부 I/O 는 {@code GraphService} 가, DB 저장은 본 빈이 담당한다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphPersister { + + private final GraphNodeRepository graphNodeRepository; + private final GraphEdgeRepository graphEdgeRepository; + private final EdgeChoiceRepository edgeChoiceRepository; + + @Transactional + public void persist(Fairytale fairytale, GraphExtractResponse response) { + // 1. graph_nodes 저장 + temp_id → real PK 매핑 + Map tempIdToNode = new HashMap<>(); + + for (GraphExtractResponse.NodeDto nodeDto : response.getNodes()) { + GraphNode node = GraphNode.builder() + .fairytale(fairytale) + .word(nodeDto.getWord()) + .category(NodeCategory.fromLabel(nodeDto.getCategory())) + .build(); + graphNodeRepository.save(node); + tempIdToNode.put(nodeDto.getTempId(), node); + } + + // 2. graph_edges + edge_choices 저장 + for (GraphExtractResponse.EdgeDto edgeDto : response.getEdges()) { + GraphNode fromNode = tempIdToNode.get(edgeDto.getFromTempId()); + GraphNode toNode = tempIdToNode.get(edgeDto.getToTempId()); + if (fromNode == null || toNode == null) { + throw new InvalidGraphPayloadException(); + } + + GraphEdge edge = GraphEdge.builder() + .fromNode(fromNode) + .toNode(toNode) + .description(edgeDto.getDescription()) + .build(); + graphEdgeRepository.save(edge); + + for (GraphExtractResponse.ChoiceDto choiceDto : edgeDto.getChoices()) { + EdgeChoice choice = EdgeChoice.builder() + .edge(edge) + .content(choiceDto.getContent()) + .isAnswer(choiceDto.getIsAnswer() != null && choiceDto.getIsAnswer()) + .build(); + edgeChoiceRepository.save(choice); + } + } + + log.info("그래프 저장 완료: fairytaleId={}, nodes={}, edges={}", + fairytale.getId(), response.getNodes().size(), response.getEdges().size()); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java index e652ff7..138bf99 100644 --- a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java @@ -1,39 +1,32 @@ package com.capstone.kkumteul.global.client; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GraphEdge; -import com.capstone.kkumteul.domain.game.entity.GraphNode; -import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; -import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; -import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; +import com.capstone.kkumteul.domain.game.exception.GraphExtractFailedException; import com.capstone.kkumteul.global.client.dto.GraphExtractRequest; import com.capstone.kkumteul.global.client.dto.GraphExtractResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; -import java.util.HashMap; -import java.util.Map; - +/** + * FastAPI 지식그래프 추출 클라이언트. + * + *

외부 I/O 호출만 담당하고, DB 저장은 {@link GraphPersister} 에 위임한다. + * 트랜잭션 경계는 GraphPersister 가 별도 빈으로 보유하므로 본 클래스에는 {@code @Transactional} 을 두지 않는다.

+ */ @Slf4j @Service @RequiredArgsConstructor public class GraphService { - private final GraphNodeRepository graphNodeRepository; - private final GraphEdgeRepository graphEdgeRepository; - private final EdgeChoiceRepository edgeChoiceRepository; private final RestTemplate restTemplate; + private final GraphPersister graphPersister; @Value("${fastapi.base-url:http://localhost:8000}") private String fastApiBaseUrl; - @Transactional public void extractAndSave(Fairytale fairytale, String content) { GraphExtractRequest request = new GraphExtractRequest(fairytale.getId(), content); @@ -44,45 +37,10 @@ public void extractAndSave(Fairytale fairytale, String content) { ); if (response == null || response.getNodes() == null) { - throw new RuntimeException("FastAPI 그래프 추출 응답이 비어있습니다."); - } - - // 1. graph_nodes 저장 + temp_id → real PK 매핑 - Map tempIdToNode = new HashMap<>(); - - for (GraphExtractResponse.NodeDto nodeDto : response.getNodes()) { - GraphNode node = GraphNode.builder() - .fairytale(fairytale) - .word(nodeDto.getWord()) - .category(NodeCategory.fromLabel(nodeDto.getCategory())) - .build(); - graphNodeRepository.save(node); - tempIdToNode.put(nodeDto.getTempId(), node); - } - - // 2. graph_edges + edge_choices 저장 - for (GraphExtractResponse.EdgeDto edgeDto : response.getEdges()) { - GraphNode fromNode = tempIdToNode.get(edgeDto.getFromTempId()); - GraphNode toNode = tempIdToNode.get(edgeDto.getToTempId()); - - GraphEdge edge = GraphEdge.builder() - .fromNode(fromNode) - .toNode(toNode) - .description(edgeDto.getDescription()) - .build(); - graphEdgeRepository.save(edge); - - for (GraphExtractResponse.ChoiceDto choiceDto : edgeDto.getChoices()) { - EdgeChoice choice = EdgeChoice.builder() - .edge(edge) - .content(choiceDto.getContent()) - .isAnswer(choiceDto.getIsAnswer() != null && choiceDto.getIsAnswer()) - .build(); - edgeChoiceRepository.save(choice); - } + throw new GraphExtractFailedException(); } - log.info("그래프 추출 완료: fairytaleId={}, nodes={}, edges={}", - fairytale.getId(), response.getNodes().size(), response.getEdges().size()); + graphPersister.persist(fairytale, response); + log.info("그래프 추출 완료: fairytaleId={}", fairytale.getId()); } } 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/AsyncConfig.java b/src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java new file mode 100644 index 0000000..c684dc5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 비동기 작업 전용 Executor 빈 등록. + * + *

현재는 그래프 추출 (FastAPI → OpenAI) 호출 1 종류에만 사용. 풀 사이즈는 캡스톤 시연 부하 기준으로 산정하며 + * 동화 동시 생성 부하가 늘어나면 core/max 를 상향 조정한다.

+ */ +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "graphExtractExecutor") + public Executor graphExtractExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("graph-extract-"); + executor.initialize(); + return executor; + } +} 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/JpaAuditingConfig.java b/src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..da87a94 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..1c9fef2 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java @@ -0,0 +1,128 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.ConsumerRecordRecoverer; +import org.springframework.kafka.listener.ContainerProperties.AckMode; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.DeserializationException; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.util.backoff.FixedBackOff; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka Consumer 인프라. + * + *

두 가지 컨슈머를 등록:

+ *
    + *
  • 기본 String 컨슈머 — 일반 텍스트 메시지용 (기존 develop 컨벤션)
  • + *
  • vocab_extracted 전용 JSON 컨슈머 — ErrorHandlingDeserializer wrap, DLT recoverer가 + * DLT publish 직전 markVocabDone 호출해 SSE hang 방지
  • + *
+ */ +@EnableKafka +@Configuration +public class KafkaConsumerConfig { + + private final String kafkaUrl; + private final String vocabGroupId; + private final FairytaleCheckService fairytaleCheckService; + + public KafkaConsumerConfig( + @Value("${KAFKA_URL}") String kafkaUrl, + @Value("${VOCAB_EXTRACTED_GROUP_ID:kkumteul-vocab}") String vocabGroupId, + FairytaleCheckService fairytaleCheckService + ) { + this.kafkaUrl = kafkaUrl; + this.vocabGroupId = vocabGroupId; + this.fairytaleCheckService = fairytaleCheckService; + } + + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaUrl); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + return factory; + } + + @Bean + public ConsumerFactory vocabConsumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaUrl); + config.put(ConsumerConfig.GROUP_ID_CONFIG, vocabGroupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + config.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName()); + config.put(JsonDeserializer.TRUSTED_PACKAGES, "com.capstone.kkumteul.domain.kafka.dto"); + config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, VocabExtractedMessage.class.getName()); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory vocabKafkaListenerContainerFactory( + KafkaTemplate kafkaTemplate + ) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(vocabConsumerFactory()); + factory.getContainerProperties().setAckMode(AckMode.RECORD); + factory.setCommonErrorHandler(buildVocabErrorHandler(kafkaTemplate)); + return factory; + } + + /** + * DLT recoverer + retry backoff. record.value()가 VocabExtractedMessage이면 + * DLT publish 직전 markVocabDone을 호출해 SSE hang을 방지한다. + */ + public DefaultErrorHandler buildVocabErrorHandler(KafkaTemplate kafkaTemplate) { + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + buildVocabRecoverer(kafkaTemplate), + new FixedBackOff(0L, 2L) + ); + errorHandler.addNotRetryableExceptions(DeserializationException.class, MethodArgumentNotValidException.class); + return errorHandler; + } + + /** + * DLT 직전 markVocabDone 호출 + DeadLetterPublishingRecoverer 위임 람다. + * 단위 테스트에서 직접 호출 가능하도록 분리. + */ + public ConsumerRecordRecoverer buildVocabRecoverer(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer dlpr = new DeadLetterPublishingRecoverer(kafkaTemplate); + return (record, ex) -> { + if (record.value() instanceof VocabExtractedMessage m) { + fairytaleCheckService.markVocabDone(m.getFairytaleId(), m.getPageNo()); + } + dlpr.accept(record, ex); + }; + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java new file mode 100644 index 0000000..3b7e6a5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -0,0 +1,47 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + + private final String KAFKA_URL; + + public KafkaProducerConfig( + @Value("${KAFKA_URL}") String KAFKA_URL + ) { this.KAFKA_URL = KAFKA_URL; } + + @Bean + + public ProducerFactory producerFactory() { + + Map config = new HashMap<>(); + + // key - String Serialization + // value - Json Serialization + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_URL); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java b/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java new file mode 100644 index 0000000..e7a6fd6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java @@ -0,0 +1,26 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} 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..a95561b 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java @@ -1,14 +1,36 @@ 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 { + /** + * 기본 RestTemplate — 그래프 추출(FastAPI → OpenAI) 호출에 사용. + * OpenAI 평균 5~15초 응답 + cold start 대응으로 read 30초. + */ + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(2)) + .readTimeout(Duration.ofSeconds(30)) + .build(); + } + + /** + * 단어장 추출용 RestTemplate. + * 페이지당 1회 호출로 응답 빠르게 받아야 하므로 짧은 timeout 적용. + */ @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); + public RestTemplate vocabRestTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ofSeconds(4)) + .build(); } } diff --git a/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java b/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java index b517d10..c287a5b 100644 --- a/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java +++ b/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java @@ -17,6 +17,7 @@ public class StaticValue { /* 5xx response */ public static final int INTERNAL_SERVER_ERROR = 500; + public static final int BAD_GATEWAY = 502; private StaticValue() {} diff --git a/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java b/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java index d35e58e..477184d 100644 --- a/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java @@ -8,10 +8,10 @@ import org.springframework.validation.BindException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..b034870 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,6 @@ +# dev profile — Kafka 미가용 환경에서 vocab만 시연. +# 활성화는 -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev 환경변수로. +# spring.profiles.active=dev 는 부트스트랩 패러독스 방지 위해 여기에 작성하지 않는다. + +fastapi.base-url=http://localhost:8000 +logging.level.com.capstone.kkumteul=DEBUG diff --git a/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java b/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java index 2c933cd..aa2b8a2 100644 --- a/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java +++ b/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java @@ -3,7 +3,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(properties = { + "KAFKA_URL=localhost:9092", + "FAIRYTALE_GENERATION=fairytale_generate", + "VOCAB_EXTRACTED=vocab_extracted" +}) class KkumteulApplicationTests { @Test diff --git a/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java b/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java new file mode 100644 index 0000000..742037a --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java @@ -0,0 +1,111 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * markImageDone inline fallback (M1) 단위 테스트. + * Paragraph 생성 시각이 임계 초과 + vocab 마커 미존재 시 빈 vocab으로 강제 mark. + */ +@ExtendWith(MockitoExtension.class) +class FairytaleCheckServiceFallbackTest { + + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations valueOps; + @Mock private SseService sseService; + @Mock private WordEntryRepository wordEntryRepository; + @Mock private ParagraphRepository paragraphRepository; + + @InjectMocks private FairytaleCheckServiceImpl service; + + private static final Long FAIRYTALE_ID = 10L; + private static final int PAGE = 3; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "vocabFallbackThresholdSeconds", 1L); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + @Test + @DisplayName("vocab 마커 이미 있으면 fallback 미발화") + void noFallbackWhenVocabAlreadyMarked() { + when(valueOps.get("vocab:10:3")).thenReturn("done"); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps).set(eq("image:10:3"), eq("done")); + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + verify(paragraphRepository, never()).findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE); + } + + @Test + @DisplayName("paragraph 없으면 fallback 미발화") + void noFallbackWhenParagraphMissing() { + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of()); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + } + + @Test + @DisplayName("paragraph age가 임계 미만이면 fallback 미발화") + void noFallbackWhenAgeBelowThreshold() { + Paragraph fresh = paragraphWithCreatedAt(LocalDateTime.now()); + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of(fresh)); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + } + + @Test + @DisplayName("paragraph age가 임계 초과이면 빈 vocab 마커 강제 세팅") + void fallbackFiresWhenAgeOverThreshold() { + Paragraph stale = paragraphWithCreatedAt(LocalDateTime.now().minusSeconds(60)); + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of(stale)); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps).set(eq("vocab:10:3"), eq("done")); + } + + private Paragraph paragraphWithCreatedAt(LocalDateTime createdAt) { + Paragraph p = Paragraph.builder().page(PAGE).text("dummy").build(); + try { + Field f = p.getClass().getSuperclass().getDeclaredField("createdAt"); + f.setAccessible(true); + f.set(p, createdAt); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + return p; + } +} diff --git a/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java b/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java new file mode 100644 index 0000000..72af8d2 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java @@ -0,0 +1,100 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.config.KafkaListenerEndpointRegistry; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.listener.MessageListenerContainer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.ContainerTestUtils; +import org.springframework.test.annotation.DirtiesContext; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * vocab_extracted 토픽 Listener의 EmbeddedKafka 통합 테스트. + * + *

실제 Kafka broker(임베디드)를 띄우고 메시지 1건 발행 → Listener가 + * VocabService.processExtractedWord를 호출하는지 wire-format 라운드트립을 검증한다.

+ */ +@Tag("kafka-broker") +@SpringBootTest(properties = { + "KAFKA_URL=${spring.embedded.kafka.brokers}", + "FAIRYTALE_GENERATION=fairytale_generate", + "VOCAB_EXTRACTED=vocab_extracted", + "VOCAB_EXTRACTED_GROUP_ID=kkumteul-vocab-test" +}) +@EmbeddedKafka(partitions = 1, topics = {"vocab_extracted", "fairytale_generate"}) +@DirtiesContext +class VocabExtractedListenerIntegrationTest { + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @Autowired + private KafkaListenerEndpointRegistry registry; + + @MockBean + private VocabService vocabService; + + @MockBean + private FairytaleCheckService fairytaleCheckService; + + @Test + @DisplayName("vocab_extracted 토픽 메시지 1건 → Listener가 VocabService.processExtractedWord 호출") + void listenerInvokesProcessExtractedWord() { + when(vocabService.processExtractedWord(any(VocabExtractedMessage.class))) + .thenReturn(VocabExtractionResult.noDifficultWord()); + + for (MessageListenerContainer container : registry.getListenerContainers()) { + ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic()); + } + + Map producerProps = new HashMap<>(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + producerProps.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + ProducerFactory pf = new DefaultKafkaProducerFactory<>(producerProps); + KafkaTemplate template = new KafkaTemplate<>(pf); + + VocabExtractedMessage msg = VocabExtractedMessage.builder() + .fairytaleId(11L) + .pageNo(3) + .word(null) + .meaning(null) + .userId(42L) + .messageId("integration-1") + .build(); + template.send("vocab_extracted", msg); + template.flush(); + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(vocabService, times(1)).processExtractedWord(any(VocabExtractedMessage.class)) + ); + } +} diff --git a/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java new file mode 100644 index 0000000..b588483 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java @@ -0,0 +1,127 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * VocabServiceImpl#processExtractedWord 단위 테스트. + * 모든 종착 분기에서 markVocabDone이 호출되는지(SSE guarantee) 검증. + */ +@ExtendWith(MockitoExtension.class) +class VocabServiceProcessExtractedWordTest { + + @Mock private WordEntryRepository wordEntryRepository; + @Mock private FairytaleRepository fairytaleRepository; + @Mock private FairytaleCheckService fairytaleCheckService; + @Mock private com.capstone.kkumteul.global.client.VocabExtractClient vocabExtractClient; + + @InjectMocks private VocabServiceImpl vocabService; + + private static final Long FAIRYTALE_ID = 10L; + private static final int PAGE_NO = 1; + + @BeforeEach + void setUp() { + } + + private VocabExtractedMessage message(String word, String meaning) { + return VocabExtractedMessage.builder() + .fairytaleId(FAIRYTALE_ID) + .pageNo(PAGE_NO) + .word(word) + .meaning(meaning) + .userId(42L) + .messageId("msg-1") + .build(); + } + + @Test + @DisplayName("word=null이면 NO_DIFFICULT_WORD 반환 + DB 저장 없음 + markVocabDone 호출") + void noDifficultWordWhenWordNull() { + VocabExtractionResult result = vocabService.processExtractedWord(message(null, null)); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.NO_DIFFICULT_WORD); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("word=blank이면 NO_DIFFICULT_WORD") + void noDifficultWordWhenWordBlank() { + VocabExtractionResult result = vocabService.processExtractedWord(message(" ", "meaning")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.NO_DIFFICULT_WORD); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("이미 같은 fairytaleId+word가 있으면 DUPLICATE + markVocabDone 호출") + void duplicateWhenWordExists() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(true); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "...")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.DUPLICATE); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("정상 저장이면 SAVED + markVocabDone 호출") + void savedWhenNew() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(false); + when(fairytaleRepository.findById(FAIRYTALE_ID)).thenReturn(Optional.of(mockFairytale())); + when(wordEntryRepository.save(any(WordEntry.class))).thenAnswer(inv -> inv.getArgument(0)); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "씩씩한 마음")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.SAVED); + verify(wordEntryRepository, times(1)).save(any(WordEntry.class)); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("save 시 DataIntegrityViolationException → RACE_SKIPPED + markVocabDone 호출 (Phase1과 의도적 divergence)") + void raceSkippedWhenUniqueViolation() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(false); + when(fairytaleRepository.findById(FAIRYTALE_ID)).thenReturn(Optional.of(mockFairytale())); + when(wordEntryRepository.save(any(WordEntry.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE violated")); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "...")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.RACE_SKIPPED); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + private Fairytale mockFairytale() { + return Fairytale.builder().id(FAIRYTALE_ID).build(); + } +} diff --git a/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java b/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java new file mode 100644 index 0000000..627ef7b --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java @@ -0,0 +1,85 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.ConsumerRecordRecoverer; +import org.springframework.kafka.support.SendResult; + +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * KafkaConsumerConfig.buildVocabRecoverer 단위 테스트. + * + *

recoverer 람다가 다음을 만족함을 직접 호출로 검증: + *

    + *
  • record.value()가 {@link VocabExtractedMessage}이면 markVocabDone 호출 후 DLT publish
  • + *
  • record.value()가 null이면 markVocabDone 미호출, DLT publish만 수행
  • + *
+ */ +class KafkaConsumerConfigDltTest { + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ConsumerRecordRecoverer buildRecoverer( + FairytaleCheckService check, KafkaTemplate template + ) { + TopicPartition tp = new TopicPartition("vocab_extracted.DLT", 0); + RecordMetadata md = new RecordMetadata(tp, 0L, 0, 0L, 0, 0); + SendResult sendResult = new SendResult(new ProducerRecord<>("vocab_extracted.DLT", null), md); + when(template.send(any(ProducerRecord.class))).thenReturn(CompletableFuture.completedFuture(sendResult)); + KafkaConsumerConfig config = new KafkaConsumerConfig("localhost:9092", "kkumteul-vocab", check); + return config.buildVocabRecoverer(template); + } + + @Test + @DisplayName("VocabExtractedMessage 페이로드면 markVocabDone 호출 후 DLT publish") + @SuppressWarnings({"rawtypes", "unchecked"}) + void recovererCallsMarkVocabDoneThenPublishesDlt() { + FairytaleCheckService check = mock(FairytaleCheckService.class); + KafkaTemplate template = mock(KafkaTemplate.class); + ConsumerRecordRecoverer recoverer = buildRecoverer(check, template); + + VocabExtractedMessage payload = VocabExtractedMessage.builder() + .fairytaleId(7L).pageNo(2).word("용감").meaning("...").messageId("m-7-2").build(); + ConsumerRecord record = + new ConsumerRecord<>("vocab_extracted", 0, 0L, null, payload); + + recoverer.accept(record, new RuntimeException("simulated")); + + verify(check, times(1)).markVocabDone(eq(7L), eq(2)); + verify(template, atLeastOnce()).send(any(ProducerRecord.class)); + } + + @Test + @DisplayName("record.value()가 null(역직렬화 실패)이면 markVocabDone 미호출, DLT publish만") + @SuppressWarnings({"rawtypes", "unchecked"}) + void recovererSkipsMarkWhenValueNull() { + FairytaleCheckService check = mock(FairytaleCheckService.class); + KafkaTemplate template = mock(KafkaTemplate.class); + ConsumerRecordRecoverer recoverer = buildRecoverer(check, template); + + ConsumerRecord record = + new ConsumerRecord<>("vocab_extracted", 0, 0L, null, null); + + recoverer.accept(record, new RuntimeException("deserialization failure")); + + verify(check, never()).markVocabDone(any(), any(Integer.class)); + verify(template, atLeastOnce()).send(any(ProducerRecord.class)); + } +} diff --git a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java new file mode 100644 index 0000000..755e656 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java @@ -0,0 +1,56 @@ +package com.capstone.kkumteul.kafka; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.global.config.KafkaProducerConfig; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@Tag("kafka-broker") +@EnabledIfEnvironmentVariable(named = "KAFKA_URL", matches = ".+") +@SpringBootTest( + classes = KafkaProducerConfig.class +) +class KafkaProducerTest { + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Test + void messagePublicationTest() throws Exception { + + FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() + .userId(1L) + .background(Background.FOREST_NATURE) + .charSpecies(CharSpecies.ANIMAL) + .morality(Morality.KINDNESS_REWARDED) + .build(); + + CompletableFuture> future = + kafkaTemplate.send("fairytale_generate", message); + + SendResult result = future.get(10, TimeUnit.SECONDS); + + assertThat(result.getRecordMetadata().topic()).isEqualTo("fairytale_generate"); + assertThat(result.getRecordMetadata().offset()).isGreaterThanOrEqualTo(0); + + log.info("토픽: {}", result.getRecordMetadata().topic()); + log.info("파티션: {}", result.getRecordMetadata().partition()); + log.info("오프셋: {}", result.getRecordMetadata().offset()); + } +}