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
4 changes: 4 additions & 0 deletions .github/workflows/deploy-to-dev-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ jobs:
INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')"
export INSIGHT_PROMPT

INSIGHT_WITH_IMAGE_PROMPT_B64="${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }}"
INSIGHT_WITH_IMAGE_PROMPT="$(printf '%s' "$INSIGHT_WITH_IMAGE_PROMPT_B64" | base64 -d | tr -d '\r')"
export INSIGHT_WITH_IMAGE_PROMPT

WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}"
WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')"
export WEEKLY_PROMPT
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy-to-prod-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ jobs:
INSIGHT_PROMPT_B64="${{ secrets.INSIGHT_PROMPT_B64 }}"
INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')"
export INSIGHT_PROMPT

INSIGHT_WITH_IMAGE_PROMPT_B64="${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }}"
INSIGHT_WITH_IMAGE_PROMPT="$(printf '%s' "$INSIGHT_WITH_IMAGE_PROMPT_B64" | base64 -d | tr -d '\r')"
export INSIGHT_WITH_IMAGE_PROMPT

WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}"
WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.devkor.ifive.nadab.domain.auth.infra.scheduler;

import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository;
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
import com.devkor.ifive.nadab.domain.user.core.service.ProfileImageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

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

private final UserRepository userRepository;
private final AnswerEntryRepository answerEntryRepository;
private final ProfileImageService profileImageService;

