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