Skip to content

Commit dbf5d8b

Browse files
1Seobcoldsunn
andauthored
#161까지 반영해 배포 (#162)
* feat: 답변에 이미지 업로드 추가 및 탈퇴 사용자 이미지 정리 추가 (#154) * feat: answer_entries 테이블에 image_key 컬럼 추가 AnswerEntry 엔티티에 imageKey 필드 추가 * feat: 기존 AnswerEntry 생성 및 조회 비즈니스 로직에 imageKey 추가 * feat: 답변 이미지 업로드 PresignedURL 생성 api 구현 * feat(report): DailyReportLlmClient 수정 이미지 포함 여부에 따라 프롬프트 및 UserMessage 분기 처리 * feat(report): 일간 리포트 조회 로직 및 응답에 이미지 포함 * feat(user): User 영구 삭제 시 프로필 이미지와 답변 이미지들을 S3에서 삭제하는 로직 추가 UserCleanupScheduler에서 삭제 대상 회원들의 프로필 이미지 키와 답변 이미지 키를 모두 수집하여 S3에서 삭제 * docs(report): 오늘의 리포트 생성 API의 스웨거 문서 수정 이미지 포함 시에 대한 설명 추가 * fix: 코드 리뷰 반영 UserCleanupScheduler에서 삭제 대상 ID가 없으면 조기 종료하도록 처리, DailyReportService의 contentType 검증 중복 코드를 제거하고 switch default 예외 처리로 통합, SecretDailyReportPromptLoader에서 with-image 프롬프트 검증 대상을 rawWithImagePrompt로 수정 등 * chore(notify): backfill 완료 후 NotificationSettingBackfillRunner 제거 (#157) * fix: 일일 질문 문구 오타 및 어색한 표현 수정 (#158) * feat: 질문 API/도메인 V2 구현 (#159) * feat: user_daily_questions 테이블에 reroll_left 컬럼 및 엔티티 필드 추가 기존 데이터 reroll_left = 0 백필, 신규 데이터 기본값 5, NOT NULL 적용, UserDailyQuestion 엔티티에 rerollLeft 필드 및 초기값 반영 * feat: 오늘의 질문 조회 V2 API 구현 * feat: 오늘의 질문 조회 V2 API 구현 * feat(infra): 개발 환경 Swagger UI 태그 정렬 방식 설정 * fix: reroll_left의 백필 값을 5로 변경 * feat: 답변 이미지 WebP 변환 및 리사이징 파이프라인 추가 (#160) * feat(report): 일간 리포트의 이미지 업로드 로직에 webp 추가 * feat(report): 답변 이미지(webp) 상태 조회 API 구현 * feat(db): 일일 질문 리스트 변경사항 반영 마이그레이션 추가 (#161) --------- Co-authored-by: Chanhae Lee <146611988+coldsunn@users.noreply.github.com>
2 parents df386bf + bf40083 commit dbf5d8b

46 files changed

Lines changed: 7926 additions & 91 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-to-dev-ec2.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ jobs:
7171
INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')"
7272
export INSIGHT_PROMPT
7373
74+
INSIGHT_WITH_IMAGE_PROMPT_B64="${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }}"
75+
INSIGHT_WITH_IMAGE_PROMPT="$(printf '%s' "$INSIGHT_WITH_IMAGE_PROMPT_B64" | base64 -d | tr -d '\r')"
76+
export INSIGHT_WITH_IMAGE_PROMPT
77+
7478
WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}"
7579
WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')"
7680
export WEEKLY_PROMPT

.github/workflows/deploy-to-prod-ec2.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ jobs:
7070
INSIGHT_PROMPT_B64="${{ secrets.INSIGHT_PROMPT_B64 }}"
7171
INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')"
7272
export INSIGHT_PROMPT
73+
74+
INSIGHT_WITH_IMAGE_PROMPT_B64="${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }}"
75+
INSIGHT_WITH_IMAGE_PROMPT="$(printf '%s' "$INSIGHT_WITH_IMAGE_PROMPT_B64" | base64 -d | tr -d '\r')"
76+
export INSIGHT_WITH_IMAGE_PROMPT
7377
7478
WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}"
7579
WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')"

src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/UserCleanupScheduler.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package com.devkor.ifive.nadab.domain.auth.infra.scheduler;
22

