diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/DailyReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/DailyReportController.java
index aec8ccc4..999e1e67 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/DailyReportController.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/DailyReportController.java
@@ -2,10 +2,7 @@
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.CreateAnswerImageUploadUrlRequest;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest;
-import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.AnswerDetailResponse;
-import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateAnswerImageUploadUrlResponse;
-import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
-import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.DailyReportResponse;
+import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.*;
import com.devkor.ifive.nadab.domain.dailyreport.application.DailyReportQueryService;
import com.devkor.ifive.nadab.domain.dailyreport.application.DailyReportService;
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
@@ -45,13 +42,16 @@ public class DailyReportController {
이 때 유저의 답변은 기존의 답변으로 자동으로 사용됩니다.
소요 시간이 최대 3~4초밖에 안 되어 동기처리로 구현했습니다.
- 이미지 미포함의 경우 objectKey는 null로 보내주시면 됩니다.
+ 이미지 미포함의 경우 objectKey와 webpKey는 null로 보내주시면 됩니다.
<이미지가 포함된 경우>
**5MB 이하의 이미지 파일만 허용됩니다.**
POST /daily-report/image/upload-url 엔드포인트로
미리 발급받은 PresignedURL을 통해 이미지를 업로드한 후,
- 해당 엔드포인트에서 반환된 objectKey를 이 요청에 포함시켜야 합니다.
+ 해당 엔드포인트에서 반환된 objectKey와 webpKey를 이 요청에 포함시켜야 합니다.
+ 또한 GET /daily-report/image/status 엔드포인트를 통해 이미지 업로드 후 webp 변환이 완료되었는지 확인한 후,
+ webp 변환이 완료된 경우에만 요청에 포함합니다.
+
| 응답의 emotion | 해당 감정 |
@@ -76,6 +76,7 @@ public class DailyReportController {
responseCode = "400",
description = """
- ErrorCode: DAILY_QUESTION_MISMATCH - 요청한 질문이 사용자에게 할당된 오늘의 질문과 일치하지 않음
+ - ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
""",
content = @Content
),
@@ -238,4 +239,50 @@ public ResponseEntity> create
dailyReportService.createUploadUrl(principal.getId(), request);
return ApiResponseEntity.ok(response);
}
+
+ @GetMapping("/image/status")
+ @PreAuthorize("isAuthenticated()")
+ @Operation(
+ summary = "답변 이미지(webp) 상태 조회",
+ description = """
+ 답변에 포함되는 이미지의 webp 변환 상태를 조회합니다.
+ POST /daily-report/image/upload-url 엔드포인트로 이미지를 업로드한 후, 해당 엔드포인트에서 반환된 webpKey를 이 API의 key 파라미터로 전달하여 이미지 상태를 조회할 수 있습니다.
+
+ 프론트엔드에서는 이 API를 주기적으로 호출하여 이미지 업로드 후 webp 변환이 완료되었는지 확인해야 합니다.
+ 최대 7초 동안 이 API를 호출하여 status가 READY로 변경되었는지 확인하고,
+ 7초가 지나면 실패로 간주하고 사용자에게 이미지 업로드 실패 메시지를 보여주면 됩니다.
+
+ - status가 READY인 경우: 이미지 업로드 및 webp 변환이 모두 완료되어 이미지 URL을 사용할 수 있음
+ - status가 PROCESSING인 경우: 이미지 업로드는 완료되었으나 webp 변환이 아직 완료되지 않음. 잠시 후 다시 확인 필요
+ """,
+ security = @SecurityRequirement(name = "bearerAuth"),
+ responses = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "성공",
+ content = @Content(schema = @Schema(implementation = ImageStatusResponse.class), mediaType = "application/json")
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = """
+ - ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
+ """,
+ content = @Content
+ ),
+ @ApiResponse(
+ responseCode = "401",
+ description = "인증 실패 (JWT 토큰 관련)",
+ content = @Content
+ )
+ }
+ )
+ public ResponseEntity> getImageStatus(
+ @AuthenticationPrincipal UserPrincipal principal,
+ @RequestParam String key
+ ) {
+ ImageStatusResponse response = dailyReportService.getImageStatus(key, principal.getId());
+
+ return ApiResponseEntity.ok(response);
+ }
+
}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/DailyReportRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/DailyReportRequest.java
index 6c9c159b..e2f40555 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/DailyReportRequest.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/DailyReportRequest.java
@@ -19,6 +19,12 @@ public record DailyReportRequest(
description = "이 값은 presignedURL 생성 API의 응답에서 받은 objectKey여야 합니다. ",
example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png"
)
- String objectKey
+ String objectKey,
+
+ @Schema(
+ description = "이 값은 presignedURL 생성 API의 응답에서 받은 webpKey여야 합니다. ",
+ example = "dev/answers/webp/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.webp"
+ )
+ String webpKey
) {
}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CreateAnswerImageUploadUrlResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CreateAnswerImageUploadUrlResponse.java
index bc0063a2..969c612c 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CreateAnswerImageUploadUrlResponse.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CreateAnswerImageUploadUrlResponse.java
@@ -8,6 +8,9 @@ public record CreateAnswerImageUploadUrlResponse(
String uploadUrl,
@Schema(description = "답변 이미지 Object Key. 일간 리포트 생성에 사용됩니다.", example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png")
- String objectKey
+ String objectKey,
+
+ @Schema(description = "답변 이미지 Webp Key. 일간 리포트 생성에 사용됩니다.", example = "dev/answers/webp/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.webp")
+ String webpKey
) {
}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/ImageStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/ImageStatusResponse.java
new file mode 100644
index 00000000..f48c9848
--- /dev/null
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/ImageStatusResponse.java
@@ -0,0 +1,9 @@
+package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record ImageStatusResponse(
+ @Schema(description = "이미지 상태", example = "READY, PROCESSING")
+ String status
+) {
+}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java
index 1f9a584c..0496c033 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java
@@ -4,11 +4,13 @@
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateAnswerImageUploadUrlResponse;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
+import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.ImageStatusResponse;
import com.devkor.ifive.nadab.domain.dailyreport.application.event.DailyReportCompletedEvent;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.ImageStatus;
import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient;
import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion;
@@ -59,6 +61,11 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq
DailyQuestion question = dailyQuestionRepository.findByIdWithInterest(request.questionId())
.orElseThrow(() -> new NotFoundException(ErrorCode.QUESTION_NOT_FOUND));
+ // webpKey 존재 시 검증
+ if(!isBlank(request.webpKey())) {
+ validateWebpKey(request.webpKey(), userId);
+ }
+
LocalDate today = TodayDateTimeProvider.getTodayDate();
// 1. 오늘 -> 어제 순서로 조회 (없으면 예외)
@@ -85,7 +92,7 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq
throw e;
}
- ConfirmDailyAndRewardDto confirmDto = dailyReportTxService.confirmDailyAndReward(prep, dto);
+ ConfirmDailyAndRewardDto confirmDto = dailyReportTxService.confirmDailyAndReward(prep, dto, request.webpKey());
// 일일 리포트 완성 이벤트 발행 (유형 리포트 제작 가능 알림 체크용)
if (question.getInterest() != null) {
@@ -125,6 +132,43 @@ public CreateAnswerImageUploadUrlResponse createUploadUrl(
String uploadUrl = profileImageService.generatePresignedUploadUrl(objectKey, contentType);
- return new CreateAnswerImageUploadUrlResponse(uploadUrl, objectKey);
+ String webpKey = "%s/answers/webp/%d/%s.webp"
+ .formatted(env, userId, uuid);
+
+ return new CreateAnswerImageUploadUrlResponse(uploadUrl, objectKey, webpKey);
+ }
+
+ public ImageStatusResponse getImageStatus(String key, Long userId) {
+ validateWebpKey(key, userId);
+
+ boolean exists = profileImageService.exists(key);
+
+ ImageStatus imageStatus = exists ? ImageStatus.READY : ImageStatus.PROCESSING;
+
+ return new ImageStatusResponse(imageStatus.name());
+ }
+
+ private void validateWebpKey(String key, Long userId) {
+ if (key == null || key.isBlank()) {
+ throw new BadRequestException(ErrorCode.IMAGE_INVALID_KEY);
+ }
+
+ String expectedPrefix = "%s/answers/webp/%d/".formatted(env, userId);
+
+ if (!key.startsWith(expectedPrefix)) {
+ throw new BadRequestException(ErrorCode.IMAGE_INVALID_KEY);
+ }
+
+ if (!key.endsWith(".webp")) {
+ throw new BadRequestException(ErrorCode.IMAGE_INVALID_KEY);
+ }
+
+ if (key.contains("..")) {
+ throw new BadRequestException(ErrorCode.IMAGE_INVALID_KEY);
+ }
+ }
+
+ private boolean isBlank(String s) {
+ return s == null || s.trim().isEmpty();
}
}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java
index a58befb9..8a70958f 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java
@@ -56,7 +56,12 @@ protected PrepareDailyResultDto prepareDaily(User user, DailyQuestion dq, String
return new PrepareDailyResultDto(entry, report.getId(), user.getId());
}
- protected ConfirmDailyAndRewardDto confirmDailyAndReward(PrepareDailyResultDto prep, AiDailyReportResultDto aiResult) {
+ protected ConfirmDailyAndRewardDto confirmDailyAndReward(
+ PrepareDailyResultDto prep, AiDailyReportResultDto aiResult, @Nullable String webpKey) {
+
+ if(!isBlank(webpKey)) {
+ prep.entry().updateImageKey(webpKey);
+ }
Emotion emotion = emotionRepository.findByName(EmotionName.valueOf(aiResult.emotion()))
.orElseThrow(() -> new NotFoundException(ErrorCode.EMOTION_NOT_FOUND));
@@ -99,4 +104,8 @@ protected void failDaily(Long reportId) {
dailyReportRepository.markFailed(reportId);
// 무료이므로 환불/로그 없음
}
+
+ private boolean isBlank(String s) {
+ return s == null || s.trim().isEmpty();
+ }
}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java
index 336dc7c2..24942a89 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java
@@ -59,4 +59,9 @@ public void updateContent(String content, @Nullable String imageKey) {
this.imageKey = imageKey;
onUpdate();
}
+
+ public void updateImageKey(String imageKey) {
+ this.imageKey = imageKey;
+ onUpdate();
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/ImageStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/ImageStatus.java
new file mode 100644
index 00000000..03ff9836
--- /dev/null
+++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/ImageStatus.java
@@ -0,0 +1,6 @@
+package com.devkor.ifive.nadab.domain.dailyreport.core.entity;
+
+public enum ImageStatus {
+ READY,
+ PROCESSING
+}
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/core/service/ProfileImageService.java b/src/main/java/com/devkor/ifive/nadab/domain/user/core/service/ProfileImageService.java
index 908f6571..86324bb7 100644
--- a/src/main/java/com/devkor/ifive/nadab/domain/user/core/service/ProfileImageService.java
+++ b/src/main/java/com/devkor/ifive/nadab/domain/user/core/service/ProfileImageService.java
@@ -88,5 +88,26 @@ public void checkImageValidity(String objectKey) {
throw new BadRequestException(ErrorCode.IMAGE_SIZE_EXCEEDED);
}
}
+
+ /**
+ * S3에 해당 키의 객체가 존재하는지 확인하는 메소드
+ */
+ public boolean exists(String key) {
+ try {
+ s3Client.headObject(HeadObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build());
+
+ return true;
+ } catch (NoSuchKeyException e) {
+ return false;
+ } catch (S3Exception e) {
+ if (e.statusCode() == 404) {
+ return false;
+ }
+ throw e;
+ }
+ }
}
diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java
index a2c6053a..15f682c3 100644
--- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java
+++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java
@@ -92,6 +92,7 @@ public enum ErrorCode {
IMAGE_UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 타입입니다"),
IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 크기가 제한을 초과했습니다 (최대 5MB)"),
IMAGE_METADATA_INVALID(HttpStatus.BAD_REQUEST, "파일 메타데이터를 읽을 수 없습니다. 다시 시도해주세요."),
+ IMAGE_INVALID_KEY(HttpStatus.BAD_REQUEST, "잘못된 이미지 key입니다."),
// ==================== QUESTION (질문) ====================
// 400 Bad Request