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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +34,7 @@
* - 리포트 제작 가능 → 사용자에게 제작 가능 알림
* - 일일 리포트 완성 → 유형 리포트 제작 가능 여부 체크 및 알림
* - PDF 내보내기 완성 → 사용자에게 완성 알림
* - PDF 내보내기 실패 → 사용자에게 실패·환불 알림
* - @Async로 비동기 처리 (리포트 생성 스레드와 분리)
*/
@Component
Expand Down Expand Up @@ -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);
}
}

// ========== 리포트 제작 가능 알림 ==========

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class PdfExportController {
생성 로딩 화면의 "포함 내용" 개수(답변/주간/월간)는 이 API가 반환하지 않습니다 — 방금 생성 직전 호출한 미리보기(GET /pdf-exports/preview) 응답값을 그대로 표시하면 됩니다(생성 직후엔 그 값이 곧 생성 대상 개수). 화면을 벗어났다 다시 들어오는 재진입 흐름에서는 GET /pdf-exports/current가 같은 개수를 함께 내려줍니다. </br>
동시 생성 1개: 한 사용자는 생성 중(PENDING/IN_PROGRESS) 작업을 동시에 1개만 가질 수 있습니다. 생성 중인데 다른 유형·기간으로 다시 호출하면 409(PDF_EXPORT_ALREADY_IN_PROGRESS)로 거부되며, 응답 data에 이미 생성 중인 작업의 jobId가 담겨 옵니다 — 그 작업의 생성 화면으로 유도하고 상세·포함 개수는 GET /pdf-exports/current로 받으세요. </br>
멱등 재사용: 같은 유형·기간의 작업이 아직 생성 중일 때 다시 호출하면(응답을 못 받아 재시도하거나 버튼 더블탭 등) 재과금 없이 그 작업을 그대로 돌려주며 balanceAfter=null 입니다(이중 과금 없음, 지갑 추가 차감 표시 금지). 완료(COMPLETED)된 작업은 재사용하지 않으므로 같은 기간 재요청은 새 작업으로 재과금됩니다(= 재생성). </br>
생성 실패는 아카이브에도 안 뜨고 완료 푸시도 없어, 오직 폴링(GET /pdf-exports/{jobId})의 status=FAILED(errorCode 포함)로만 드러납니다. 그래서 로딩 화면에서 폴링 중이라면 FAILED를 COMPLETED와 같은 '종료 상태'로 처리해야 합니다(안 그러면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤라면 따로 확인할 필요가 없습니다 — 차감 크리스탈은 자동 환불되고(지갑 이력에 남음) 산출물이 없어 사용자가 할 액션이 없습니다. 잔액을 표시 중이면 다시 조회해 갱신하세요. </br>
생성 실패는 아카이브에 뜨지 않습니다. 로딩 화면에서 폴링 중이라면 FAILED가 나오면 COMPLETED와 마찬가지로 폴링을 멈춥니다(FAILED를 종료로 처리하지 않으면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤에 실패한 경우에는 실패 푸시(data type=PDF_EXPORT_FAILED, targetId=jobId)가 갑니다. 그걸 누르면 그 jobId로 GET /pdf-exports/{jobId}를 호출해 기간·유형을 받고, 그 기간으로 GET /pdf-exports/preview를 한 번 더 호출해 포함 내용 개수까지 채워 실패 화면을 구성합니다(상세는 GET /pdf-exports/{jobId} 설명 참조). 어느 쪽이든 차감 크리스탈은 자동 환불되며(지갑 이력에 남음), 잔액을 표시 중이면 다시 조회해 갱신하세요. </br>
내보낼 답변/리포트가 하나도 없으면 PDF_EXPORT_NO_DATA로 거부됩니다 — 미리보기(GET /pdf-exports/preview)로 사전 확인을 권장합니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
Expand Down Expand Up @@ -162,6 +162,7 @@ public ResponseEntity<ApiResponseDto<PdfExportCurrentResponse>> getCurrentPdfExp
summary = "PDF 내보내기 미리보기(포함 개수)",
description = """
해당 기간에 포함될 답변·주간 리포트·월간 리포트 개수를 돌려줍니다. 생성/차감 전 확인 팝업에서 "무엇이 몇 개 포함되는지" 보여주는 용도의 순수 조회입니다. </br>
실패 화면에서도 재사용합니다: 실패 알림을 눌러 진입하면 로컬 카운트가 없으므로, GET /pdf-exports/{jobId} 로 받은 기간(startDate·endDate)을 그대로 넣어 이 API를 호출해 "포함 내용" 개수를 채웁니다. </br>
유형과 무관하게 3종 개수를 모두 내려주므로, 선택한 유형에 맞는 값만 표시하면 됩니다(예: 답변만 선택 시 answerCount). </br>
세 개수가 모두 0이면 생성해도 빈 PDF라 생성 API가 PDF_EXPORT_NO_DATA로 거부합니다. 이 경우 생성 버튼을 비활성화하거나 기획에 맞게 팝업을 띄우면 됩니다. </br>
기간 규칙은 생성 API와 동일합니다(시작일 ≤ 종료일, 종료일 ≤ 오늘, 최대 1년) — 잘못된 기간이면 PDF_EXPORT_INVALID_PERIOD.
Expand Down Expand Up @@ -204,6 +205,9 @@ public ResponseEntity<ApiResponseDto<PdfExportPreviewResponse>> getPdfExportPrev
FAILED일 때 errorCode 값(둘 다 자동 환불됨): </br>
- PDF_EXPORT_GENERATION_FAILED: 생성 도중 오류로 실패 </br>
- PDF_EXPORT_GENERATION_TIMEOUT: 생성이 60분 안에 끝나지 않아 자동 취소(배포·서버 재시작 등으로 작업이 유실된 경우) </br>
응답에는 유형(type)·기간(startDate~endDate)도 함께 담깁니다. 로딩/실패 화면의 기간 표시, 차감 크리스탈(유형에서 유도: 리포트만/답변만 50, 둘 다 100), "다시 PDF 생성하기" 버튼의 조건 프리필에 쓰세요. 화면에 머물러 폴링 중이라면 이미 알고 있는 값이지만, 실패 알림을 눌러 들어온 경우(앱이 종료돼 있었다면 로컬 상태가 없음)에는 이 API가 유일한 출처입니다. </br>
실패 알림(FCM): 생성이 실패해 환불되면 푸시가 발송됩니다(data type=PDF_EXPORT_FAILED, targetId=jobId). 이걸 눌러 들어오면 그 jobId로 이 API를 호출해 실패 화면을 구성합니다. FAILED는 아카이브(COMPLETED만)와 GET /pdf-exports/current(진행 중만) 어디에도 나오지 않으므로, jobId 없이 실패 작업을 찾을 방법은 없습니다. </br>
"포함 내용" 개수(답변/주간/월간)는 이 응답에 없습니다 — 정적값이라 폴링마다 싣지 않습니다. 얻는 방법: 생성 직후에는 미리보기(GET /pdf-exports/preview)에서 받은 값을, 진행 중 재진입은 GET /pdf-exports/current가 함께 내려주는 값을 그대로 쓰세요. 실패 알림을 눌러 실패 화면으로 콜드 스타트 진입한 경우(앱이 종료돼 있어 로컬 값이 없음)에는, 이 응답의 기간(startDate·endDate)으로 GET /pdf-exports/preview?startDate=..&endDate=.. 를 한 번 더 호출해 개수를 받으세요. </br>
다운로드 URL은 이 응답에 포함되지 않습니다. COMPLETED가 된 뒤 POST /pdf-exports/{jobId}/download-url 로 발급받으세요(발급 빈도 제한이 폴링에 영향을 주지 않도록 분리되어 있습니다).
""",
security = @SecurityRequirement(name = "bearerAuth"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDate;
import java.time.OffsetDateTime;

public record PdfExportStatusResponse(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ public class PdfExportQueryService {
private static final List<PdfExportStatus> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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, 음수)을 그대로 되돌린다 — 로그에서 읽어 가격이 바뀌어도 차감액=환불액 보장.
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Loading
Loading