From 68aa41d7c4baaa08defdb53cd98e5c381a44de75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:15:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=9D=91=EB=8B=B5=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleApiV2.java | 38 ++++++ .../controller/ArticleControllerV2.java | 31 +++++ .../article/dto/ArticleAiSummaryResponse.java | 73 ++++++++++++ .../article/dto/ArticleResponseV2.java | 110 ++++++++++++++++++ .../article/service/ArticleService.java | 30 ++++- .../summary/ArticleAiSummaryService.java | 25 +++- .../service/summary/ArticleSummaryView.java | 35 ++++++ .../domain/ArticleAiSummaryApiTest.java | 50 ++++++++ .../article/ArticleAiSummaryResponseTest.java | 45 +++++++ .../summary/ArticleAiSummaryServiceTest.java | 72 ++++++++++++ 10 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java new file mode 100644 index 000000000..30036af30 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.community.article.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +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.RequestParam; + +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; +import in.koreatech.koin.global.ipaddress.IpAddress; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Articles V2: 게시글", description = "게시글 정보를 관리한다") +@RequestMapping("/v2/articles") +public interface ArticleApiV2 { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "게시글 단건 조회 V2", description = "게시글 원문과 AI 요약을 분리해 반환한다.") + @GetMapping("/{id}") + ResponseEntity getArticleV2( + @RequestParam(required = false) Integer boardId, + @Parameter(in = PATH) @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java new file mode 100644 index 000000000..d1d742320 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.community.article.controller; + +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; +import in.koreatech.koin.domain.community.article.service.ArticleService; +import in.koreatech.koin.global.ipaddress.IpAddress; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/articles") +public class ArticleControllerV2 implements ArticleApiV2 { + + private final ArticleService articleService; + + @GetMapping("/{id}") + public ResponseEntity getArticleV2( + @RequestParam(required = false) Integer boardId, + @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ) { + ArticleResponseV2 foundArticle = articleService.getArticleV2(boardId, articleId, ipAddress); + return ResponseEntity.ok().body(foundArticle); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java new file mode 100644 index 000000000..968b32cca --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java @@ -0,0 +1,73 @@ +package in.koreatech.koin.domain.community.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryIcon; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record ArticleAiSummaryResponse( + + @Schema(description = "AI 요약 상태", example = "SUCCESS", requiredMode = REQUIRED) + Status status, + + @Schema(description = "AI 요약 항목", requiredMode = REQUIRED) + List items +) { + + private static final int MAX_SUMMARY_LINES = 3; + private static final int MAX_SUMMARY_LINE_LENGTH = 220; + + public static ArticleAiSummaryResponse from(ArticleSummaryView summaryView) { + List items = summaryView.summaryLines().stream() + .filter(line -> line != null && !line.isBlank()) + .filter(line -> line.length() <= MAX_SUMMARY_LINE_LENGTH) + .map(InnerArticleAiSummaryItemResponse::from) + .flatMap(Optional::stream) + .limit(MAX_SUMMARY_LINES) + .toList(); + Status status = summaryView.isSuccess() && items.isEmpty() + ? Status.UNAVAILABLE + : Status.valueOf(summaryView.status().name()); + return new ArticleAiSummaryResponse(status, items); + } + + public enum Status { + SUCCESS, + PENDING, + UNAVAILABLE + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerArticleAiSummaryItemResponse( + + @Schema(description = "요약 항목 아이콘", example = "📅", requiredMode = REQUIRED) + String icon, + + @Schema(description = "요약 내용", example = "신청은 5월 20일까지 접수됩니다.", requiredMode = REQUIRED) + String text + ) { + + private static Optional from(String summaryLine) { + if (summaryLine == null || summaryLine.isBlank()) { + return Optional.empty(); + } + return Arrays.stream(ArticleSummaryIcon.values()) + .filter(icon -> summaryLine.startsWith(icon.getEmoji() + " ")) + .findFirst() + .map(icon -> new InnerArticleAiSummaryItemResponse( + icon.getEmoji(), + summaryLine.substring((icon.getEmoji() + " ").length()).trim() + )) + .filter(item -> !item.text().isBlank()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java new file mode 100644 index 000000000..8aae17a0f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.domain.community.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.ArticleAttachment; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record ArticleResponseV2( + + @Schema(description = "게시글 고유 ID", example = "2", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 고유 ID", example = "4", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "제목", example = "제목", requiredMode = REQUIRED) + String title, + + @Schema(description = "게시글 원문", example = "내용", requiredMode = REQUIRED) + String content, + + @Schema(description = "AI 요약", requiredMode = REQUIRED) + ArticleAiSummaryResponse aiSummary, + + @Schema(description = "작성자", example = "닉네임", requiredMode = REQUIRED) + String author, + + @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) + Integer hit, + + @Schema(description = "공지 원본 url", example = "https://portal.koreatech.ac.kr/ctt/bb/bulletin?b=14&ls=20&ln=1&dm=r&p=33248") + String url, + + @Schema(description = "첨부 파일") + List attachments, + + @Schema(description = "이전 게시글 ID", example = "1") + Integer prevId, + + @Schema(description = "다음 게시글 ID", example = "3") + Integer nextId, + + @Schema(description = "등록 일자", example = "2024-08-28", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") LocalDate registeredAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt +) { + + public static ArticleResponseV2 from(Article article, String content, ArticleSummaryView summaryView) { + return new ArticleResponseV2( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + content, + ArticleAiSummaryResponse.from(summaryView), + article.getAuthor(), + article.getTotalHit(), + article.getUrl(), + article.getAttachments().stream() + .map(InnerArticleAttachmentResponse::from) + .toList(), + article.getPrevId(), + article.getNextId(), + article.getRegisteredAt(), + article.getUpdatedAt() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + private record InnerArticleAttachmentResponse( + + @Schema(description = "파일 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "파일 이름", example = "이미지.png", requiredMode = REQUIRED) + String name, + + @Schema(description = "파일 url", requiredMode = REQUIRED) + String url, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + private static InnerArticleAttachmentResponse from(ArticleAttachment attachment) { + return new InnerArticleAttachmentResponse( + attachment.getId(), + attachment.getName(), + attachment.getUrl(), + attachment.getCreatedAt(), + attachment.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index 5a4f17751..036c5749b 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -19,6 +19,7 @@ import in.koreatech.koin.common.model.Criteria; import in.koreatech.koin.domain.community.article.dto.ArticleHotKeywordResponse; import in.koreatech.koin.domain.community.article.dto.ArticleResponse; +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; import in.koreatech.koin.domain.community.article.dto.ArticlesResponse; import in.koreatech.koin.domain.community.article.dto.HotArticleItemResponse; import in.koreatech.koin.domain.community.article.exception.ArticleBoardMisMatchException; @@ -32,6 +33,7 @@ import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryService; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; import in.koreatech.koin.global.exception.custom.KoinIllegalArgumentException; import in.koreatech.koin.infrastructure.s3.client.S3Client; import lombok.RequiredArgsConstructor; @@ -65,6 +67,25 @@ public class ArticleService { @Transactional public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) { + ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp); + String contentWithSummary = articleAiSummaryService.prependSummaryIfReady( + articleDetail.article(), + articleDetail.content() + ); + return ArticleResponse.from(articleDetail.article(), contentWithSummary); + } + + @Transactional + public ArticleResponseV2 getArticleV2(Integer boardId, Integer articleId, String publicIp) { + ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp); + ArticleSummaryView summaryView = articleAiSummaryService.getSummary( + articleDetail.article(), + articleDetail.content() + ); + return ArticleResponseV2.from(articleDetail.article(), articleDetail.content(), summaryView); + } + + private ArticleDetail getArticleDetail(Integer boardId, Integer articleId, String publicIp) { Article article = articleRepository.getById(articleId); String content = article.getContent(); String contentUrl = content == null ? null : content.trim(); @@ -76,8 +97,7 @@ public ArticleResponse getArticle(Integer boardId, Integer articleId, String pub articleHitUserRepository.save(ArticleHitUser.of(articleId, publicIp)); } setPrevNextArticle(boardId, article); - String contentWithSummary = articleAiSummaryService.prependSummaryIfReady(article, content); - return ArticleResponse.from(article, contentWithSummary); + return new ArticleDetail(article, content); } public ArticlesResponse getArticles(Integer boardId, Integer page, Integer limit, Integer userId) { @@ -213,4 +233,10 @@ private Board getBoard(Integer boardId, Article article) { } return boardRepository.getById(boardId); } + + private record ArticleDetail( + Article article, + String content + ) { + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java index 926cd5f22..970d45ee4 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java @@ -22,6 +22,7 @@ import in.koreatech.koin.domain.community.article.model.ArticleAiSummary; import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLog; import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLogType; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryLogRepository; import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryRepository; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -49,16 +50,29 @@ public class ArticleAiSummaryService { @Transactional public String prependSummaryIfReady(Article article, String content) { - if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) { + ArticleSummaryView summaryView = resolveSummary(article, content); + if (!summaryView.isSuccess()) { return content; } + return contentRenderer.prependSummary(content, summaryView.summaryLines()); + } + + @Transactional + public ArticleSummaryView getSummary(Article article, String content) { + return resolveSummary(article, content); + } + + private ArticleSummaryView resolveSummary(Article article, String content) { + if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) { + return ArticleSummaryView.unavailable(); + } ArticleSummarySourceSeed seed = ArticleSummarySourceSeed.from(article, content); String fingerprint = sourceReader.createFingerprint(seed); Optional optionalSummary = articleAiSummaryRepository.findByArticleId(article.getId()); if (optionalSummary.isEmpty()) { enqueueIfEnabled(article, fingerprint, article.getUpdatedAt()); - return content; + return canGenerate() ? ArticleSummaryView.pending() : ArticleSummaryView.unavailable(); } ArticleAiSummary summary = optionalSummary.get(); @@ -66,12 +80,15 @@ public String prependSummaryIfReady(Article article, String content) { if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) { summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion()); } - return contentRenderer.prependSummary(content, summary.getSummaryLines()); + return ArticleSummaryView.success(summary.getSummaryLines()); } if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) { summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion()); } - return content; + if (canGenerate() && (summary.getStatus() == ArticleAiSummaryStatus.WAIT || summary.isProcessing())) { + return ArticleSummaryView.pending(); + } + return ArticleSummaryView.unavailable(); } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java new file mode 100644 index 000000000..02e0b3042 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.community.article.service.summary; + +import java.util.List; + +public record ArticleSummaryView( + Status status, + List summaryLines +) { + + public ArticleSummaryView { + summaryLines = summaryLines == null ? List.of() : List.copyOf(summaryLines); + } + + public static ArticleSummaryView success(List summaryLines) { + return new ArticleSummaryView(Status.SUCCESS, summaryLines); + } + + public static ArticleSummaryView pending() { + return new ArticleSummaryView(Status.PENDING, List.of()); + } + + public static ArticleSummaryView unavailable() { + return new ArticleSummaryView(Status.UNAVAILABLE, List.of()); + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public enum Status { + SUCCESS, + PENDING, + UNAVAILABLE + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java index 339b81dbb..fa3afa2d4 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java @@ -110,6 +110,21 @@ void setUp() { assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); } + @Test + void V2에서_요약이_없으면_원문과_생성_대기_상태를_분리해_반환한다() throws Exception { + mockMvc.perform( + get("/v2/articles/{articleId}", article.getId()) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("

내용

")) + .andExpect(jsonPath("$.ai_summary.status").value("PENDING")) + .andExpect(jsonPath("$.ai_summary.items").isEmpty()); + + ArticleAiSummary summary = articleAiSummaryRepository.findByArticleId(article.getId()).orElseThrow(); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + } + @Test void 요약이_성공되어_있으면_content_맨_앞에_요약을_붙인다() throws Exception { String content = "

내용

"; @@ -141,4 +156,39 @@ void setUp() { .andExpect(jsonPath("$.content").value(containsString(">신청은 5월 20일까지 접수됩니다."))) .andExpect(jsonPath("$.content").value(containsString("

내용

"))); } + + @Test + void V2에서_성공한_요약은_원문과_분리된_항목으로_반환한다() throws Exception { + String content = "

내용

"; + String fingerprint = sourceReader.createFingerprint(ArticleSummarySourceSeed.from(article, content)); + ArticleAiSummary summary = ArticleAiSummary.waiting( + article, + fingerprint, + article.getUpdatedAt(), + properties.getModel(), + properties.getPromptVersion() + ); + summary.completeSuccess( + List.of("📅 신청은 5월 20일까지 접수됩니다.", "🎯 재학생을 대상으로 모집합니다."), + fingerprint, + article.getUpdatedAt(), + properties.getModel(), + properties.getPromptVersion(), + LocalDateTime.now(clock) + ); + articleAiSummaryRepository.save(summary); + + mockMvc.perform( + get("/v2/articles/{articleId}", article.getId()) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(content)) + .andExpect(jsonPath("$.ai_summary.status").value("SUCCESS")) + .andExpect(jsonPath("$.ai_summary.items.length()").value(2)) + .andExpect(jsonPath("$.ai_summary.items[0].icon").value("📅")) + .andExpect(jsonPath("$.ai_summary.items[0].text").value("신청은 5월 20일까지 접수됩니다.")) + .andExpect(jsonPath("$.ai_summary.items[1].icon").value("🎯")) + .andExpect(jsonPath("$.ai_summary.items[1].text").value("재학생을 대상으로 모집합니다.")); + } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java new file mode 100644 index 000000000..6b5bcce65 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.unit.domain.community.article; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.community.article.dto.ArticleAiSummaryResponse; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; + +class ArticleAiSummaryResponseTest { + + @Test + void 저장된_요약_문장을_아이콘과_본문으로_분리한다() { + ArticleSummaryView summaryView = ArticleSummaryView.success(List.of( + "📅 신청은 5월 20일까지 접수됩니다.", + "🎯 재학생을 대상으로 모집합니다." + )); + + ArticleAiSummaryResponse response = ArticleAiSummaryResponse.from(summaryView); + + assertThat(response.status()).isEqualTo(ArticleAiSummaryResponse.Status.SUCCESS); + assertThat(response.items()).containsExactly( + new ArticleAiSummaryResponse.InnerArticleAiSummaryItemResponse( + "📅", + "신청은 5월 20일까지 접수됩니다." + ), + new ArticleAiSummaryResponse.InnerArticleAiSummaryItemResponse( + "🎯", + "재학생을 대상으로 모집합니다." + ) + ); + } + + @Test + void 표시할_수_없는_요약만_있으면_UNAVAILABLE을_반환한다() { + ArticleSummaryView summaryView = ArticleSummaryView.success(List.of("아이콘이 없는 요약")); + + ArticleAiSummaryResponse response = ArticleAiSummaryResponse.from(summaryView); + + assertThat(response.status()).isEqualTo(ArticleAiSummaryResponse.Status.UNAVAILABLE); + assertThat(response.items()).isEmpty(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java index 844da96ff..8c3dafe66 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java @@ -37,6 +37,7 @@ import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryContentRenderer; import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureReasonSanitizer; import in.koreatech.koin.domain.community.article.service.summary.ArticleSummarySourceReader; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; import in.koreatech.koin.infrastructure.upstage.client.UpstageProperties; @ExtendWith(MockitoExtension.class) @@ -117,6 +118,77 @@ class ArticleAiSummaryServiceTest { verify(contentRenderer).prependSummary("본문", List.of("기존 요약입니다.")); } + @Test + void V2에서_모델이_바뀌어도_원문이_같으면_기존_요약을_반환한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + ArticleAiSummary summary = completedSummary(clock, article, "solar-open2", "v10"); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(LocalDateTime.now(clock)); + when(sourceReader.createFingerprint(any())).thenReturn("fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.of(summary)); + + ArticleSummaryView summaryView = service.getSummary(article, "본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.SUCCESS); + assertThat(summaryView.summaryLines()).containsExactly("기존 요약입니다."); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + verify(contentRenderer, never()).prependSummary(any(), any()); + } + + @Test + void V2에서_요약이_없으면_PENDING을_반환하고_WAIT으로_등록한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + LocalDateTime updatedAt = LocalDateTime.now(clock); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(updatedAt); + when(sourceReader.createFingerprint(any())).thenReturn("fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.empty()); + + ArticleSummaryView summaryView = service.getSummary(article, "본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.PENDING); + assertThat(summaryView.summaryLines()).isEmpty(); + verify(articleAiSummaryRepository).insertWaitIfAbsent( + 1, + "fingerprint", + updatedAt, + "solar-pro4", + "v11" + ); + } + + @Test + void V2에서_원문이_바뀌면_기존_요약을_제외하고_PENDING을_반환한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + ArticleAiSummary summary = completedSummary(clock, article, "solar-pro4", "v11"); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(LocalDateTime.now(clock)); + when(sourceReader.createFingerprint(any())).thenReturn("changed-fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.of(summary)); + + ArticleSummaryView summaryView = service.getSummary(article, "변경된 본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.PENDING); + assertThat(summaryView.summaryLines()).isEmpty(); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + assertThat(summary.getSummaryLines()).isEmpty(); + } + @Test void 게시글_AI_요약_로그는_90일_이전_데이터를_삭제한다() { Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul"));