From 6ed7068ed2c387e709b4fac126374a24af8972d7 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Tue, 5 May 2026 22:34:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(report):=20=EC=9D=BC=EA=B0=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=EC=9D=98=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20webp=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/request/DailyReportRequest.java | 8 +++++++- .../response/CreateAnswerImageUploadUrlResponse.java | 5 ++++- .../dailyreport/application/DailyReportService.java | 7 +++++-- .../dailyreport/application/DailyReportTxService.java | 11 ++++++++++- .../domain/dailyreport/core/entity/AnswerEntry.java | 5 +++++ 5 files changed, 31 insertions(+), 5 deletions(-) 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 6c9c159..e2f4055 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 bc0063a..969c612 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/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java index 1f9a584..68e1225 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 @@ -85,7 +85,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 +125,9 @@ 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); } } 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 a58befb..8a70958 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 336dc7c..24942a8 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 From da6c5772c8f7c4210348211db90295c4411c3dfd Mon Sep 17 00:00:00 2001 From: 1Seob Date: Wed, 6 May 2026 14:13:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(report):=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80(webp)=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DailyReportController.java | 59 +++++++++++++++++-- .../api/dto/response/ImageStatusResponse.java | 9 +++ .../application/DailyReportService.java | 41 +++++++++++++ .../dailyreport/core/entity/ImageStatus.java | 6 ++ .../core/service/ProfileImageService.java | 21 +++++++ .../nadab/global/core/response/ErrorCode.java | 1 + 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/ImageStatusResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/ImageStatus.java 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 aec8ccc..999e1e6 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/response/ImageStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/ImageStatusResponse.java new file mode 100644 index 0000000..f48c984 --- /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 68e1225..0496c03 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. 오늘 -> 어제 순서로 조회 (없으면 예외) @@ -130,4 +137,38 @@ public CreateAnswerImageUploadUrlResponse createUploadUrl( 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/core/entity/ImageStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/ImageStatus.java new file mode 100644 index 0000000..03ff983 --- /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 908f657..86324bb 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 a2c6053..15f682c 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