Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ArticleResponseV2> getArticleV2(
@RequestParam(required = false) Integer boardId,
@Parameter(in = PATH) @PathVariable("id") Integer articleId,
@IpAddress String ipAddress
);
}
Original file line number Diff line number Diff line change
@@ -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<ArticleResponseV2> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<InnerArticleAiSummaryItemResponse> 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<InnerArticleAiSummaryItemResponse> 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<InnerArticleAiSummaryItemResponse> 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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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<InnerArticleAttachmentResponse> 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()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -213,4 +233,10 @@ private Board getBoard(Integer boardId, Article article) {
}
return boardRepository.getById(boardId);
}

private record ArticleDetail(
Article article,
String content
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,29 +50,45 @@ 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<ArticleAiSummary> 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();
if (summary.hasSummaryForSource(fingerprint)) {
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
Expand Down
Loading
Loading