From 79d5adbe1866bc03a309e28f766eb84ef0c5ce89 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: Tue, 7 Jul 2026 20:11:34 +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=ED=81=90=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영자가 게시글 AI 요약 큐 상태와 실패 원인을 조회할 수 있는 Admin API를 추가합니다. --- .../controller/AdminArticleAiSummaryApi.java | 64 ++++++++ .../AdminArticleAiSummaryController.java | 52 +++++++ .../dto/AdminArticleAiSummariesResponse.java | 45 ++++++ ...AdminArticleAiSummaryOverviewResponse.java | 71 +++++++++ .../dto/AdminArticleAiSummaryProjection.java | 38 +++++ ...nArticleAiSummaryQueueCountProjection.java | 14 ++ .../dto/AdminArticleAiSummaryResponse.java | 66 ++++++++ ...ArticleAiSummaryStatusCountProjection.java | 8 + ...dminArticleAiSummaryNotFoundException.java | 16 ++ .../service/AdminArticleAiSummaryService.java | 118 ++++++++++++++ .../ArticleAiSummaryRepository.java | 108 +++++++++++++ .../summary/ArticleAiSummaryService.java | 13 +- .../summary/ArticleAiSummaryWorker.java | 8 + .../ArticleSummaryFailureReasonSanitizer.java | 77 ++++++++++ .../summary/ArticleSummaryFailureType.java | 16 ++ .../admin/AdminArticleAiSummaryApiTest.java | 145 ++++++++++++++++++ .../summary/ArticleAiSummaryServiceTest.java | 2 + .../summary/ArticleAiSummaryWorkerTest.java | 25 +++ ...icleSummaryFailureReasonSanitizerTest.java | 39 +++++ 19 files changed, 921 insertions(+), 4 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummariesResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryOverviewResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryProjection.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryQueueCountProjection.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryResponse.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryStatusCountProjection.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/exception/AdminArticleAiSummaryNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizer.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureType.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizerTest.java diff --git a/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java new file mode 100644 index 000000000..32d24e6d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java @@ -0,0 +1,64 @@ +package in.koreatech.koin.admin.article.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +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.RequestParam; + +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryOverviewResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryResponse; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; +import in.koreatech.koin.global.auth.Auth; +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 = "(Admin) ArticleAiSummary: 게시글 AI 요약", description = "관리자 권한으로 게시글 AI 요약 작업 상태를 조회한다") +public interface AdminArticleAiSummaryApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + }) + @Operation(summary = "게시글 AI 요약 큐 상태 요약 조회") + @GetMapping("/admin/articles/ai-summaries/overview") + ResponseEntity getOverview( + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))) + }) + @Operation(summary = "게시글 AI 요약 작업 목록 조회") + @GetMapping("/admin/articles/ai-summaries") + ResponseEntity getSummaries( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "status", required = false) ArticleAiSummaryStatus status, + @Auth(permit = {ADMIN}) Integer adminId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + }) + @Operation(summary = "게시글 AI 요약 작업 단건 조회") + @GetMapping("/admin/articles/ai-summaries/{summaryId}") + ResponseEntity getSummary( + @Parameter(in = PATH) @PathVariable Integer summaryId, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java new file mode 100644 index 000000000..589fe036d --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.admin.article.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryOverviewResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryResponse; +import in.koreatech.koin.admin.article.service.AdminArticleAiSummaryService; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; +import in.koreatech.koin.global.auth.Auth; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminArticleAiSummaryController implements AdminArticleAiSummaryApi { + + private final AdminArticleAiSummaryService adminArticleAiSummaryService; + + @Override + @GetMapping("/admin/articles/ai-summaries/overview") + public ResponseEntity getOverview( + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok(adminArticleAiSummaryService.getOverview()); + } + + @Override + @GetMapping("/admin/articles/ai-summaries") + public ResponseEntity getSummaries( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "status", required = false) ArticleAiSummaryStatus status, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok(adminArticleAiSummaryService.getSummaries(page, limit, status)); + } + + @Override + @GetMapping("/admin/articles/ai-summaries/{summaryId}") + public ResponseEntity getSummary( + @PathVariable Integer summaryId, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok(adminArticleAiSummaryService.getSummary(summaryId)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummariesResponse.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummariesResponse.java new file mode 100644 index 000000000..a954cbecb --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummariesResponse.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.admin.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.common.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminArticleAiSummariesResponse( + @Schema(description = "게시글 AI 요약 작업 목록", requiredMode = REQUIRED) + List summaries, + + @Schema(description = "총 작업 수", example = "57", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에 포함된 작업 수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "총 페이지 수", example = "6", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "2", requiredMode = REQUIRED) + Integer currentPage +) { + + public static AdminArticleAiSummariesResponse of( + Page pagedResult, + Criteria criteria + ) { + return new AdminArticleAiSummariesResponse( + pagedResult.getContent(), + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1 + ); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryOverviewResponse.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryOverviewResponse.java new file mode 100644 index 000000000..3f48266ab --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryOverviewResponse.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.admin.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminArticleAiSummaryOverviewResponse( + @Schema(description = "상태별 작업 수", requiredMode = REQUIRED) + List statusCounts, + + @Schema(description = "큐 처리 상태", requiredMode = REQUIRED) + QueueResponse queue, + + @Schema(description = "요약 설정", requiredMode = REQUIRED) + ConfigResponse config +) { + + @JsonNaming(SnakeCaseStrategy.class) + public record StatusCountResponse( + @Schema(description = "요약 상태", example = "WAIT", requiredMode = REQUIRED) + String status, + + @Schema(description = "작업 수", example = "12", requiredMode = REQUIRED) + Long count + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + public record QueueResponse( + @Schema(description = "즉시 처리 가능한 WAIT 수", example = "4", requiredMode = REQUIRED) + Long readyWaitCount, + + @Schema(description = "next_attempt_at 대기 중인 WAIT 수", example = "2", requiredMode = REQUIRED) + Long delayedWaitCount, + + @Schema(description = "처리 중인 수", example = "1", requiredMode = REQUIRED) + Long processingCount, + + @Schema(description = "lock이 만료된 PROCESSING 수", example = "0", requiredMode = REQUIRED) + Long expiredProcessingCount, + + @Schema(description = "재시도 가능한 FAILED 수", example = "3", requiredMode = REQUIRED) + Long retryableFailedCount + ) { + } + + @JsonNaming(SnakeCaseStrategy.class) + public record ConfigResponse( + @Schema(description = "스케줄러 실행 간격(ms)", example = "60000", requiredMode = REQUIRED) + Long schedulerFixedDelayMs, + + @Schema(description = "스케줄러 batch size", example = "5", requiredMode = REQUIRED) + Integer batchSize, + + @Schema(description = "최대 재시도 횟수", example = "5", requiredMode = REQUIRED) + Integer maxRetryCount, + + @Schema(description = "FAILED 재처리 시작 시각", example = "0", requiredMode = REQUIRED) + Integer failedRetryWindowStartHour, + + @Schema(description = "FAILED 재처리 종료 시각", example = "4", requiredMode = REQUIRED) + Integer failedRetryWindowEndHour + ) { + } +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryProjection.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryProjection.java new file mode 100644 index 000000000..6eb8f16c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryProjection.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.admin.article.dto; + +import java.time.LocalDateTime; + +public interface AdminArticleAiSummaryProjection { + + Integer getSummaryId(); + + Integer getArticleId(); + + Integer getBoardId(); + + String getArticleTitle(); + + String getStatus(); + + String getFailureReason(); + + Integer getRetryCount(); + + LocalDateTime getNextAttemptAt(); + + LocalDateTime getLockedUntil(); + + String getWorkerId(); + + LocalDateTime getCreatedAt(); + + LocalDateTime getUpdatedAt(); + + LocalDateTime getSummarizedAt(); + + LocalDateTime getSourceUpdatedAt(); + + String getModel(); + + String getPromptVersion(); +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryQueueCountProjection.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryQueueCountProjection.java new file mode 100644 index 000000000..48b24a7df --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryQueueCountProjection.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.article.dto; + +public interface AdminArticleAiSummaryQueueCountProjection { + + Long getReadyWaitCount(); + + Long getDelayedWaitCount(); + + Long getProcessingCount(); + + Long getExpiredProcessingCount(); + + Long getRetryableFailedCount(); +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryResponse.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryResponse.java new file mode 100644 index 000000000..d672bfa29 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryResponse.java @@ -0,0 +1,66 @@ +package in.koreatech.koin.admin.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminArticleAiSummaryResponse( + @Schema(description = "요약 작업 ID", example = "1", requiredMode = REQUIRED) + Integer summaryId, + + @Schema(description = "게시글 ID", example = "17291", requiredMode = REQUIRED) + Integer articleId, + + @Schema(description = "게시판 ID", example = "5", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "게시글 제목", requiredMode = REQUIRED) + String articleTitle, + + @Schema(description = "요약 상태", example = "FAILED", requiredMode = REQUIRED) + String status, + + @Schema(description = "실패 유형", example = "RATE_LIMIT") + ArticleSummaryFailureType failureType, + + @Schema(description = "안전하게 마스킹된 실패 메시지") + String failureMessage, + + @Schema(description = "재시도 횟수", example = "2", requiredMode = REQUIRED) + Integer retryCount, + + @Schema(description = "다음 시도 가능 시각") + LocalDateTime nextAttemptAt, + + @Schema(description = "작업 잠금 만료 시각") + LocalDateTime lockedUntil, + + @Schema(description = "작업자 ID") + String workerId, + + @Schema(description = "생성 시각", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @Schema(description = "수정 시각", requiredMode = REQUIRED) + LocalDateTime updatedAt, + + @Schema(description = "요약 성공 시각") + LocalDateTime summarizedAt, + + @Schema(description = "원본 수정 시각") + LocalDateTime sourceUpdatedAt, + + @Schema(description = "모델명") + String model, + + @Schema(description = "프롬프트 버전") + String promptVersion +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryStatusCountProjection.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryStatusCountProjection.java new file mode 100644 index 000000000..436c628bc --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryStatusCountProjection.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.admin.article.dto; + +public interface AdminArticleAiSummaryStatusCountProjection { + + String getStatus(); + + Long getSummaryCount(); +} diff --git a/src/main/java/in/koreatech/koin/admin/article/exception/AdminArticleAiSummaryNotFoundException.java b/src/main/java/in/koreatech/koin/admin/article/exception/AdminArticleAiSummaryNotFoundException.java new file mode 100644 index 000000000..2997970ca --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/exception/AdminArticleAiSummaryNotFoundException.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.admin.article.exception; + +import in.koreatech.koin.global.exception.custom.DataNotFoundException; + +public class AdminArticleAiSummaryNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "해당 게시글 AI 요약 작업을 찾을 수 없습니다."; + + public AdminArticleAiSummaryNotFoundException(String message, String detail) { + super(message, detail); + } + + public static AdminArticleAiSummaryNotFoundException withDetail(String detail) { + return new AdminArticleAiSummaryNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java b/src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java new file mode 100644 index 000000000..fbcbf128a --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java @@ -0,0 +1,118 @@ +package in.koreatech.koin.admin.article.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryOverviewResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryProjection; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryQueueCountProjection; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryResponse; +import in.koreatech.koin.admin.article.exception.AdminArticleAiSummaryNotFoundException; +import in.koreatech.koin.common.model.Criteria; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; +import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryRepository; +import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryProperties; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureReasonSanitizer; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminArticleAiSummaryService { + + private final ArticleAiSummaryRepository articleAiSummaryRepository; + private final ArticleAiSummaryProperties properties; + private final ArticleSummaryFailureReasonSanitizer failureReasonSanitizer; + private final Clock clock; + + @Value("${article.ai-summary.scheduler-fixed-delay-ms:60000}") + private Long schedulerFixedDelayMs; + + public AdminArticleAiSummaryOverviewResponse getOverview() { + Map statusCounts = articleAiSummaryRepository.countAdminSummariesByStatus().stream() + .collect(Collectors.toMap( + count -> count.getStatus(), + count -> nullToZero(count.getSummaryCount()), + (first, second) -> first + )); + AdminArticleAiSummaryQueueCountProjection queue = articleAiSummaryRepository.countAdminQueue( + LocalDateTime.now(clock), + properties.getMaxRetryCount() + ); + + return new AdminArticleAiSummaryOverviewResponse( + Arrays.stream(ArticleAiSummaryStatus.values()) + .map(status -> new AdminArticleAiSummaryOverviewResponse.StatusCountResponse( + status.name(), + statusCounts.getOrDefault(status.name(), 0L) + )) + .toList(), + new AdminArticleAiSummaryOverviewResponse.QueueResponse( + nullToZero(queue.getReadyWaitCount()), + nullToZero(queue.getDelayedWaitCount()), + nullToZero(queue.getProcessingCount()), + nullToZero(queue.getExpiredProcessingCount()), + nullToZero(queue.getRetryableFailedCount()) + ), + new AdminArticleAiSummaryOverviewResponse.ConfigResponse( + schedulerFixedDelayMs, + properties.getBatchSize(), + properties.getMaxRetryCount(), + properties.getBoundedFailedRetryWindowStartHour(), + properties.getBoundedFailedRetryWindowEndHour() + ) + ); + } + + public AdminArticleAiSummariesResponse getSummaries(Integer page, Integer limit, ArticleAiSummaryStatus status) { + Criteria criteria = Criteria.of(page, limit); + PageRequest pageable = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page summaries = articleAiSummaryRepository + .findAdminSummaries(status == null ? null : status.name(), pageable) + .map(this::toResponse); + return AdminArticleAiSummariesResponse.of(summaries, criteria); + } + + public AdminArticleAiSummaryResponse getSummary(Integer summaryId) { + return articleAiSummaryRepository.findAdminSummaryById(summaryId) + .map(this::toResponse) + .orElseThrow(() -> AdminArticleAiSummaryNotFoundException.withDetail("summaryId: " + summaryId)); + } + + private AdminArticleAiSummaryResponse toResponse(AdminArticleAiSummaryProjection summary) { + String failureMessage = failureReasonSanitizer.sanitize(summary.getFailureReason()); + return new AdminArticleAiSummaryResponse( + summary.getSummaryId(), + summary.getArticleId(), + summary.getBoardId(), + summary.getArticleTitle(), + summary.getStatus(), + failureReasonSanitizer.classify(failureMessage), + failureMessage, + summary.getRetryCount(), + summary.getNextAttemptAt(), + summary.getLockedUntil(), + summary.getWorkerId(), + summary.getCreatedAt(), + summary.getUpdatedAt(), + summary.getSummarizedAt(), + summary.getSourceUpdatedAt(), + summary.getModel(), + summary.getPromptVersion() + ); + } + + private Long nullToZero(Long value) { + return value == null ? 0L : value; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryRepository.java index 1517ef8b4..fcaf28fb1 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryRepository.java @@ -4,11 +4,16 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryProjection; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryQueueCountProjection; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryStatusCountProjection; import in.koreatech.koin.domain.community.article.model.ArticleAiSummary; public interface ArticleAiSummaryRepository extends Repository { @@ -104,4 +109,107 @@ List findRetryableFailedSummariesForUpdate( @Param("maxRetryCount") int maxRetryCount ); + @Query(value = """ + SELECT + s.id AS summaryId, + a.id AS articleId, + a.board_id AS boardId, + a.title AS articleTitle, + s.status AS status, + s.failure_reason AS failureReason, + s.retry_count AS retryCount, + s.next_attempt_at AS nextAttemptAt, + s.locked_until AS lockedUntil, + s.worker_id AS workerId, + s.created_at AS createdAt, + s.updated_at AS updatedAt, + s.summarized_at AS summarizedAt, + s.source_updated_at AS sourceUpdatedAt, + s.model AS model, + s.prompt_version AS promptVersion + FROM article_ai_summaries s + JOIN new_articles a ON a.id = s.article_id AND a.is_deleted = 0 + WHERE s.is_deleted = 0 + AND (:status IS NULL OR s.status = :status) + ORDER BY s.updated_at DESC, s.id DESC + """, + countQuery = """ + SELECT COUNT(*) + FROM article_ai_summaries s + JOIN new_articles a ON a.id = s.article_id AND a.is_deleted = 0 + WHERE s.is_deleted = 0 + AND (:status IS NULL OR s.status = :status) + """, + nativeQuery = true) + Page findAdminSummaries( + @Param("status") String status, + Pageable pageable + ); + + @Query(value = """ + SELECT + s.id AS summaryId, + a.id AS articleId, + a.board_id AS boardId, + a.title AS articleTitle, + s.status AS status, + s.failure_reason AS failureReason, + s.retry_count AS retryCount, + s.next_attempt_at AS nextAttemptAt, + s.locked_until AS lockedUntil, + s.worker_id AS workerId, + s.created_at AS createdAt, + s.updated_at AS updatedAt, + s.summarized_at AS summarizedAt, + s.source_updated_at AS sourceUpdatedAt, + s.model AS model, + s.prompt_version AS promptVersion + FROM article_ai_summaries s + JOIN new_articles a ON a.id = s.article_id AND a.is_deleted = 0 + WHERE s.is_deleted = 0 + AND s.id = :summaryId + """, nativeQuery = true) + Optional findAdminSummaryById(@Param("summaryId") Integer summaryId); + + @Query(value = """ + SELECT s.status AS status, COUNT(*) AS summaryCount + FROM article_ai_summaries s + WHERE s.is_deleted = 0 + GROUP BY s.status + """, nativeQuery = true) + List countAdminSummariesByStatus(); + + @Query(value = """ + SELECT + COALESCE(SUM(CASE + WHEN status = 'WAIT' + AND (locked_until IS NULL OR locked_until < :now) + AND (next_attempt_at IS NULL OR next_attempt_at <= :now) + THEN 1 ELSE 0 END), 0) AS readyWaitCount, + COALESCE(SUM(CASE + WHEN status = 'WAIT' + AND next_attempt_at > :now + THEN 1 ELSE 0 END), 0) AS delayedWaitCount, + COALESCE(SUM(CASE + WHEN status = 'PROCESSING' + AND (locked_until IS NULL OR locked_until >= :now) + THEN 1 ELSE 0 END), 0) AS processingCount, + COALESCE(SUM(CASE + WHEN status = 'PROCESSING' + AND locked_until < :now + THEN 1 ELSE 0 END), 0) AS expiredProcessingCount, + COALESCE(SUM(CASE + WHEN status = 'FAILED' + AND retry_count < :maxRetryCount + AND (next_attempt_at IS NULL OR next_attempt_at <= :now) + AND (locked_until IS NULL OR locked_until < :now) + THEN 1 ELSE 0 END), 0) AS retryableFailedCount + FROM article_ai_summaries + WHERE is_deleted = 0 + """, nativeQuery = true) + AdminArticleAiSummaryQueueCountProjection countAdminQueue( + @Param("now") LocalDateTime now, + @Param("maxRetryCount") int maxRetryCount + ); + } 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 7cb6a2795..c08030186 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 @@ -34,6 +34,7 @@ public class ArticleAiSummaryService { private final ArticleRepository articleRepository; private final ArticleSummarySourceReader sourceReader; private final ArticleSummaryContentRenderer contentRenderer; + private final ArticleSummaryFailureReasonSanitizer failureReasonSanitizer; private final ArticleAiSummaryProperties properties; private final UpstageProperties upstageProperties; private final Clock clock; @@ -151,16 +152,17 @@ public void completeFailure(Integer summaryId, String workerId, String reason, D articleAiSummaryRepository.findById(summaryId) .filter(summary -> summary.isProcessingBy(workerId)) .ifPresent(summary -> { + String sanitizedReason = failureReasonSanitizer.sanitize(reason); int nextRetryCount = summary.getRetryCount() + 1; LocalDateTime nextAttemptAt = null; if (nextRetryCount < properties.getMaxRetryCount()) { nextAttemptAt = LocalDateTime.now(clock).plus(resolveRetryDelay(nextRetryCount, retryAfter)); } if (retryAfter != null && nextAttemptAt != null) { - summary.waitForRetry(reason, nextAttemptAt); + summary.waitForRetry(sanitizedReason, nextAttemptAt); return; } - summary.completeFailure(reason, nextAttemptAt); + summary.completeFailure(sanitizedReason, nextAttemptAt); }); } @@ -168,14 +170,17 @@ public void completeFailure(Integer summaryId, String workerId, String reason, D public void completeFailureWithoutRetry(Integer summaryId, String workerId, String reason) { articleAiSummaryRepository.findById(summaryId) .filter(summary -> summary.isProcessingBy(workerId)) - .ifPresent(summary -> summary.completeFailureWithoutRetry(reason, properties.getMaxRetryCount())); + .ifPresent(summary -> summary.completeFailureWithoutRetry( + failureReasonSanitizer.sanitize(reason), + properties.getMaxRetryCount() + )); } @Transactional public void skip(Integer summaryId, String workerId, String reason) { articleAiSummaryRepository.findById(summaryId) .filter(summary -> summary.isProcessingBy(workerId)) - .ifPresent(summary -> summary.skip(reason)); + .ifPresent(summary -> summary.skip(failureReasonSanitizer.sanitize(reason))); } private void enqueueIfEnabled(Article article, String fingerprint, LocalDateTime sourceUpdatedAt) { diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryWorker.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryWorker.java index d47996b76..e45cf49d9 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryWorker.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryWorker.java @@ -53,6 +53,10 @@ private void generate(Integer summaryId, String workerId, ArticleSummarySourceSe if (shouldWaitForTemporaryAttachment(source)) { throw new ArticleSummaryExternalApiException("첨부 중심 게시글의 문서 파싱이 일시 실패해 요약 생성을 보류합니다.", true, null); } + if (isAttachmentOnlyContent(source) && source.attachmentTexts().isEmpty()) { + articleAiSummaryService.skip(summaryId, workerId, "첨부 중심 게시글이지만 요약에 사용할 첨부 문서 내용이 없습니다."); + return; + } ArticleSummaryPrompt prompt = promptBuilder.build(source); ArticleSummaryResult result = articleSummaryAiClient.summarize(prompt); @@ -169,6 +173,10 @@ private boolean shouldWaitForTemporaryAttachment(ArticleSummarySource source) { if (!source.hasTemporaryAttachmentFailure() || !source.attachmentTexts().isEmpty()) { return false; } + return isAttachmentOnlyContent(source); + } + + private boolean isAttachmentOnlyContent(ArticleSummarySource source) { String normalizedContent = normalize(source.contentText()); if (!StringUtils.hasText(normalizedContent)) { return true; diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizer.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizer.java new file mode 100644 index 000000000..3fa3ceea0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizer.java @@ -0,0 +1,77 @@ +package in.koreatech.koin.domain.community.article.service.summary; + +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class ArticleSummaryFailureReasonSanitizer { + + private static final int MAX_LENGTH = 500; + private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)bearer\\s+[a-z0-9._~+/=-]+"); + private static final Pattern UPSTAGE_KEY_PATTERN = Pattern.compile("up_[A-Za-z0-9]+"); + private static final Pattern URL_QUERY_PATTERN = Pattern.compile("(https?://[^\\s,]+)\\?[^\\s,]*"); + private static final Pattern BODY_PATTERN = Pattern.compile("(?i)(,?\\s*body=).*$"); + + public String sanitize(String reason) { + if (!StringUtils.hasText(reason)) { + return null; + } + String sanitized = reason.replaceAll("\\s+", " ").trim(); + sanitized = BEARER_TOKEN_PATTERN.matcher(sanitized).replaceAll("Bearer "); + sanitized = UPSTAGE_KEY_PATTERN.matcher(sanitized).replaceAll("up_"); + sanitized = URL_QUERY_PATTERN.matcher(sanitized).replaceAll("$1?"); + sanitized = BODY_PATTERN.matcher(sanitized).replaceAll("$1"); + return truncate(sanitized); + } + + public ArticleSummaryFailureType classify(String reason) { + String sanitized = sanitize(reason); + if (!StringUtils.hasText(sanitized)) { + return ArticleSummaryFailureType.UNKNOWN; + } + String value = sanitized.toLowerCase(); + if (value.contains("status=429") || value.contains("rate limit") || value.contains("too many requests")) { + return ArticleSummaryFailureType.RATE_LIMIT; + } + if (value.contains("문서 다운로드")) { + return ArticleSummaryFailureType.DOCUMENT_DOWNLOAD; + } + if (value.contains("문서 파싱") || value.contains("document parse")) { + return ArticleSummaryFailureType.DOCUMENT_PARSE; + } + if (value.contains("prematurecloseexception") || value.contains("timeout") || value.contains("timed out")) { + return ArticleSummaryFailureType.NETWORK_TRANSIENT; + } + if (value.contains("status=5")) { + return ArticleSummaryFailureType.UPSTAGE_SERVER_ERROR; + } + if (value.contains("status=4")) { + return ArticleSummaryFailureType.UPSTAGE_CLIENT_ERROR; + } + if (value.contains("json 파싱") || value.contains("응답이 비어") || value.contains("본문이 비어")) { + return ArticleSummaryFailureType.MODEL_RESPONSE_INVALID; + } + if (value.contains("요약에 사용할 본문") || value.contains("내용이 없습니다")) { + return ArticleSummaryFailureType.EMPTY_SOURCE; + } + if (value.contains("요약할 핵심 정보") || value.contains("결과가 비어")) { + return ArticleSummaryFailureType.EMPTY_RESULT; + } + if (value.contains("upstage api key")) { + return ArticleSummaryFailureType.CONFIG; + } + if (value.contains("검증") || value.contains("출처에 없는") || value.contains("초과") || value.contains("중복")) { + return ArticleSummaryFailureType.VALIDATION; + } + return ArticleSummaryFailureType.UNKNOWN; + } + + private String truncate(String value) { + if (value.length() <= MAX_LENGTH) { + return value; + } + return value.substring(0, MAX_LENGTH); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureType.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureType.java new file mode 100644 index 000000000..dd180a611 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryFailureType.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.community.article.service.summary; + +public enum ArticleSummaryFailureType { + RATE_LIMIT, + NETWORK_TRANSIENT, + UPSTAGE_SERVER_ERROR, + UPSTAGE_CLIENT_ERROR, + DOCUMENT_DOWNLOAD, + DOCUMENT_PARSE, + MODEL_RESPONSE_INVALID, + VALIDATION, + EMPTY_SOURCE, + EMPTY_RESULT, + CONFIG, + UNKNOWN +} diff --git a/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java new file mode 100644 index 000000000..67d054dec --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java @@ -0,0 +1,145 @@ +package in.koreatech.koin.acceptance.admin; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.ArticleAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.BoardAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.admin.manager.model.Admin; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummary; +import in.koreatech.koin.domain.community.article.model.Board; +import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryRepository; +import in.koreatech.koin.domain.student.model.Department; +import in.koreatech.koin.domain.student.model.Student; + +class AdminArticleAiSummaryApiTest extends AcceptanceTest { + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private BoardAcceptanceFixture boardFixture; + + @Autowired + private ArticleAcceptanceFixture articleFixture; + + @Autowired + private ArticleAiSummaryRepository articleAiSummaryRepository; + + private String adminToken; + private String studentToken; + private Article failedArticle; + private ArticleAiSummary failedSummary; + + @BeforeEach + void setUp() { + clear(); + Department department = departmentFixture.컴퓨터공학부(); + Student student = userFixture.준호_학생(department, null); + Admin admin = userFixture.코인_운영자(); + studentToken = userFixture.getToken(student.getUser()); + adminToken = userFixture.getToken(admin.getUser()); + Board board = boardFixture.자유게시판(); + Article waitingArticle = articleFixture.자유글_1(board, student.getUser()); + failedArticle = articleFixture.자유글_2(board, student.getUser()); + + articleAiSummaryRepository.save(ArticleAiSummary.waiting( + waitingArticle, + "fingerprint-wait", + waitingArticle.getUpdatedAt(), + "solar-pro3", + "v9" + )); + failedSummary = ArticleAiSummary.waiting( + failedArticle, + "fingerprint-failed", + failedArticle.getUpdatedAt(), + "solar-pro3", + "v9" + ); + failedSummary.completeFailure( + "Upstage 요약 API 호출에 실패했습니다. status=429, body={\"url\":\"https://koreatech.in/file.pdf?token=abc\"} " + + "Authorization: Bearer abc.def up_S9K7816h4noxh8JQjJPigwhLohsIz", + LocalDateTime.now(clock).minusMinutes(1) + ); + articleAiSummaryRepository.save(failedSummary); + } + + @Test + void 관리자가_게시글_AI_요약_큐_상태를_조회한다() throws Exception { + mockMvc.perform( + get("/admin/articles/ai-summaries/overview") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status_counts[0].status").value("WAIT")) + .andExpect(jsonPath("$.status_counts[0].count").value(1)) + .andExpect(jsonPath("$.status_counts[3].status").value("FAILED")) + .andExpect(jsonPath("$.status_counts[3].count").value(1)) + .andExpect(jsonPath("$.queue.ready_wait_count").value(1)) + .andExpect(jsonPath("$.queue.retryable_failed_count").value(1)) + .andExpect(jsonPath("$.config.batch_size").value(5)); + } + + @Test + void 관리자가_실패한_게시글_AI_요약_목록과_마스킹된_오류를_조회한다() throws Exception { + mockMvc.perform( + get("/admin/articles/ai-summaries") + .header("Authorization", "Bearer " + adminToken) + .param("status", "FAILED") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(1)) + .andExpect(jsonPath("$.summaries[0].summary_id").value(failedSummary.getId())) + .andExpect(jsonPath("$.summaries[0].article_id").value(failedArticle.getId())) + .andExpect(jsonPath("$.summaries[0].failure_type").value("RATE_LIMIT")) + .andExpect(jsonPath("$.summaries[0].failure_message").value(containsString("body="))) + .andExpect(jsonPath("$.summaries[0].failure_message").value(not(containsString("abc.def")))) + .andExpect(jsonPath("$.summaries[0].failure_message").value(not(containsString("up_S9K")))) + .andExpect(jsonPath("$.summaries[0].failure_message").value(not(containsString("token=abc")))); + } + + @Test + void 관리자가_게시글_AI_요약_상세를_조회해도_원문과_raw_오류는_노출되지_않는다() throws Exception { + mockMvc.perform( + get("/admin/articles/ai-summaries/{summaryId}", failedSummary.getId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.article_title").value(failedArticle.getTitle())) + .andExpect(jsonPath("$.failure_message").value(containsString("body="))) + .andExpect(jsonPath("$.content").doesNotExist()) + .andExpect(jsonPath("$.prompt").doesNotExist()) + .andExpect(jsonPath("$.parsed_text").doesNotExist()); + } + + @Test + void 관리자가_아니면_게시글_AI_요약_큐를_조회할_수_없다() throws Exception { + mockMvc.perform( + get("/admin/articles/ai-summaries/overview") + .header("Authorization", "Bearer " + studentToken) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isForbidden()); + } +} 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 231eb1431..a929ac29a 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 @@ -27,6 +27,7 @@ import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryProperties; import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryService; 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.infrastructure.upstage.client.UpstageProperties; @@ -122,6 +123,7 @@ private ArticleAiSummaryService service(Clock clock) { articleRepository, sourceReader, contentRenderer, + new ArticleSummaryFailureReasonSanitizer(), properties, upstageProperties, clock diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryWorkerTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryWorkerTest.java index 8cbf10a43..eacc2101c 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryWorkerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryWorkerTest.java @@ -240,6 +240,31 @@ void setUp() { verify(articleAiSummaryService, never()).completeSuccess(any(), any(), any(), any()); } + @Test + void 첨부_중심_게시글인데_첨부_내용이_없으면_Solar를_호출하지_않고_스킵한다() { + ArticleSummarySourceSeed seed = sourceSeed(); + ArticleSummarySource source = new ArticleSummarySource( + 1, + "장학금 신청 안내", + "첨부파일을 확인하세요.", + "학생처", + LocalDate.of(2026, 5, 1), + LocalDateTime.of(2026, 5, 1, 10, 0), + List.of(), + false, + "fingerprint" + ); + when(articleAiSummaryService.getGenerationSeed(1, "worker")).thenReturn(Optional.of(seed)); + when(sourceReader.read(seed)).thenReturn(source); + + worker.process(1, "worker"); + + verify(articleAiSummaryService).skip(eq(1), eq("worker"), contains("첨부 중심 게시글")); + verify(articleSummaryAiClient, never()).summarize(any()); + verify(articleAiSummaryService, never()).completeFailure(any(), any(), any()); + verify(articleAiSummaryService, never()).completeSuccess(any(), any(), any(), any()); + } + @Test void retry_after가_있는_외부_API_일시_오류는_재시도_시간과_함께_실패_처리한다() { ArticleSummarySourceSeed seed = sourceSeed(); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizerTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizerTest.java new file mode 100644 index 000000000..507532741 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleSummaryFailureReasonSanitizerTest.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.unit.domain.community.article.service.summary; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureReasonSanitizer; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureType; + +class ArticleSummaryFailureReasonSanitizerTest { + + private final ArticleSummaryFailureReasonSanitizer sanitizer = new ArticleSummaryFailureReasonSanitizer(); + + @Test + void 민감한_토큰_URL_query_provider_body를_마스킹한다() { + String reason = "Upstage 요약 API 호출에 실패했습니다. status=429, body={\"url\":\"https://x.com/a.pdf?token=abc\"} " + + "Authorization: Bearer abc.def up_S9K7816h4noxh8JQjJPigwhLohsIz"; + + String sanitized = sanitizer.sanitize(reason); + + assertThat(sanitized).contains("status=429"); + assertThat(sanitized).contains("body="); + assertThat(sanitized).doesNotContain("abc.def"); + assertThat(sanitized).doesNotContain("up_S9K"); + assertThat(sanitized).doesNotContain("token=abc"); + } + + @Test + void 실패_원인을_운영자가_볼_수_있는_유형으로_분류한다() { + assertThat(sanitizer.classify("Upstage 요약 API 호출에 실패했습니다. status=429")) + .isEqualTo(ArticleSummaryFailureType.RATE_LIMIT); + assertThat(sanitizer.classify("Upstage 요약 처리 중 오류가 발생했습니다. cause=PrematureCloseException")) + .isEqualTo(ArticleSummaryFailureType.NETWORK_TRANSIENT); + assertThat(sanitizer.classify("Upstage 문서 파싱 API 호출에 실패했습니다. status=500")) + .isEqualTo(ArticleSummaryFailureType.DOCUMENT_PARSE); + assertThat(sanitizer.classify("출처에 없는 날짜/숫자 정보가 포함되었습니다. token=5월 21일")) + .isEqualTo(ArticleSummaryFailureType.VALIDATION); + } +}