3+
import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository;
34
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
5+
import com.devkor.ifive.nadab.domain.user.core.service.ProfileImageService;
46
import lombok.RequiredArgsConstructor;
57
import lombok.extern.slf4j.Slf4j;
68
import org.springframework.scheduling.annotation.Scheduled;
79
import org.springframework.stereotype.Component;
810
import org.springframework.transaction.annotation.Transactional;
911

1012
import java.time.OffsetDateTime;
13+
import java.util.HashSet;
14+
import java.util.List;
15+
import java.util.Set;
1116

1217
/**
1318
* 탈퇴 유저 영구 삭제 스케줄러
@@ -20,17 +25,32 @@
2025
public class UserCleanupScheduler {
2126

2227
private final UserRepository userRepository;
28+
private final AnswerEntryRepository answerEntryRepository;
29+
private final ProfileImageService profileImageService;
2330

2431
@Scheduled(cron = "0 0 0 * * *") // 매일 자정
2532
@Transactional
2633
public void cleanupDeletedUsers() {
2734
OffsetDateTime expirationDate = OffsetDateTime.now().minusDays(14);
28-
int deletedCount = userRepository.deleteOldWithdrawnUsers(expirationDate);
35+
36+
// 영구 삭제될 User의 id들
37+
List<Long> targetUserIds = userRepository.findOldWithdrawnUserIds(expirationDate);
38+
39+
if(targetUserIds.isEmpty()) {
40+
log.debug("정리할 탈퇴 회원이 없습니다.");
41+
return;
42+
}
43+
44+
// 삭제 대상 회원들의 프로필 이미지 키와 답변 이미지 키를 모두 수집하여 S3에서 삭제
45+
Set<String> imageKeysToDelete = new HashSet<>();
46+
imageKeysToDelete.addAll(userRepository.findProfileImageKeysByIdIn(targetUserIds));
47+
imageKeysToDelete.addAll(answerEntryRepository.findImageKeysByUserIds(targetUserIds));
48+
imageKeysToDelete.forEach(profileImageService::deleteProfileImage);
49+
50+
int deletedCount = userRepository.deleteWithdrawnUsersByIdIn(targetUserIds);
2951

3052
if (deletedCount > 0) {
3153
log.info("회원 정리 완료: {}명의 탈퇴 회원 영구 삭제 (기준일: {})", deletedCount, expirationDate.toLocalDate());
32-
} else {
33-
log.debug("정리할 탈퇴 회원이 없습니다.");
3454
}
3555
}
3656
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/DailyReportController.java

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.devkor.ifive.nadab.domain.dailyreport.api;
22

3+
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.CreateAnswerImageUploadUrlRequest;
34
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest;
4-
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.AnswerDetailResponse;
5-
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
6-
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.DailyReportResponse;
5+
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.*;
76
import com.devkor.ifive.nadab.domain.dailyreport.application.DailyReportQueryService;
87
import com.devkor.ifive.nadab.domain.dailyreport.application.DailyReportService;
98
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
@@ -43,6 +42,18 @@ public class DailyReportController {
4342
이 때 유저의 답변은 기존의 답변으로 자동으로 사용됩니다. <br/>
4443
소요 시간이 최대 3~4초밖에 안 되어 동기처리로 구현했습니다. <br/>
4544
45+
이미지 미포함의 경우 objectKey와 webpKey는 null로 보내주시면 됩니다. <br/>
46+
47+
<이미지가 포함된 경우> <br/>
48+
**5MB 이하의 이미지 파일만 허용됩니다.** <br/>
49+
POST /daily-report/image/upload-url 엔드포인트로
50+
미리 발급받은 PresignedURL을 통해 이미지를 업로드한 후,
51+
해당 엔드포인트에서 반환된 objectKey와 webpKey를 이 요청에 포함시켜야 합니다. <br/>
52+
또한 GET /daily-report/image/status 엔드포인트를 통해 이미지 업로드 후 webp 변환이 완료되었는지 확인한 후, <br/>
53+
webp 변환이 완료된 경우에만 요청에 포함합니다.<br/>
54+
<br/>
55+
56+
4657
| 응답의 emotion | 해당 감정 |
4758
| :--- | :--- |
4859
| `ACHIEVEMENT` | 성취 |
@@ -65,6 +76,7 @@ public class DailyReportController {
6576
responseCode = "400",
6677
description = """
6778
- ErrorCode: DAILY_QUESTION_MISMATCH - 요청한 질문이 사용자에게 할당된 오늘의 질문과 일치하지 않음
79+
- ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
6880
""",
6981
content = @Content
7082
),
@@ -185,4 +197,92 @@ public ResponseEntity<ApiResponseDto<AnswerDetailResponse>> getDailyReportById(
185197
AnswerDetailResponse response = dailyReportQueryService.getDailyReportById(principal.getId(), reportId);
186198
return ApiResponseEntity.ok(response);
187199
}
200+
201+
@PostMapping("/image/upload-url")
202+
@PreAuthorize("isAuthenticated()")
203+
@Operation(
204+
summary = "답변 이미지 업로드 PresignedURL 생성",
205+
description = """
206+
답변에 포함되는 이미지를 업로드할 수 있는 PresignedURL을 생성합니다.
207+
208+
- HTTP Method: PUT
209+
- Headers:
210+
- Content-Type(필수): image/jpeg, image/png만 허용
211+
- Body: 이미지 파일
212+
- URL 만료 시간: 5분
213+
""",
214+
security = @SecurityRequirement(name = "bearerAuth"),
215+
responses = {
216+
@ApiResponse(
217+
responseCode = "200",
218+
description = "성공",
219+
content = @Content(schema = @Schema(implementation = CreateAnswerImageUploadUrlResponse.class), mediaType = "application/json")
220+
),
221+
@ApiResponse(
222+
responseCode = "400",
223+
description = """
224+
- ErrorCode: IMAGE_UNSUPPORTED_TYPE - 지원하지 않는 이미지 타입
225+
""",
226+
content = @Content
227+
),
228+
@ApiResponse(
229+
responseCode = "401",
230+
description = "인증 실패 (JWT 토큰 관련)",
231+
content = @Content
232+
)
233+
}
234+
)
235+
public ResponseEntity<ApiResponseDto<CreateAnswerImageUploadUrlResponse>> createAnswerImageUploadUrl(
236+
@AuthenticationPrincipal UserPrincipal principal,
237+
@Valid @RequestBody CreateAnswerImageUploadUrlRequest request) {
238+
CreateAnswerImageUploadUrlResponse response =
239+
dailyReportService.createUploadUrl(principal.getId(), request);
240+
return ApiResponseEntity.ok(response);
241+
}
242+
243+
@GetMapping("/image/status")
244+
@PreAuthorize("isAuthenticated()")
245+
@Operation(
246+
summary = "답변 이미지(webp) 상태 조회",
247+
description = """
248+
답변에 포함되는 이미지의 webp 변환 상태를 조회합니다. <br/>
249+
POST /daily-report/image/upload-url 엔드포인트로 이미지를 업로드한 후, 해당 엔드포인트에서 반환된 webpKey를 이 API의 key 파라미터로 전달하여 이미지 상태를 조회할 수 있습니다. <br/>
250+
251+
프론트엔드에서는 이 API를 주기적으로 호출하여 이미지 업로드 후 webp 변환이 완료되었는지 확인해야 합니다. <br/>
252+
최대 7초 동안 이 API를 호출하여 status가 READY로 변경되었는지 확인하고, <br/>
253+
7초가 지나면 실패로 간주하고 사용자에게 이미지 업로드 실패 메시지를 보여주면 됩니다. <br/>
254+
255+
- status가 READY인 경우: 이미지 업로드 및 webp 변환이 모두 완료되어 이미지 URL을 사용할 수 있음
256+
- status가 PROCESSING인 경우: 이미지 업로드는 완료되었으나 webp 변환이 아직 완료되지 않음. 잠시 후 다시 확인 필요
257+
""",
258+
security = @SecurityRequirement(name = "bearerAuth"),
259+
responses = {
260+
@ApiResponse(
261+
responseCode = "200",
262+
description = "성공",
263+
content = @Content(schema = @Schema(implementation = ImageStatusResponse.class), mediaType = "application/json")
264+
),
265+
@ApiResponse(
266+
responseCode = "400",
267+
description = """
268+
- ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
269+
""",
270+
content = @Content
271+
),
272+
@ApiResponse(
273+
responseCode = "401",
274+
description = "인증 실패 (JWT 토큰 관련)",
275+
content = @Content
276+
)
277+
}
278+
)
279+
public ResponseEntity<ApiResponseDto<ImageStatusResponse>> getImageStatus(
280+
@AuthenticationPrincipal UserPrincipal principal,
281+
@RequestParam String key
282+
) {
283+
ImageStatusResponse response = dailyReportService.getImageStatus(key, principal.getId());
284+
285+
return ApiResponseEntity.ok(response);
286+
}
287+
188288
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.devkor.ifive.nadab.domain.dailyreport.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
6+
@Schema(description = "답변 이미지 업로드 PresignedURL 생성 요청")
7+
public record CreateAnswerImageUploadUrlRequest(
8+
@Schema(description = "파일 확장자 (image/png, image/jpeg만 허용)", example = "image/png")
9+
@NotBlank(message = "파일 확장자는 필수입니다.")
10+
String contentType
11+
) {
12+
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/DailyReportRequest.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ public record DailyReportRequest(
1313
@Size(max = 200, message = "answer는 최대 200자까지 입력 가능합니다")
1414
@Size(min = 1, message = "answer는 최소 1자 이상 입력해야 합니다")
1515
@NotBlank(message = "answer는 필수입니다")
16-
String answer
16+
String answer,
17+
18+
@Schema(
19+
description = "이 값은 presignedURL 생성 API의 응답에서 받은 objectKey여야 합니다. ",
20+
example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png"
21+
)
22+
String objectKey,
23+
24+
@Schema(
25+
description = "이 값은 presignedURL 생성 API의 응답에서 받은 webpKey여야 합니다. ",
26+
example = "dev/answers/webp/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.webp"
27+
)
28+
String webpKey
1729
) {
1830
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerDetailResponse.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,20 @@ public record AnswerDetailResponse(
2424
String content,
2525

2626
@Schema(description = "리포트 감정 상태", example = "ACHIEVEMENT")
27-
String emotion
27+
String emotion,
28+
29+
@Schema(description = "이미지 URL")
30+
String imageUrl
2831
) {
29-
public static AnswerDetailResponse from(AnswerDetailDto dto) {
32+
public static AnswerDetailResponse from(AnswerDetailDto dto, String imageUrl) {
3033
return new AnswerDetailResponse(
3134
dto.questionText(),
3235
dto.interestCode() != null ? dto.interestCode().name() : null,
3336
dto.answerDate(),
3437
dto.answerContent(),
3538
dto.reportContent(),
36-
dto.emotionCode() != null ? dto.emotionCode().name() : null
39+
dto.emotionCode() != null ? dto.emotionCode().name() : null,
40+
imageUrl
3741
);
3842
}
3943
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "답변 이미지 업로드 PresignedURL 생성 응답")
6+
public record CreateAnswerImageUploadUrlResponse(
7+
@Schema(description = "답변 이미지 업로드 PresignedURL", example = "https://nadab-profile-images.s3.amazonaws.com/...")
8+
String uploadUrl,
9+
10+
@Schema(description = "답변 이미지 Object Key. 일간 리포트 생성에 사용됩니다.", example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png")
11+
String objectKey,
12+
13+
@Schema(description = "답변 이미지 Webp Key. 일간 리포트 생성에 사용됩니다.", example = "dev/answers/webp/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.webp")
14+
String webpKey
15+
) {
16+
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CreateDailyReportResponse.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public record CreateDailyReportResponse(
1414
String emotion,
1515

1616
@Schema(description = "리포트 작성 후 크리스탈 잔액", example = "100")
17-
Long balanceAfter
17+
Long balanceAfter,
18+
19+
@Schema(description = "이미지 URL")
20+
String imageUrl
1821
) {
1922
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/DailyReportResponse.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ public record DailyReportResponse(
1515
String emotion,
1616

1717
@Schema(description = "피드 공유 상태", example = "false")
18-
Boolean isShared
18+
Boolean isShared,
19+
20+
@Schema(description = "이미지 URL")
21+
String imageUrl
1922
) {
2023
}

0 commit comments

Comments
 (0)