diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java index ad58a18a..fdfa4451 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.domain.notification.application.NotificationCommandService; import com.devkor.ifive.nadab.domain.notification.core.entity.NotificationType; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository; import com.devkor.ifive.nadab.domain.typereport.application.event.TypeReportCompletedEvent; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; @@ -33,6 +34,7 @@ * - 리포트 제작 가능 → 사용자에게 제작 가능 알림 * - 일일 리포트 완성 → 유형 리포트 제작 가능 여부 체크 및 알림 * - PDF 내보내기 완성 → 사용자에게 완성 알림 + * - PDF 내보내기 실패 → 사용자에게 실패·환불 알림 * - @Async로 비동기 처리 (리포트 생성 스레드와 분리) */ @Component @@ -189,6 +191,42 @@ public void handlePdfExportCompleted(PdfExportCompletedEvent event) { } } + /** + * PDF 내보내기 실패 알림 + * - 실패 확정·환불이 커밋된 뒤 호출자(렌더 리스너·복구 스케줄러)가 발행하는 PdfExportFailedEvent 를 받아 알림 생성 + * - 이벤트는 트랜잭션 밖(failAndRefund 이후)에서 발행되므로 @EventListener(완성 알림과 동일), AFTER_COMMIT 아님 + * - 클릭 시 실패 화면 이동 = FCM data(type=PDF_EXPORT_FAILED, targetId=jobId)로 FE 라우팅 + * - 실패 화면이 쓰는 기간·유형은 GET /pdf-exports/{jobId} 가 내려준다 + */ + @Async("notificationTaskExecutor") + @EventListener + public void handlePdfExportFailed(PdfExportFailedEvent event) { + try { + NotificationContent content = messageFactory.createMessage( + NotificationType.PDF_EXPORT_FAILED, + Map.of() + ); + + String idempotencyKey = String.format("PDF_EXPORT_FAILED_%d", event.getJobId()); + notificationCommandService.sendNotification( + event.getUserId(), + NotificationType.PDF_EXPORT_FAILED, + content.title(), + content.body(), + content.inboxMessage(), + event.getJobId().toString(), + idempotencyKey + ); + + log.debug("PDF export failed notification created: jobId={}, userId={}", + event.getJobId(), event.getUserId()); + + } catch (Exception e) { + log.error("Failed to handle PDF export failed event: jobId={}, error={}", + event.getJobId(), e.getMessage(), e); + } + } + // ========== 리포트 제작 가능 알림 ========== /** diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java index 4f881f55..9f9317fe 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java @@ -20,6 +20,7 @@ public enum NotificationType { MONTHLY_REPORT_AVAILABLE("월간 리포트 제작 가능", NotificationGroup.REPORT), TYPE_REPORT_AVAILABLE("유형 리포트 제작 가능", NotificationGroup.REPORT), PDF_EXPORT_COMPLETED("PDF 내보내기 완성", NotificationGroup.REPORT), + PDF_EXPORT_FAILED("PDF 내보내기 실패", NotificationGroup.REPORT), // 소셜 알림 FRIEND_REQUEST_RECEIVED("친구 요청", NotificationGroup.SOCIAL), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java index af202f98..82689616 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java @@ -55,7 +55,7 @@ public class PdfExportController { 생성 로딩 화면의 "포함 내용" 개수(답변/주간/월간)는 이 API가 반환하지 않습니다 — 방금 생성 직전 호출한 미리보기(GET /pdf-exports/preview) 응답값을 그대로 표시하면 됩니다(생성 직후엔 그 값이 곧 생성 대상 개수). 화면을 벗어났다 다시 들어오는 재진입 흐름에서는 GET /pdf-exports/current가 같은 개수를 함께 내려줍니다.
동시 생성 1개: 한 사용자는 생성 중(PENDING/IN_PROGRESS) 작업을 동시에 1개만 가질 수 있습니다. 생성 중인데 다른 유형·기간으로 다시 호출하면 409(PDF_EXPORT_ALREADY_IN_PROGRESS)로 거부되며, 응답 data에 이미 생성 중인 작업의 jobId가 담겨 옵니다 — 그 작업의 생성 화면으로 유도하고 상세·포함 개수는 GET /pdf-exports/current로 받으세요.
멱등 재사용: 같은 유형·기간의 작업이 아직 생성 중일 때 다시 호출하면(응답을 못 받아 재시도하거나 버튼 더블탭 등) 재과금 없이 그 작업을 그대로 돌려주며 balanceAfter=null 입니다(이중 과금 없음, 지갑 추가 차감 표시 금지). 완료(COMPLETED)된 작업은 재사용하지 않으므로 같은 기간 재요청은 새 작업으로 재과금됩니다(= 재생성).
- 생성 실패는 아카이브에도 안 뜨고 완료 푸시도 없어, 오직 폴링(GET /pdf-exports/{jobId})의 status=FAILED(errorCode 포함)로만 드러납니다. 그래서 로딩 화면에서 폴링 중이라면 FAILED를 COMPLETED와 같은 '종료 상태'로 처리해야 합니다(안 그러면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤라면 따로 확인할 필요가 없습니다 — 차감 크리스탈은 자동 환불되고(지갑 이력에 남음) 산출물이 없어 사용자가 할 액션이 없습니다. 잔액을 표시 중이면 다시 조회해 갱신하세요.
+ 생성 실패는 아카이브에 뜨지 않습니다. 로딩 화면에서 폴링 중이라면 FAILED가 나오면 COMPLETED와 마찬가지로 폴링을 멈춥니다(FAILED를 종료로 처리하지 않으면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤에 실패한 경우에는 실패 푸시(data type=PDF_EXPORT_FAILED, targetId=jobId)가 갑니다. 그걸 누르면 그 jobId로 GET /pdf-exports/{jobId}를 호출해 기간·유형을 받고, 그 기간으로 GET /pdf-exports/preview를 한 번 더 호출해 포함 내용 개수까지 채워 실패 화면을 구성합니다(상세는 GET /pdf-exports/{jobId} 설명 참조). 어느 쪽이든 차감 크리스탈은 자동 환불되며(지갑 이력에 남음), 잔액을 표시 중이면 다시 조회해 갱신하세요.
내보낼 답변/리포트가 하나도 없으면 PDF_EXPORT_NO_DATA로 거부됩니다 — 미리보기(GET /pdf-exports/preview)로 사전 확인을 권장합니다. """, security = @SecurityRequirement(name = "bearerAuth"), @@ -162,6 +162,7 @@ public ResponseEntity> getCurrentPdfExp summary = "PDF 내보내기 미리보기(포함 개수)", description = """ 해당 기간에 포함될 답변·주간 리포트·월간 리포트 개수를 돌려줍니다. 생성/차감 전 확인 팝업에서 "무엇이 몇 개 포함되는지" 보여주는 용도의 순수 조회입니다.
+ 실패 화면에서도 재사용합니다: 실패 알림을 눌러 진입하면 로컬 카운트가 없으므로, GET /pdf-exports/{jobId} 로 받은 기간(startDate·endDate)을 그대로 넣어 이 API를 호출해 "포함 내용" 개수를 채웁니다.
유형과 무관하게 3종 개수를 모두 내려주므로, 선택한 유형에 맞는 값만 표시하면 됩니다(예: 답변만 선택 시 answerCount).
세 개수가 모두 0이면 생성해도 빈 PDF라 생성 API가 PDF_EXPORT_NO_DATA로 거부합니다. 이 경우 생성 버튼을 비활성화하거나 기획에 맞게 팝업을 띄우면 됩니다.
기간 규칙은 생성 API와 동일합니다(시작일 ≤ 종료일, 종료일 ≤ 오늘, 최대 1년) — 잘못된 기간이면 PDF_EXPORT_INVALID_PERIOD. @@ -204,6 +205,9 @@ public ResponseEntity> getPdfExportPrev FAILED일 때 errorCode 값(둘 다 자동 환불됨):
- PDF_EXPORT_GENERATION_FAILED: 생성 도중 오류로 실패
- PDF_EXPORT_GENERATION_TIMEOUT: 생성이 60분 안에 끝나지 않아 자동 취소(배포·서버 재시작 등으로 작업이 유실된 경우)
+ 응답에는 유형(type)·기간(startDate~endDate)도 함께 담깁니다. 로딩/실패 화면의 기간 표시, 차감 크리스탈(유형에서 유도: 리포트만/답변만 50, 둘 다 100), "다시 PDF 생성하기" 버튼의 조건 프리필에 쓰세요. 화면에 머물러 폴링 중이라면 이미 알고 있는 값이지만, 실패 알림을 눌러 들어온 경우(앱이 종료돼 있었다면 로컬 상태가 없음)에는 이 API가 유일한 출처입니다.
+ 실패 알림(FCM): 생성이 실패해 환불되면 푸시가 발송됩니다(data type=PDF_EXPORT_FAILED, targetId=jobId). 이걸 눌러 들어오면 그 jobId로 이 API를 호출해 실패 화면을 구성합니다. FAILED는 아카이브(COMPLETED만)와 GET /pdf-exports/current(진행 중만) 어디에도 나오지 않으므로, jobId 없이 실패 작업을 찾을 방법은 없습니다.
+ "포함 내용" 개수(답변/주간/월간)는 이 응답에 없습니다 — 정적값이라 폴링마다 싣지 않습니다. 얻는 방법: 생성 직후에는 미리보기(GET /pdf-exports/preview)에서 받은 값을, 진행 중 재진입은 GET /pdf-exports/current가 함께 내려주는 값을 그대로 쓰세요. 실패 알림을 눌러 실패 화면으로 콜드 스타트 진입한 경우(앱이 종료돼 있어 로컬 값이 없음)에는, 이 응답의 기간(startDate·endDate)으로 GET /pdf-exports/preview?startDate=..&endDate=.. 를 한 번 더 호출해 개수를 받으세요.
다운로드 URL은 이 응답에 포함되지 않습니다. COMPLETED가 된 뒤 POST /pdf-exports/{jobId}/download-url 로 발급받으세요(발급 빈도 제한이 폴링에 영향을 주지 않도록 분리되어 있습니다). """, security = @SecurityRequirement(name = "bearerAuth"), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java index 4d7d81dd..88f224ad 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; import java.time.OffsetDateTime; public record PdfExportStatusResponse( @@ -11,6 +12,16 @@ public record PdfExportStatusResponse( @Schema(description = "작업 상태 (PENDING/IN_PROGRESS/COMPLETED/FAILED)", example = "COMPLETED") String status, + @Schema(description = "내보내기 유형 (REPORT_ONLY/ANSWER_ONLY/REPORT_AND_ANSWER). 차감 크리스탈은 여기서 유도(리포트만/답변만 50, 둘 다 100)", + example = "REPORT_AND_ANSWER") + String type, + + @Schema(description = "기간 시작일", example = "2025-11-01") + LocalDate startDate, + + @Schema(description = "기간 종료일", example = "2025-11-30") + LocalDate endDate, + @Schema(description = "COMPLETED 시 다운로드 보관 만료 시각(완료 시각 + 7일). 그 외 null", example = "2025-11-08T05:30:00Z") OffsetDateTime expiresAt, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java index f1865f11..8e6a66b5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java @@ -44,12 +44,15 @@ public class PdfExportQueryService { private static final List ACTIVE_STATUSES = List.of(PdfExportStatus.PENDING, PdfExportStatus.IN_PROGRESS); - /** 폴링용 상태 조회. 다운로드 발급은 별도 엔드포인트. */ + /** 폴링용 상태 조회(상태·유형·기간). 다운로드 발급은 별도 엔드포인트. */ public PdfExportStatusResponse getStatus(Long userId, Long jobId) { PdfExportJob job = findOwnedJob(userId, jobId); return new PdfExportStatusResponse( job.getId(), job.getStatus().name(), + job.getType().name(), + job.getStartDate(), + job.getEndDate(), job.getExpiresAt(), job.isDownloadExpired(), job.getErrorCode()); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java index b7480425..9e03d3d8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java @@ -86,14 +86,20 @@ public PdfExportReserveResultDto reserveAndPublish(User user, PdfExportType type return new PdfExportReserveResultDto(jobId, balanceAfter); } - public void confirm(Long jobId, Long logId) { + /** + * 완료 확정. 반환값은 이 호출이 실제로 확정했는지다. + * 호출자(렌더 리스너)는 이 값이 true 일 때만 완료 알림을 발행한다. + * false 는 CAS 경합에서 져 아무것도 안 한 경우로, 그때 알리면 이미 실패·환불된 작업에 "PDF가 완성됐어요" 푸시가 나간다. + */ + public boolean confirm(Long jobId, Long logId) { // 진행 중일 때만 성공(1). 0이면 그 사이 다른 곳(복구 스윕)에서 이미 실패·환불된 job이라 확정하지 않는다. if (pdfExportJobRepository.markCompleted(jobId) == 0) { log.warn("[PDF_EXPORT][CONFIRM_SKIPPED] jobId={} — 이미 실패·환불 처리된 작업이라 확정하지 않는다", jobId); - return; + return false; } crystalLogRepository.markConfirmed(logId); dedupPreviousCompleted(jobId); + return true; } /** @@ -127,12 +133,17 @@ public void afterCommit() { }); } - public void failAndRefund(Long userId, Long jobId, Long logId, String errorCode) { + /** + * 실패 확정 + 환불. 반환값은 이 호출이 실제로 환불했는지다. + * 호출자(렌더 리스너·복구 스케줄러)는 이 값이 true 일 때만 실패 알림을 발행한다. + * false 는 CAS 경합에서 져 아무것도 안 한 경우로, 그때 알리면 이미 완료된 작업에 "생성에 실패했어요" 푸시가 나간다. + */ + public boolean failAndRefund(Long userId, Long jobId, Long logId, String errorCode) { // 진행 중일 때만 성공(1)한 호출만 환불을 책임진다. 0이면 이미 완료·환불된 job → 공짜 PDF·이중 환불 방지. if (pdfExportJobRepository.markFailed(jobId, errorCode) == 0) { log.warn("[PDF_EXPORT][REFUND_SKIPPED] jobId={}, errorCode={} — 이미 완료·환불된 작업이라 환불하지 않는다", jobId, errorCode); - return; + return false; } // 실제 차감된 값(CrystalLog.delta, 음수)을 그대로 되돌린다 — 로그에서 읽어 가격이 바뀌어도 차감액=환불액 보장. @@ -148,5 +159,6 @@ public void failAndRefund(Long userId, Long jobId, Long logId, String errorCode) crystalLogRepository.markRefunded(logId); log.warn("[PDF_EXPORT][REFUNDED] jobId={}, userId={}, refund={}, errorCode={}", jobId, userId, refundAmount, errorCode); + return true; } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java new file mode 100644 index 00000000..0d5d5497 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.pdfexport.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * PDF 내보내기 실패 FCM 알림 트리거. + * - failAndRefund 로 모이는 두 경로(렌더 리스너·복구 스케줄러)가 그 반환값이 true(=이 호출이 실제로 환불)일 때만 트랜잭션 커밋 후 발행한다. + * - 경합에서 진 호출은 이미 완료·환불된 job 이라 발행하지 않는다. + */ +@Getter +@RequiredArgsConstructor +public class PdfExportFailedEvent { + + private final Long jobId; + private final Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java index c7849024..0b84338d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.domain.pdfexport.application.helper.PdfPhotoPrefetcher; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfHtmlAssembler; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfImage; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfRenderer; @@ -130,16 +131,23 @@ public void handle(PdfExportRequestedEventDto event) { storage.upload(resultKey, pdfFile, PdfExportFileNames.downloadFileName(job), PdfExportFileNames.asciiFallbackFileName(job)); - txService.confirm(jobId, crystalLogId); - // ── 4) 완료 이벤트(FCM 알림용). 확정 이후라 여기서 실패해도 실패로 되돌리지 않는다 ── - notifyCompleted(jobId, userId); + // ── 4) 완료 이벤트(FCM 알림용). 실제로 확정한 경우에만 알린다. + // false 면 복구 스윕이 먼저 실패·환불한 것이라, 여기서 알리면 이미 실패 처리된 작업에 완성 푸시가 나간다. + if (txService.confirm(jobId, crystalLogId)) { + notifyCompleted(jobId, userId); + } } catch (Exception e) { // 렌더·업로드·confirm 어디서 실패해도: 실패 확정 + 환불(별도 Tx). CAS 가드로 이중 환불·공짜 PDF 방지. // error_code엔 안전한 ErrorCode enum 이름만 저장 — 예외 메시지·스택은 클라(getStatus)에 노출 금지. log.error("[PDF_EXPORT][GENERATION_FAILED] jobId={}, userId={}", jobId, userId, e); - txService.failAndRefund(userId, jobId, crystalLogId, ErrorCode.PDF_EXPORT_GENERATION_FAILED.name()); + if (txService.failAndRefund(userId, jobId, crystalLogId, + ErrorCode.PDF_EXPORT_GENERATION_FAILED.name())) { + // 실제로 환불한 경우에만 알린다. + // false 면 복구 스윕이 먼저 처리한 것이라, 여기서 또 알리면 실패 푸시가 중복된다. + notifyFailed(jobId, userId); + } } finally { // 업로드 성공·실패 무관 로컬 임시파일 정리(S3에 올라간 뒤엔 불필요, 실패 시엔 잔여 제거). if (pdfFile != null) { @@ -166,6 +174,19 @@ private void notifyCompleted(Long jobId, Long userId) { } } + /** + * 실패 알림 발행. 이 시점엔 실패 확정·환불이 이미 커밋된 뒤다. + * 예외가 새면 위 catch 밖으로 나가 원인 예외를 덮으므로 여기서 삼킨다. + */ + private void notifyFailed(Long jobId, Long userId) { + try { + eventPublisher.publishEvent(new PdfExportFailedEvent(jobId, userId)); + } catch (Exception e) { + log.warn("[PDF_EXPORT][NOTIFY_FAILED] jobId={}, userId={} — 환불은 정상 처리됐고 알림만 실패했다", + jobId, userId, e); + } + } + /** 결과 임시파일 삭제(실패해도 삼킨다). */ private void deleteQuietly(Path file) { try { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java index f684a8d9..de46de29 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java @@ -1,11 +1,13 @@ package com.devkor.ifive.nadab.domain.pdfexport.application.scheduler; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.core.entity.PdfExportJob; import com.devkor.ifive.nadab.domain.pdfexport.core.repository.PdfExportJobRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -30,6 +32,7 @@ public class PdfExportRecoveryScheduler { private final PdfExportJobRepository pdfExportJobRepository; private final PdfExportTxService txService; + private final ApplicationEventPublisher eventPublisher; @Scheduled(fixedDelay = 5 * 60 * 1000) public void recoverStuckJobs() { @@ -64,9 +67,14 @@ private boolean refundQuietly(PdfExportJob job, List failed) { Long jobId = job.getId(); try { Long userId = job.getUser().getId(); // LAZY 프록시 id 접근 — 쿼리 없음 - txService.failAndRefund(userId, jobId, job.getCrystalLogId(), + boolean refunded = txService.failAndRefund(userId, jobId, job.getCrystalLogId(), ErrorCode.PDF_EXPORT_GENERATION_TIMEOUT.name()); - return true; + if (refunded) { + // 이 경로는 정의상 사용자가 생성 화면을 떠난 뒤다(최소 60분 경과) — 알림이 유일한 통보 수단이다. + notifyFailed(jobId, userId); + } + // false 면 그 사이 렌더 리스너가 먼저 정리한 것이라 우리가 회수한 건이 아니다. + return refunded; } catch (Exception e) { if (failed.isEmpty()) { log.error("[PDF_EXPORT][RECOVERY] 개별 환불 실패: jobId={}", jobId, e); @@ -75,4 +83,16 @@ private boolean refundQuietly(PdfExportJob job, List failed) { return false; } } + + /** + * 실패 알림 발행. 환불은 이미 커밋된 뒤다. + * 예외가 새면 위 catch 가 환불 성공을 실패로 집계하므로 여기서 삼킨다. + */ + private void notifyFailed(Long jobId, Long userId) { + try { + eventPublisher.publishEvent(new PdfExportFailedEvent(jobId, userId)); + } catch (Exception e) { + log.warn("[PDF_EXPORT][RECOVERY] 알림 발행 실패: jobId={} — 환불은 정상 처리됐다", jobId, e); + } + } } \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java index c159a521..58e4abaa 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java @@ -201,6 +201,33 @@ void tearDown() throws IOException { verify(storage, never()).delete(anyString()); } + /* ── 실패 알림 발행 여부를 가르는 반환값 ──────────────────────────── */ + + @Test + void 실제로_환불하면_참을_돌려주고_지갑과_상태를_되돌린다() { + Reserved reserved = inProgressJob(fundedUser); + + boolean refunded = txService.failAndRefund(fundedUser.getId(), reserved.jobId(), reserved.logId(), + ErrorCode.PDF_EXPORT_GENERATION_FAILED.name()); + + assertThat(refunded).isTrue(); + assertThat(statusOf(reserved.jobId())).isEqualTo(PdfExportStatus.FAILED); + assertThat(logStatusOf(reserved.logId())).isEqualTo(CrystalLogStatus.REFUNDED); + assertThat(balanceOf(fundedUser)).isEqualTo(FUNDED_BALANCE); // 차감분 원복 + } + + @Test + void 이미_완료된_작업엔_거짓을_돌려준다() { + Reserved reserved = inProgressJob(fundedUser); + txService.confirm(reserved.jobId(), reserved.logId()); + + // markFailed 경합에서 진 호출(복구 스윕이 뒤늦게 도는 경우)이라 false. + // true 면 이미 완성된 PDF 에 "생성에 실패했어요" 푸시가 나가므로, 호출자는 이 값으로 알림을 막는다. + assertThat(txService.failAndRefund(fundedUser.getId(), reserved.jobId(), reserved.logId(), + ErrorCode.PDF_EXPORT_GENERATION_TIMEOUT.name())) + .isFalse(); + } + /* ── 픽스처·조회 ─────────────────────────────────────────────────── */ private static final Long UNKNOWN_USER_ID = -1L; diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java index b4dd3ff9..cafa3780 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.application.helper.PdfExportRenderQueue; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfHtmlAssembler; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfRenderer; @@ -96,6 +97,7 @@ void setUp() throws IOException { when(assembler.assemble(any(), any(), any(), any(), any(), any())) .thenReturn(new PdfHtmlAssembler.AssembledDocument(XHTML, Map.of())); when(renderer.render(any(), any())).thenReturn(pdfFile); + when(txService.confirm(anyLong(), anyLong())).thenReturn(true); listener = new PdfExportGenerationListener( jobRepository, queryRepository, assembler, renderer, storage, txService, renderQueue, eventPublisher); @@ -185,6 +187,20 @@ void tearDown() throws IOException { verify(txService, never()).failAndRefund(anyLong(), anyLong(), anyLong(), any()); } + @Test + void 복구_스윕이_먼저_실패처리했으면_완료를_알리지_않는다() { + givenJob(PdfExportType.ANSWER_ONLY); + when(queryRepository.findAnswersInPeriod(USER_ID, START, END)).thenReturn(List.of()); + // confirm 이 markCompleted 경합에서 짐 = 복구 스윕이 먼저 FAILED·환불 처리했다. + when(txService.confirm(anyLong(), anyLong())).thenReturn(false); + + listener.handle(EVENT); + + // 업로드는 됐지만 확정에 실패했으므로 완성 푸시를 보내면 안 된다(눌러도 아카이브에 없고 크리스탈은 환불됨). + verify(storage).upload(any(), any(), any(), any()); + verifyNoInteractions(eventPublisher); + } + @Test void 리포트답변_유형은_답변과_리포트를_모두_조회한다() { givenJob(PdfExportType.REPORT_AND_ANSWER); @@ -208,6 +224,7 @@ void tearDown() throws IOException { when(queryRepository.findAnswersInPeriod(USER_ID, START, END)) .thenReturn(List.of(answer("2026-01-05", "질문", null))); when(renderer.render(any(), any())).thenThrow(new RuntimeException("openhtmltopdf boom")); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), any())).thenReturn(true); listener.handle(EVENT); @@ -216,6 +233,26 @@ void tearDown() throws IOException { verify(txService).failAndRefund(USER_ID, JOB_ID, CRYSTAL_LOG_ID, "PDF_EXPORT_GENERATION_FAILED"); verify(txService, never()).confirm(anyLong(), anyLong()); verify(storage, never()).upload(any(), any(), any(), any()); + + // 생성 화면을 벗어난 사용자는 이 알림이 없으면 실패를 알 방법이 없다(아카이브·/current 모두 FAILED 미노출). + ArgumentCaptor failed = ArgumentCaptor.forClass(PdfExportFailedEvent.class); + verify(eventPublisher).publishEvent(failed.capture()); + assertThat(failed.getValue().getJobId()).isEqualTo(JOB_ID); + assertThat(failed.getValue().getUserId()).isEqualTo(USER_ID); + } + + @Test + void 복구_스윕이_먼저_환불했으면_실패_알림을_보내지_않는다() { + givenJob(PdfExportType.ANSWER_ONLY); + when(queryRepository.findAnswersInPeriod(USER_ID, START, END)) + .thenReturn(List.of(answer("2026-01-05", "질문", null))); + when(renderer.render(any(), any())).thenThrow(new RuntimeException("openhtmltopdf boom")); + // false = markFailed 경합에서 짐. 스윕이 이미 실패 확정·환불했고 알림도 그쪽이 보냈다. + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), any())).thenReturn(false); + + listener.handle(EVENT); + + // 여기서 또 발행하면 같은 작업에 실패 푸시가 두 번 간다. verifyNoInteractions(eventPublisher); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java index 966e04f0..18e76435 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java @@ -1,14 +1,18 @@ package com.devkor.ifive.nadab.domain.pdfexport.application.scheduler; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.core.entity.PdfExportJob; import com.devkor.ifive.nadab.domain.pdfexport.core.repository.PdfExportJobRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -28,13 +32,15 @@ class PdfExportRecoverySchedulerTest { private PdfExportJobRepository jobRepository; private PdfExportTxService txService; + private ApplicationEventPublisher eventPublisher; private PdfExportRecoveryScheduler scheduler; @BeforeEach void setUp() { jobRepository = mock(PdfExportJobRepository.class); txService = mock(PdfExportTxService.class); - scheduler = new PdfExportRecoveryScheduler(jobRepository, txService); + eventPublisher = mock(ApplicationEventPublisher.class); + scheduler = new PdfExportRecoveryScheduler(jobRepository, txService, eventPublisher); } @Test @@ -43,6 +49,7 @@ void setUp() { List stuck = List.of(stuckJob(11L, 7L, 101L), stuckJob(12L, 8L, 102L), stuckJob(13L, 9L, 103L)); when(jobRepository.findStuckInProgress(any(), anyInt())).thenReturn(stuck); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true); doThrow(new IllegalStateException("wallet down")) .when(txService).failAndRefund(eq(8L), anyLong(), anyLong(), anyString()); @@ -53,6 +60,23 @@ void setUp() { verify(txService).failAndRefund(9L, 13L, 103L, "PDF_EXPORT_GENERATION_TIMEOUT"); } + @Test + void 환불한_건만_실패_알림을_발행한다() { + List stuck = List.of(stuckJob(11L, 7L, 101L), stuckJob(12L, 8L, 102L)); + when(jobRepository.findStuckInProgress(any(), anyInt())).thenReturn(stuck); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true); + // 8번 유저 건은 그 사이 렌더 리스너가 먼저 정리해 CAS 경합에서 졌다. + when(txService.failAndRefund(eq(8L), anyLong(), anyLong(), anyString())).thenReturn(false); + + scheduler.recoverStuckJobs(); + + // 환불에 성공한 11번만 발행되고, CAS 경합에서 진 8번은 발행되지 않는다. + ArgumentCaptor failed = ArgumentCaptor.forClass(PdfExportFailedEvent.class); + verify(eventPublisher).publishEvent(failed.capture()); + assertThat(failed.getValue().getJobId()).isEqualTo(11L); + assertThat(failed.getValue().getUserId()).isEqualTo(7L); + } + /** 조회 결과 대역 — 스윕이 읽는 건 id·userId·crystalLogId 뿐이다. */ private PdfExportJob stuckJob(long jobId, long userId, long crystalLogId) { User user = mock(User.class);