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 @@ -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;
Expand Down Expand Up @@ -45,13 +42,16 @@ public class DailyReportController {
이 때 유저의 답변은 기존의 답변으로 자동으로 사용됩니다. <br/>
소요 시간이 최대 3~4초밖에 안 되어 동기처리로 구현했습니다. <br/>

이미지 미포함의 경우 objectKey는 null로 보내주시면 됩니다. <br/>
이미지 미포함의 경우 objectKey와 webpKey는 null로 보내주시면 됩니다. <br/>

<이미지가 포함된 경우> <br/>
**5MB 이하의 이미지 파일만 허용됩니다.** <br/>
POST /daily-report/image/upload-url 엔드포인트로
미리 발급받은 PresignedURL을 통해 이미지를 업로드한 후,
해당 엔드포인트에서 반환된 objectKey를 이 요청에 포함시켜야 합니다. <br/>
해당 엔드포인트에서 반환된 objectKey와 webpKey를 이 요청에 포함시켜야 합니다. <br/>
또한 GET /daily-report/image/status 엔드포인트를 통해 이미지 업로드 후 webp 변환이 완료되었는지 확인한 후, <br/>
webp 변환이 완료된 경우에만 요청에 포함합니다.<br/>
<br/>


| 응답의 emotion | 해당 감정 |
Expand All @@ -76,6 +76,7 @@ public class DailyReportController {
responseCode = "400",
description = """
- ErrorCode: DAILY_QUESTION_MISMATCH - 요청한 질문이 사용자에게 할당된 오늘의 질문과 일치하지 않음
- ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
""",
content = @Content
),
Expand Down Expand Up @@ -238,4 +239,50 @@ public ResponseEntity<ApiResponseDto<CreateAnswerImageUploadUrlResponse>> create
dailyReportService.createUploadUrl(principal.getId(), request);
return ApiResponseEntity.ok(response);
}

@GetMapping("/image/status")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "답변 이미지(webp) 상태 조회",
description = """
답변에 포함되는 이미지의 webp 변환 상태를 조회합니다. <br/>
POST /daily-report/image/upload-url 엔드포인트로 이미지를 업로드한 후, 해당 엔드포인트에서 반환된 webpKey를 이 API의 key 파라미터로 전달하여 이미지 상태를 조회할 수 있습니다. <br/>

프론트엔드에서는 이 API를 주기적으로 호출하여 이미지 업로드 후 webp 변환이 완료되었는지 확인해야 합니다. <br/>
최대 7초 동안 이 API를 호출하여 status가 READY로 변경되었는지 확인하고, <br/>
7초가 지나면 실패로 간주하고 사용자에게 이미지 업로드 실패 메시지를 보여주면 됩니다. <br/>

- 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<ApiResponseDto<ImageStatusResponse>> getImageStatus(
@AuthenticationPrincipal UserPrincipal principal,
@RequestParam String key
) {
ImageStatusResponse response = dailyReportService.getImageStatus(key, principal.getId());

return ApiResponseEntity.ok(response);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. 오늘 -> 어제 순서로 조회 (없으면 예외)
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -99,4 +104,8 @@ protected void failDaily(Long reportId) {
dailyReportRepository.markFailed(reportId);
// 무료이므로 환불/로그 없음
}

private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.entity;

public enum ImageStatus {
READY,
PROCESSING
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading