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 index 32d24e6d7..450a2b570 100644 --- a/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java +++ b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryApi.java @@ -9,9 +9,12 @@ import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryLogsResponse; import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryOverviewResponse; import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryResponse; +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.service.summary.ArticleSummaryFailureType; import in.koreatech.koin.global.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -49,6 +52,23 @@ ResponseEntity getSummaries( @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/logs") + ResponseEntity getLogs( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "summary_id", required = false) Integer summaryId, + @RequestParam(name = "article_id", required = false) Integer articleId, + @RequestParam(name = "event_type", required = false) ArticleAiSummaryLogType eventType, + @RequestParam(name = "failure_type", required = false) ArticleSummaryFailureType failureType, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponses(value = { @ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), 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 index 589fe036d..ac92980d1 100644 --- a/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java +++ b/src/main/java/in/koreatech/koin/admin/article/controller/AdminArticleAiSummaryController.java @@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryLogsResponse; 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.ArticleAiSummaryLogType; import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureType; import in.koreatech.koin.global.auth.Auth; import lombok.RequiredArgsConstructor; @@ -41,6 +44,27 @@ public ResponseEntity getSummaries( return ResponseEntity.ok(adminArticleAiSummaryService.getSummaries(page, limit, status)); } + @Override + @GetMapping("/admin/articles/ai-summaries/logs") + public ResponseEntity getLogs( + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10", required = false) Integer limit, + @RequestParam(name = "summary_id", required = false) Integer summaryId, + @RequestParam(name = "article_id", required = false) Integer articleId, + @RequestParam(name = "event_type", required = false) ArticleAiSummaryLogType eventType, + @RequestParam(name = "failure_type", required = false) ArticleSummaryFailureType failureType, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok(adminArticleAiSummaryService.getLogs( + page, + limit, + summaryId, + articleId, + eventType, + failureType + )); + } + @Override @GetMapping("/admin/articles/ai-summaries/{summaryId}") public ResponseEntity getSummary( diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogProjection.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogProjection.java new file mode 100644 index 000000000..a32949121 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogProjection.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.admin.article.dto; + +import java.time.LocalDateTime; + +public interface AdminArticleAiSummaryLogProjection { + + Integer getLogId(); + + Integer getSummaryId(); + + Integer getArticleId(); + + Integer getBoardId(); + + String getArticleTitle(); + + String getEventType(); + + String getStatus(); + + String getFailureType(); + + String getMessage(); + + Integer getRetryCount(); + + LocalDateTime getNextAttemptAt(); + + String getWorkerId(); + + LocalDateTime getCreatedAt(); +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogResponse.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogResponse.java new file mode 100644 index 000000000..865a6a07f --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogResponse.java @@ -0,0 +1,53 @@ +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 io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminArticleAiSummaryLogResponse( + @Schema(description = "로그 ID", example = "1", requiredMode = REQUIRED) + Integer logId, + + @Schema(description = "요약 작업 ID", example = "12") + Integer summaryId, + + @Schema(description = "게시글 ID", example = "17291") + Integer articleId, + + @Schema(description = "게시판 ID", example = "5") + Integer boardId, + + @Schema(description = "게시글 제목") + String articleTitle, + + @Schema(description = "로그 유형", example = "FAILED", requiredMode = REQUIRED) + String eventType, + + @Schema(description = "로그 발생 시점의 요약 상태", example = "FAILED") + String status, + + @Schema(description = "실패 유형", example = "RATE_LIMIT") + String failureType, + + @Schema(description = "안전하게 마스킹된 메시지") + String message, + + @Schema(description = "로그 발생 시점의 재시도 횟수", example = "2") + Integer retryCount, + + @Schema(description = "다음 시도 가능 시각") + LocalDateTime nextAttemptAt, + + @Schema(description = "작업자 ID") + String workerId, + + @Schema(description = "로그 생성 시각", requiredMode = REQUIRED) + LocalDateTime createdAt +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogsResponse.java b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogsResponse.java new file mode 100644 index 000000000..a700f2318 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/article/dto/AdminArticleAiSummaryLogsResponse.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 AdminArticleAiSummaryLogsResponse( + @Schema(description = "게시글 AI 요약 로그 목록", requiredMode = REQUIRED) + List logs, + + @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 AdminArticleAiSummaryLogsResponse of( + Page pagedResult, + Criteria criteria + ) { + return new AdminArticleAiSummaryLogsResponse( + pagedResult.getContent(), + pagedResult.getTotalElements(), + pagedResult.getContent().size(), + pagedResult.getTotalPages(), + criteria.getPage() + 1 + ); + } +} 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 index fbcbf128a..d524bb1f6 100644 --- a/src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java +++ b/src/main/java/in/koreatech/koin/admin/article/service/AdminArticleAiSummaryService.java @@ -13,15 +13,21 @@ import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.admin.article.dto.AdminArticleAiSummariesResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryLogProjection; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryLogResponse; +import in.koreatech.koin.admin.article.dto.AdminArticleAiSummaryLogsResponse; 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.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.service.summary.ArticleAiSummaryProperties; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureType; import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureReasonSanitizer; import lombok.RequiredArgsConstructor; @@ -31,6 +37,7 @@ public class AdminArticleAiSummaryService { private final ArticleAiSummaryRepository articleAiSummaryRepository; + private final ArticleAiSummaryLogRepository articleAiSummaryLogRepository; private final ArticleAiSummaryProperties properties; private final ArticleSummaryFailureReasonSanitizer failureReasonSanitizer; private final Clock clock; @@ -83,6 +90,28 @@ public AdminArticleAiSummariesResponse getSummaries(Integer page, Integer limit, return AdminArticleAiSummariesResponse.of(summaries, criteria); } + public AdminArticleAiSummaryLogsResponse getLogs( + Integer page, + Integer limit, + Integer summaryId, + Integer articleId, + ArticleAiSummaryLogType eventType, + ArticleSummaryFailureType failureType + ) { + Criteria criteria = Criteria.of(page, limit); + PageRequest pageable = PageRequest.of(criteria.getPage(), criteria.getLimit()); + Page logs = articleAiSummaryLogRepository + .findAdminLogs( + summaryId, + articleId, + eventType == null ? null : eventType.name(), + failureType == null ? null : failureType.name(), + pageable + ) + .map(this::toLogResponse); + return AdminArticleAiSummaryLogsResponse.of(logs, criteria); + } + public AdminArticleAiSummaryResponse getSummary(Integer summaryId) { return articleAiSummaryRepository.findAdminSummaryById(summaryId) .map(this::toResponse) @@ -112,6 +141,25 @@ private AdminArticleAiSummaryResponse toResponse(AdminArticleAiSummaryProjection ); } + private AdminArticleAiSummaryLogResponse toLogResponse(AdminArticleAiSummaryLogProjection log) { + String message = failureReasonSanitizer.sanitize(log.getMessage()); + return new AdminArticleAiSummaryLogResponse( + log.getLogId(), + log.getSummaryId(), + log.getArticleId(), + log.getBoardId(), + log.getArticleTitle(), + log.getEventType(), + log.getStatus(), + log.getFailureType(), + message, + log.getRetryCount(), + log.getNextAttemptAt(), + log.getWorkerId(), + log.getCreatedAt() + ); + } + private Long nullToZero(Long value) { return value == null ? 0L : value; } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLog.java b/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLog.java new file mode 100644 index 000000000..f32141a4e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLog.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.domain.community.article.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "article_ai_summary_logs") +@NoArgsConstructor(access = PROTECTED) +public class ArticleAiSummaryLog extends BaseEntity { + + private static final int MESSAGE_LIMIT = 500; + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @Column(name = "summary_id") + private Integer summaryId; + + @Column(name = "article_id") + private Integer articleId; + + @Column(name = "board_id") + private Integer boardId; + + @Enumerated(STRING) + @Column(name = "event_type", nullable = false) + private ArticleAiSummaryLogType eventType; + + @Enumerated(STRING) + @Column(name = "status") + private ArticleAiSummaryStatus status; + + @Enumerated(STRING) + @Column(name = "failure_type") + private ArticleSummaryFailureType failureType; + + @Column(name = "message", length = MESSAGE_LIMIT) + private String message; + + @Column(name = "retry_count") + private Integer retryCount; + + @Column(name = "next_attempt_at", columnDefinition = "TIMESTAMP") + private LocalDateTime nextAttemptAt; + + @Column(name = "worker_id", length = 100) + private String workerId; + + @Builder + private ArticleAiSummaryLog( + Integer summaryId, + Integer articleId, + Integer boardId, + ArticleAiSummaryLogType eventType, + ArticleAiSummaryStatus status, + ArticleSummaryFailureType failureType, + String message, + Integer retryCount, + LocalDateTime nextAttemptAt, + String workerId + ) { + this.summaryId = summaryId; + this.articleId = articleId; + this.boardId = boardId; + this.eventType = eventType; + this.status = status; + this.failureType = failureType; + this.message = truncate(message); + this.retryCount = retryCount; + this.nextAttemptAt = nextAttemptAt; + this.workerId = workerId; + } + + public static ArticleAiSummaryLog of( + ArticleAiSummary summary, + ArticleAiSummaryLogType eventType, + ArticleSummaryFailureType failureType, + String message, + String workerId + ) { + Article article = summary.getArticle(); + Board board = article == null ? null : article.getBoard(); + return ArticleAiSummaryLog.builder() + .summaryId(summary.getId()) + .articleId(article == null ? null : article.getId()) + .boardId(board == null ? null : board.getId()) + .eventType(eventType) + .status(summary.getStatus()) + .failureType(failureType) + .message(message) + .retryCount(summary.getRetryCount()) + .nextAttemptAt(summary.getNextAttemptAt()) + .workerId(workerId) + .build(); + } + + private String truncate(String value) { + if (value == null || value.length() <= MESSAGE_LIMIT) { + return value; + } + return value.substring(0, MESSAGE_LIMIT); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLogType.java b/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLogType.java new file mode 100644 index 000000000..de71ed982 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/ArticleAiSummaryLogType.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.community.article.model; + +public enum ArticleAiSummaryLogType { + RETRY_WAITING, + FAILED, + TERMINAL_FAILED, + SKIPPED +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryLogRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryLogRepository.java new file mode 100644 index 000000000..dff573201 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleAiSummaryLogRepository.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.domain.community.article.repository; + +import java.time.LocalDateTime; + +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.AdminArticleAiSummaryLogProjection; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLog; + +public interface ArticleAiSummaryLogRepository extends Repository { + + ArticleAiSummaryLog save(ArticleAiSummaryLog log); + + @Modifying + @Query(""" + DELETE FROM ArticleAiSummaryLog l + WHERE l.createdAt < :threshold + """) + int deleteOlderThan(@Param("threshold") LocalDateTime threshold); + + @Query(value = """ + SELECT + l.id AS logId, + l.summary_id AS summaryId, + l.article_id AS articleId, + l.board_id AS boardId, + a.title AS articleTitle, + l.event_type AS eventType, + l.status AS status, + l.failure_type AS failureType, + l.message AS message, + l.retry_count AS retryCount, + l.next_attempt_at AS nextAttemptAt, + l.worker_id AS workerId, + l.created_at AS createdAt + FROM article_ai_summary_logs l + LEFT JOIN new_articles a ON a.id = l.article_id AND a.is_deleted = 0 + WHERE (:summaryId IS NULL OR l.summary_id = :summaryId) + AND (:articleId IS NULL OR l.article_id = :articleId) + AND (:eventType IS NULL OR l.event_type = :eventType) + AND (:failureType IS NULL OR l.failure_type = :failureType) + ORDER BY l.created_at DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(*) + FROM article_ai_summary_logs l + WHERE (:summaryId IS NULL OR l.summary_id = :summaryId) + AND (:articleId IS NULL OR l.article_id = :articleId) + AND (:eventType IS NULL OR l.event_type = :eventType) + AND (:failureType IS NULL OR l.failure_type = :failureType) + """, + nativeQuery = true) + Page findAdminLogs( + @Param("summaryId") Integer summaryId, + @Param("articleId") Integer articleId, + @Param("eventType") String eventType, + @Param("failureType") String failureType, + Pageable pageable + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleAiSummaryScheduler.java b/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleAiSummaryScheduler.java index 42d0f6744..80980da4b 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleAiSummaryScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/scheduler/ArticleAiSummaryScheduler.java @@ -60,6 +60,19 @@ public void generateArticleAiSummaries() { } } + // 기본 FAILED 재처리 창 안의 저트래픽 시간대이며, 정각 배치와 겹치지 않도록 03:20에 실행한다. + @Scheduled(cron = "0 20 3 * * *") + public void deleteOldArticleAiSummaryLogs() { + try { + int deletedCount = articleAiSummaryService.deleteOldLogs(); + if (deletedCount > 0) { + log.info("90일 지난 게시글 AI 요약 로그를 삭제했습니다. count: {}", deletedCount); + } + } catch (Exception e) { + log.warn("게시글 AI 요약 로그 삭제 중 오류가 발생했습니다.", e); + } + } + private int resolveClaimLimit() { ThreadPoolExecutor executor = articleSummaryTaskExecutor.getThreadPoolExecutor(); int idleWorkerCount = Math.max(0, articleSummaryTaskExecutor.getMaxPoolSize() - articleSummaryTaskExecutor.getActiveCount()); 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 c08030186..a35ccc233 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 @@ -20,17 +20,25 @@ 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.ArticleAiSummaryLog; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLogType; +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; import in.koreatech.koin.infrastructure.upstage.client.UpstageProperties; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ArticleAiSummaryService { + private static final int LOG_RETENTION_DAYS = 90; + private final ArticleAiSummaryRepository articleAiSummaryRepository; + private final ArticleAiSummaryLogRepository articleAiSummaryLogRepository; private final ArticleRepository articleRepository; private final ArticleSummarySourceReader sourceReader; private final ArticleSummaryContentRenderer contentRenderer; @@ -160,9 +168,23 @@ public void completeFailure(Integer summaryId, String workerId, String reason, D } if (retryAfter != null && nextAttemptAt != null) { summary.waitForRetry(sanitizedReason, nextAttemptAt); + ArticleSummaryFailureType failureType = saveLog( + summary, + ArticleAiSummaryLogType.RETRY_WAITING, + sanitizedReason, + workerId + ); + logRetryWaiting(summary, workerId, failureType, sanitizedReason, nextAttemptAt); return; } summary.completeFailure(sanitizedReason, nextAttemptAt); + ArticleSummaryFailureType failureType = saveLog( + summary, + nextAttemptAt == null ? ArticleAiSummaryLogType.TERMINAL_FAILED : ArticleAiSummaryLogType.FAILED, + sanitizedReason, + workerId + ); + logFailure(summary, workerId, failureType, sanitizedReason, nextAttemptAt); }); } @@ -170,17 +192,39 @@ 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( - failureReasonSanitizer.sanitize(reason), - properties.getMaxRetryCount() - )); + .ifPresent(summary -> { + String sanitizedReason = failureReasonSanitizer.sanitize(reason); + summary.completeFailureWithoutRetry(sanitizedReason, properties.getMaxRetryCount()); + ArticleSummaryFailureType failureType = saveLog( + summary, + ArticleAiSummaryLogType.TERMINAL_FAILED, + sanitizedReason, + workerId + ); + logTerminalFailure(summary, workerId, failureType, sanitizedReason, "재시도 불가능한 오류입니다."); + }); } @Transactional public void skip(Integer summaryId, String workerId, String reason) { articleAiSummaryRepository.findById(summaryId) .filter(summary -> summary.isProcessingBy(workerId)) - .ifPresent(summary -> summary.skip(failureReasonSanitizer.sanitize(reason))); + .ifPresent(summary -> { + String sanitizedReason = failureReasonSanitizer.sanitize(reason); + summary.skip(sanitizedReason); + ArticleSummaryFailureType failureType = saveLog( + summary, + ArticleAiSummaryLogType.SKIPPED, + sanitizedReason, + workerId + ); + logSkipped(summary, workerId, failureType, sanitizedReason); + }); + } + + @Transactional + public int deleteOldLogs() { + return articleAiSummaryLogRepository.deleteOlderThan(LocalDateTime.now(clock).minusDays(LOG_RETENTION_DAYS)); } private void enqueueIfEnabled(Article article, String fingerprint, LocalDateTime sourceUpdatedAt) { @@ -232,4 +276,125 @@ private Duration resolveRetryDelay(int nextRetryCount, Duration retryAfter) { Duration configuredBackoff = Duration.ofMinutes((long)properties.getRetryBackoffMinutes() * multiplier); return configuredBackoff.compareTo(maxBackoff) > 0 ? maxBackoff : configuredBackoff; } + + private void logRetryWaiting( + ArticleAiSummary summary, + String workerId, + ArticleSummaryFailureType failureType, + String reason, + LocalDateTime nextAttemptAt + ) { + log.debug( + "게시글 AI 요약 일시 오류로 재시도 대기합니다. summaryId: {}, articleId: {}, boardId: {}, status: {}, " + + "failureType: {}, retryCount: {}/{}, nextAttemptAt: {}, workerId: {}, reason: {}, " + + "impact: 요약은 아직 노출되지 않고 원문만 반환됩니다.", + summary.getId(), + articleId(summary), + boardId(summary), + summary.getStatus(), + failureType, + summary.getRetryCount(), + properties.getMaxRetryCount(), + nextAttemptAt, + workerId, + reason + ); + } + + private void logFailure( + ArticleAiSummary summary, + String workerId, + ArticleSummaryFailureType failureType, + String reason, + LocalDateTime nextAttemptAt + ) { + if (nextAttemptAt == null) { + logTerminalFailure(summary, workerId, failureType, reason, "최대 재시도 횟수에 도달했습니다."); + return; + } + log.debug( + "게시글 AI 요약 생성 실패로 FAILED 재처리 큐에 등록했습니다. summaryId: {}, articleId: {}, boardId: {}, " + + "status: {}, failureType: {}, retryCount: {}/{}, nextAttemptAt: {}, retryWindow: {}:00-{}:00, " + + "workerId: {}, reason: {}, impact: 요약은 아직 노출되지 않고 원문만 반환됩니다.", + summary.getId(), + articleId(summary), + boardId(summary), + summary.getStatus(), + failureType, + summary.getRetryCount(), + properties.getMaxRetryCount(), + nextAttemptAt, + properties.getBoundedFailedRetryWindowStartHour(), + properties.getBoundedFailedRetryWindowEndHour(), + workerId, + reason + ); + } + + private void logTerminalFailure( + ArticleAiSummary summary, + String workerId, + ArticleSummaryFailureType failureType, + String reason, + String cause + ) { + log.debug( + "게시글 AI 요약 생성이 중단되었습니다. summaryId: {}, articleId: {}, boardId: {}, status: {}, " + + "failureType: {}, retryCount: {}/{}, workerId: {}, cause: {}, reason: {}, " + + "impact: 해당 게시글은 요약 없이 원문만 반환됩니다. action: Admin AI 요약 상세 API로 상태를 확인하고 원문/첨부/Upstage 상태를 점검하세요.", + summary.getId(), + articleId(summary), + boardId(summary), + summary.getStatus(), + failureType, + summary.getRetryCount(), + properties.getMaxRetryCount(), + workerId, + cause, + reason + ); + } + + private void logSkipped( + ArticleAiSummary summary, + String workerId, + ArticleSummaryFailureType failureType, + String reason + ) { + log.debug( + "게시글 AI 요약 생성을 스킵했습니다. summaryId: {}, articleId: {}, boardId: {}, status: {}, " + + "failureType: {}, workerId: {}, reason: {}, impact: 요약 없이 원문만 반환됩니다.", + summary.getId(), + articleId(summary), + boardId(summary), + summary.getStatus(), + failureType, + workerId, + reason + ); + } + + private ArticleSummaryFailureType saveLog( + ArticleAiSummary summary, + ArticleAiSummaryLogType eventType, + String reason, + String workerId + ) { + ArticleSummaryFailureType failureType = failureReasonSanitizer.classify(reason); + articleAiSummaryLogRepository.save(ArticleAiSummaryLog.of(summary, eventType, failureType, reason, workerId)); + return failureType; + } + + private Integer articleId(ArticleAiSummary summary) { + Article article = summary.getArticle(); + return article == null ? null : article.getId(); + } + + private Integer boardId(ArticleAiSummary summary) { + Article article = summary.getArticle(); + if (article == null || article.getBoard() == null) { + return null; + } + return article.getBoard().getId(); + } } 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 e45cf49d9..d60f5ac33 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 @@ -29,15 +29,19 @@ public void process(Integer summaryId, String workerId) { .ifPresent(seed -> generate(summaryId, workerId, seed)); } catch (ArticleSummaryExternalApiException e) { if (e.isRetryable()) { - log.warn("게시글 AI 요약 외부 API가 일시 실패했습니다. summaryId: {}, reason: {}", summaryId, e.getMessage()); articleAiSummaryService.completeFailure(summaryId, workerId, e.getMessage(), e.getRetryAfter()); return; } - log.error("게시글 AI 요약 외부 API가 재시도 불가능한 오류를 반환했습니다. summaryId: {}", summaryId, e); articleAiSummaryService.completeFailureWithoutRetry(summaryId, workerId, e.getMessage()); } catch (Exception e) { - log.error("게시글 AI 요약 생성 중 오류가 발생했습니다. summaryId: {}", summaryId, e); - articleAiSummaryService.completeFailure(summaryId, workerId, e.getMessage()); + log.error( + "게시글 AI 요약 워커에서 예기치 못한 예외가 발생했습니다. summaryId: {}, workerId: {}, exception: {}", + summaryId, + workerId, + e.getClass().getSimpleName(), + e + ); + articleAiSummaryService.completeFailure(summaryId, workerId, exceptionSummary(e)); } } @@ -81,7 +85,6 @@ private void generate(Integer summaryId, String workerId, ArticleSummarySourceSe try { summaryLines = validateOrCorrect(result, prompt); } catch (ArticleSummaryValidationException e) { - log.warn("게시글 AI 요약이 최종 검증을 통과하지 못해 재시도 대기로 전환합니다. summaryId: {}, reason: {}", summaryId, e.getMessage()); articleAiSummaryService.completeFailure(summaryId, workerId, e.getMessage()); return; } @@ -197,6 +200,13 @@ private String normalize(String value) { return value.toLowerCase(Locale.ROOT).replaceAll("\\s+", ""); } + private String exceptionSummary(Exception e) { + if (!StringUtils.hasText(e.getMessage())) { + return e.getClass().getSimpleName(); + } + return e.getClass().getSimpleName() + ": " + e.getMessage(); + } + private record RefinementResult( ArticleSummaryResult result, boolean attempted diff --git a/src/main/resources/db/migration/V240__create_article_ai_summary_logs.sql b/src/main/resources/db/migration/V240__create_article_ai_summary_logs.sql new file mode 100644 index 000000000..ee7d1589e --- /dev/null +++ b/src/main/resources/db/migration/V240__create_article_ai_summary_logs.sql @@ -0,0 +1,25 @@ +CREATE TABLE `koin`.`article_ai_summary_logs` +( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `summary_id` INT UNSIGNED NULL, + `article_id` INT UNSIGNED NULL, + `board_id` INT UNSIGNED NULL, + `event_type` VARCHAR(30) NOT NULL, + `status` VARCHAR(20) NULL, + `failure_type` VARCHAR(50) NULL, + `message` VARCHAR(500) CHARACTER SET 'utf8mb4' NULL, + `retry_count` INT UNSIGNED NULL, + `next_attempt_at` TIMESTAMP NULL, + `worker_id` VARCHAR(100) NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_article_ai_summary_logs_created_at` (`created_at`), + INDEX `idx_article_ai_summary_logs_summary_created` (`summary_id`, `created_at`), + INDEX `idx_article_ai_summary_logs_article_created` (`article_id`, `created_at`), + INDEX `idx_article_ai_summary_logs_type_created` (`event_type`, `failure_type`, `created_at`), + CONSTRAINT `fk_article_ai_summary_logs_summary_id` + FOREIGN KEY (`summary_id`) REFERENCES `koin`.`article_ai_summaries` (`id`) + ON DELETE SET NULL + ON UPDATE NO ACTION +); diff --git a/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java index 67d054dec..a45296d0f 100644 --- a/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/admin/AdminArticleAiSummaryApiTest.java @@ -21,8 +21,12 @@ 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.ArticleAiSummaryLog; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLogType; import in.koreatech.koin.domain.community.article.model.Board; +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.service.summary.ArticleSummaryFailureType; import in.koreatech.koin.domain.student.model.Department; import in.koreatech.koin.domain.student.model.Student; @@ -43,10 +47,14 @@ class AdminArticleAiSummaryApiTest extends AcceptanceTest { @Autowired private ArticleAiSummaryRepository articleAiSummaryRepository; + @Autowired + private ArticleAiSummaryLogRepository articleAiSummaryLogRepository; + private String adminToken; private String studentToken; private Article failedArticle; private ArticleAiSummary failedSummary; + private String rawFailureReason; @BeforeEach void setUp() { @@ -74,12 +82,18 @@ void setUp() { "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) - ); + rawFailureReason = "Upstage 요약 API 호출에 실패했습니다. status=429, " + + "body={\"url\":\"https://koreatech.in/file.pdf?token=abc\"} " + + "Authorization: Bearer abc.def up_S9K7816h4noxh8JQjJPigwhLohsIz"; + failedSummary.completeFailure(rawFailureReason, LocalDateTime.now(clock).minusMinutes(1)); articleAiSummaryRepository.save(failedSummary); + articleAiSummaryLogRepository.save(ArticleAiSummaryLog.of( + failedSummary, + ArticleAiSummaryLogType.FAILED, + ArticleSummaryFailureType.RATE_LIMIT, + rawFailureReason, + "worker-test" + )); } @Test @@ -133,6 +147,26 @@ void setUp() { .andExpect(jsonPath("$.parsed_text").doesNotExist()); } + @Test + void 관리자가_게시글_AI_요약_로그를_조회한다() throws Exception { + mockMvc.perform( + get("/admin/articles/ai-summaries/logs") + .header("Authorization", "Bearer " + adminToken) + .param("failure_type", "RATE_LIMIT") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(1)) + .andExpect(jsonPath("$.logs[0].summary_id").value(failedSummary.getId())) + .andExpect(jsonPath("$.logs[0].article_id").value(failedArticle.getId())) + .andExpect(jsonPath("$.logs[0].event_type").value("FAILED")) + .andExpect(jsonPath("$.logs[0].failure_type").value("RATE_LIMIT")) + .andExpect(jsonPath("$.logs[0].message").value(containsString("body="))) + .andExpect(jsonPath("$.logs[0].message").value(not(containsString("abc.def")))) + .andExpect(jsonPath("$.logs[0].message").value(not(containsString("up_S9K")))) + .andExpect(jsonPath("$.logs[0].message").value(not(containsString("token=abc")))); + } + @Test void 관리자가_아니면_게시글_AI_요약_큐를_조회할_수_없다() throws Exception { mockMvc.perform( 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 a929ac29a..482c7d7b4 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 @@ -17,11 +17,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; 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; import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryProperties; @@ -37,6 +41,9 @@ class ArticleAiSummaryServiceTest { @Mock private ArticleAiSummaryRepository articleAiSummaryRepository; + @Mock + private ArticleAiSummaryLogRepository articleAiSummaryLogRepository; + @Mock private ArticleRepository articleRepository; @@ -59,6 +66,11 @@ class ArticleAiSummaryServiceTest { assertThat(summary.getRetryCount()).isEqualTo(1); assertThat(summary.getNextAttemptAt()).isEqualTo(LocalDateTime.now(clock).plusMinutes(1)); assertThat(summary.getLockedUntil()).isNull(); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(ArticleAiSummaryLog.class); + verify(articleAiSummaryLogRepository).save(logCaptor.capture()); + assertThat(logCaptor.getValue().getEventType()).isEqualTo(ArticleAiSummaryLogType.RETRY_WAITING); + assertThat(logCaptor.getValue().getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); } @Test @@ -75,6 +87,16 @@ class ArticleAiSummaryServiceTest { assertThat(summary.getNextAttemptAt()).isEqualTo(LocalDateTime.now(clock).plusMinutes(5)); } + @Test + void 게시글_AI_요약_로그는_90일_이전_데이터를_삭제한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + + service.deleteOldLogs(); + + verify(articleAiSummaryLogRepository).deleteOlderThan(LocalDateTime.now(clock).minusDays(90)); + } + @Test void 실패_재처리_시간대가_아니면_FAILED_큐를_claim하지_않는다() { Clock clock = clockAt(12); @@ -120,6 +142,7 @@ private ArticleAiSummaryService service(Clock clock) { upstageProperties.setApiKey("test-api-key"); return new ArticleAiSummaryService( articleAiSummaryRepository, + articleAiSummaryLogRepository, articleRepository, sourceReader, contentRenderer,