@Scheduled(cron = "0 0 0 * * *") // 매일 자정
@Transactional
public void cleanupDeletedUsers() {
OffsetDateTime expirationDate = OffsetDateTime.now().minusDays(14);
int deletedCount = userRepository.deleteOldWithdrawnUsers(expirationDate);

// 영구 삭제될 User의 id들
List<Long> targetUserIds = userRepository.findOldWithdrawnUserIds(expirationDate);

if(targetUserIds.isEmpty()) {
log.debug("정리할 탈퇴 회원이 없습니다.");
return;
}

// 삭제 대상 회원들의 프로필 이미지 키와 답변 이미지 키를 모두 수집하여 S3에서 삭제
Set<String> imageKeysToDelete = new HashSet<>();
imageKeysToDelete.addAll(userRepository.findProfileImageKeysByIdIn(targetUserIds));
imageKeysToDelete.addAll(answerEntryRepository.findImageKeysByUserIds(targetUserIds));
imageKeysToDelete.forEach(profileImageService::deleteProfileImage);

int deletedCount = userRepository.deleteWithdrawnUsersByIdIn(targetUserIds);

if (deletedCount > 0) {
log.info("회원 정리 완료: {}명의 탈퇴 회원 영구 삭제 (기준일: {})", deletedCount, expirationDate.toLocalDate());
} else {
log.debug("정리할 탈퇴 회원이 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.devkor.ifive.nadab.domain.dailyreport.api;

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

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

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


| 응답의 emotion | 해당 감정 |
| :--- | :--- |
| `ACHIEVEMENT` | 성취 |
Expand All @@ -65,6 +76,7 @@ public class DailyReportController {
responseCode = "400",
description = """
- ErrorCode: DAILY_QUESTION_MISMATCH - 요청한 질문이 사용자에게 할당된 오늘의 질문과 일치하지 않음
- ErrorCode: IMAGE_INVALID_KEY - 유효하지 않은 이미지 키
""",
content = @Content
),
Expand Down Expand Up @@ -185,4 +197,92 @@ public ResponseEntity<ApiResponseDto<AnswerDetailResponse>> getDailyReportById(
AnswerDetailResponse response = dailyReportQueryService.getDailyReportById(principal.getId(), reportId);
return ApiResponseEntity.ok(response);
}

@PostMapping("/image/upload-url")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "답변 이미지 업로드 PresignedURL 생성",
description = """
답변에 포함되는 이미지를 업로드할 수 있는 PresignedURL을 생성합니다.

- HTTP Method: PUT
- Headers:
- Content-Type(필수): image/jpeg, image/png만 허용
- Body: 이미지 파일
- URL 만료 시간: 5분
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "성공",
content = @Content(schema = @Schema(implementation = CreateAnswerImageUploadUrlResponse.class), mediaType = "application/json")
),
@ApiResponse(
responseCode = "400",
description = """
- ErrorCode: IMAGE_UNSUPPORTED_TYPE - 지원하지 않는 이미지 타입
""",
content = @Content
),
@ApiResponse(
responseCode = "401",
description = "인증 실패 (JWT 토큰 관련)",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<CreateAnswerImageUploadUrlResponse>> createAnswerImageUploadUrl(
@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody CreateAnswerImageUploadUrlRequest request) {
CreateAnswerImageUploadUrlResponse response =
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
@@ -0,0 +1,12 @@
package com.devkor.ifive.nadab.domain.dailyreport.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "답변 이미지 업로드 PresignedURL 생성 요청")
public record CreateAnswerImageUploadUrlRequest(
@Schema(description = "파일 확장자 (image/png, image/jpeg만 허용)", example = "image/png")
@NotBlank(message = "파일 확장자는 필수입니다.")
String contentType
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ public record DailyReportRequest(
@Size(max = 200, message = "answer는 최대 200자까지 입력 가능합니다")
@Size(min = 1, message = "answer는 최소 1자 이상 입력해야 합니다")
@NotBlank(message = "answer는 필수입니다")
String answer
String answer,

@Schema(
description = "이 값은 presignedURL 생성 API의 응답에서 받은 objectKey여야 합니다. ",
example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png"
)
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 @@ -24,16 +24,20 @@ public record AnswerDetailResponse(
String content,

@Schema(description = "리포트 감정 상태", example = "ACHIEVEMENT")
String emotion
String emotion,

@Schema(description = "이미지 URL")
String imageUrl
) {
public static AnswerDetailResponse from(AnswerDetailDto dto) {
public static AnswerDetailResponse from(AnswerDetailDto dto, String imageUrl) {
return new AnswerDetailResponse(
dto.questionText(),
dto.interestCode() != null ? dto.interestCode().name() : null,
dto.answerDate(),
dto.answerContent(),
dto.reportContent(),
dto.emotionCode() != null ? dto.emotionCode().name() : null
dto.emotionCode() != null ? dto.emotionCode().name() : null,
imageUrl
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "답변 이미지 업로드 PresignedURL 생성 응답")
public record CreateAnswerImageUploadUrlResponse(
@Schema(description = "답변 이미지 업로드 PresignedURL", example = "https://nadab-profile-images.s3.amazonaws.com/...")
String uploadUrl,

@Schema(description = "답변 이미지 Object Key. 일간 리포트 생성에 사용됩니다.", example = "dev/answers/original/12345/092f7ab2-c845-4bdf-8458-e2897135d4e7.png")
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
Expand Up @@ -14,6 +14,9 @@ public record CreateDailyReportResponse(
String emotion,

@Schema(description = "리포트 작성 후 크리스탈 잔액", example = "100")
Long balanceAfter
Long balanceAfter,

@Schema(description = "이미지 URL")
String imageUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public record DailyReportResponse(
String emotion,

@Schema(description = "피드 공유 상태", example = "false")
Boolean isShared
Boolean isShared,

@Schema(description = "이미지 URL")
String imageUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public record FeedResponse(
String answer,

@Schema(description = "감정 코드", example = "ACHIEVEMENT")
String emotionCode
String emotionCode,

@Schema(description = "이미지 URL")
String imageUrl
) {
}
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 @@ -14,6 +14,7 @@
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode;
import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryQueryRepository;
import com.devkor.ifive.nadab.domain.dailyreport.application.helper.CursorParser;
import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
import com.devkor.ifive.nadab.global.exception.NotFoundException;
import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator;
Expand All @@ -35,6 +36,8 @@ public class AnswerQueryService {

private final AnswerEntryQueryRepository answerEntryQueryRepository;

private final ProfileImageUrlBuilder profileImageUrlBuilder;

private static final int PAGE_SIZE = 20;

public SearchAnswerEntryResponse searchAnswers(Long userId, SearchAnswerEntryRequest request) {
Expand Down Expand Up @@ -129,14 +132,18 @@ public AnswerDetailResponse getAnswerDetailById(Long userId, Long answerId) {
AnswerDetailDto dto = answerEntryQueryRepository.findDetailByAnswerId(userId, answerId)
.orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND));

return AnswerDetailResponse.from(dto);
String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null;

return AnswerDetailResponse.from(dto, imageUrl);
}

public AnswerDetailResponse getAnswerDetailByDate(Long userId, LocalDate date) {
AnswerDetailDto dto = answerEntryQueryRepository.findDetailByUserAndDate(userId, date)
.orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND));

return AnswerDetailResponse.from(dto);
String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null;

return AnswerDetailResponse.from(dto, imageUrl);
}

/**
Expand Down
Loading
Loading