diff --git a/goorm/build.gradle b/goorm/build.gradle index d9335dc..cc82a16 100644 --- a/goorm/build.gradle +++ b/goorm/build.gradle @@ -36,6 +36,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.753' } tasks.named('test') { diff --git a/goorm/src/main/java/study/goorm/domain/cloth/domain/entity/Cloth.java b/goorm/src/main/java/study/goorm/domain/cloth/domain/entity/Cloth.java index dce955c..3abdd76 100644 --- a/goorm/src/main/java/study/goorm/domain/cloth/domain/entity/Cloth.java +++ b/goorm/src/main/java/study/goorm/domain/cloth/domain/entity/Cloth.java @@ -64,4 +64,14 @@ public class Cloth extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; + + public void increaseWearCount() { + this.wearNum++; + } + + public void decreaseWearCount() { + if (this.wearNum > 0) { + this.wearNum--; + } + } } diff --git a/goorm/src/main/java/study/goorm/domain/cloth/domain/repository/ClothRepository.java b/goorm/src/main/java/study/goorm/domain/cloth/domain/repository/ClothRepository.java index 5b66547..588eef9 100644 --- a/goorm/src/main/java/study/goorm/domain/cloth/domain/repository/ClothRepository.java +++ b/goorm/src/main/java/study/goorm/domain/cloth/domain/repository/ClothRepository.java @@ -3,9 +3,14 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import study.goorm.domain.cloth.domain.entity.Cloth; import study.goorm.domain.member.domain.entity.Member; +import java.util.List; + public interface ClothRepository extends JpaRepository { // 1. wearNum 오름차순 @@ -19,4 +24,12 @@ public interface ClothRepository extends JpaRepository { // 4. createdAt 내림차순 Page findByMemberOrderByCreatedAtDesc(Member member, Pageable pageable); + + // 특정 ID 목록에 해당하는 옷들을 조회 + List findAllByIdIn(List ids); + + // 옷 착용 횟수 증가 (생성 및 수정 시 사용) + @Modifying + @Query("UPDATE Cloth c SET c.wearNum = c.wearNum + 1 WHERE c.id = :clothId") + void incrementWearNum(@Param("clothId") Long clothId); } diff --git a/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java new file mode 100644 index 0000000..e508ba7 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java @@ -0,0 +1,101 @@ +package study.goorm.domain.history.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import study.goorm.domain.history.application.HistoryService; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.global.common.response.BaseResponse; +import study.goorm.global.error.code.status.SuccessStatus; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/histories") +public class HistoryRestController { + + private final HistoryService historyService; + + @GetMapping("/monthly/") + @Operation(summary = "특정 월의 기록을 전부 조회하는 API", description = "query string으로 clokeyId, month를 넘겨주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "HISTORY_200", description = "OK, 성공적으로 조회되었습니다.") + }) + @Parameters({ + @Parameter(name = "clokey-id", description = "클로키 유저의 clokey id, query string 입니다."), + @Parameter(name = "month", description = "YYYY-MM 형태로 값을 입력 받는 query string 입니다.") + }) + public BaseResponse getMonthlyHistories( + @RequestParam(value = "clokey-id") String clokeyId, + @RequestParam(value = "month") String month + ) { + + HistoryResponseDTO.HistoryMonthResult result = historyService.getMonthlyHistories(clokeyId,month); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_MONTH, result); + } + + @GetMapping("/{historyId}") + @Operation(summary = "특정 History에 대한 정보를 조회하는 API", description = "Path Variable로 historyId를 던져주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_201", description = "OK, 성공적으로 조회되었습니다."), + }) + public BaseResponse getDailyHistory( + @Parameter(name = "historyId") Long historyId + ) { + HistoryResponseDTO.HistoryDayResult result = historyService.getDailyHistory(historyId); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_DAY, result); + } + + + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "새로운 기록 생성 API", description = "메타데이터(JSON)와 이미지 파일을 받아 기록을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_202", description = "OK, 성공적으로 조회되었습니다.") + }) + public BaseResponse createHistory( + @RequestPart("metadata") HistoryRequestDTO.HistoryCreateDTO request, + @RequestPart(value = "image", required = false) MultipartFile imageFile, + @RequestHeader(name = "clokey-id") String clokeyId // 사용자 인증용 + ) { + Long historyId = historyService.createHistory(clokeyId, request, imageFile); + return BaseResponse.onSuccess(SuccessStatus.HISTORY_CREATED, historyId); + } + + @PatchMapping(value = "/{historyId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "특정 History에 대한 정보를 수정하는 API", description = "Path Variable로 historyId를 던져주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_203", description = "OK, 성공적으로 조회되었습니다."), + }) + public BaseResponse updateHistory( + @PathVariable(name = "historyId") Long historyId, + @RequestPart("metadata") HistoryRequestDTO.HistoryUpdateDTO request, + @RequestPart(value = "image", required = false) MultipartFile imageFile, + @RequestHeader(name = "clokey-id") String clokeyId + ) { + historyService.updateHistory(historyId, clokeyId, request, imageFile); + + return BaseResponse.onSuccess(SuccessStatus.CLOTH_DELETED, null); + } + + @DeleteMapping("/{historyId}") + @Operation(summary = "특정 History 기록을 삭제하는 API", description = "Path Variable로 historyId를 던져주세요. " + + "관련된 댓글, 옷 착용 횟수, 해시태그 기록, 사진, 좋아요, 기록-옷 연결이 모두 삭제됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "NO_CONTENT, 성공적으로 삭제되었습니다."), + }) + public BaseResponse deleteHistory( + @PathVariable Long historyId, + @RequestHeader(name = "clokey-id") String clokeyId + ) { + historyService.deleteHistory(historyId, clokeyId); + + return BaseResponse.onSuccess(SuccessStatus.CLOTH_DELETED, null); + } +} diff --git a/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java new file mode 100644 index 0000000..f7bcb4f --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java @@ -0,0 +1,19 @@ +package study.goorm.domain.history.application; + +import org.springframework.web.multipart.MultipartFile; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; + +public interface HistoryService { + + HistoryResponseDTO.HistoryMonthResult getMonthlyHistories(String clokeyId, String month); + + HistoryResponseDTO.HistoryDayResult getDailyHistory(Long historyId); + + Long createHistory(String clokeyId, HistoryRequestDTO.HistoryCreateDTO request, MultipartFile imageFile); + + void updateHistory(Long historyId, String clokeyId, HistoryRequestDTO.HistoryUpdateDTO request, MultipartFile imageFile); + + void deleteHistory(Long historyId, String clokeyId); + +} diff --git a/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java new file mode 100644 index 0000000..803f410 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -0,0 +1,352 @@ +package study.goorm.domain.history.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import study.goorm.domain.cloth.domain.entity.Cloth; +import study.goorm.domain.cloth.domain.repository.ClothRepository; +import study.goorm.domain.history.converter.HistoryConverter; +import study.goorm.domain.cloth.exception.ClothException; +import study.goorm.domain.history.domain.entity.*; +import study.goorm.domain.history.domain.repository.*; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.domain.history.exception.HistoryException; +import study.goorm.domain.member.domain.entity.Member; +import study.goorm.domain.member.domain.repository.MemberRepository; +import study.goorm.global.error.code.status.ErrorStatus; +import study.goorm.global.exception.GeneralException; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HistoryServiceImpl implements HistoryService{ + + private final HistoryRepository historyRepository; + private final HistoryImageRepository historyImageRepository; + private final HashtagHistoryRepository hashtagHistoryRepository; + private final HistoryClothRepository historyClothRepository; + private final MemberRepository memberRepository; + private final ClothRepository clothRepository; + private final HashtagRepository hashtagRepository; + private final S3Service s3Service; + private final CommentRepository commentRepository; + private final MemberLikeRepository memberLikeRepository; + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryMonthResult getMonthlyHistories(String clokeyId, String month) { + + Member member = memberRepository.findByClokeyId(clokeyId) + .orElseThrow(()-> new ClothException(ErrorStatus.NO_SUCH_MEMBER)); + + // HistoryImage와 그에 연결된 History, Member를 함께 로드합니다. + List fetchedHistoryImages = historyImageRepository.findMonthlyHistoryImagesWithDetails(clokeyId, month); + + // 모든 변환 로직을 컨버터의 단일 메소드에 위임합니다. + return HistoryConverter.toHistoryMonthResult(member, fetchedHistoryImages); + } + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryDayResult getDailyHistory(Long historyId) { + + // 1. History와 Member 정보 조회 + History history = historyRepository.findHistoryAndMemberById(historyId) + .orElseThrow(() -> new ClothException(ErrorStatus.HISTORY_NOT_FOUND)); + + Member authorMember = history.getMember(); + + // 2. HistoryImage 목록 조회 + List imageUrls = historyImageRepository.findByHistoryId(historyId).stream() + .map(HistoryImage::getImageUrl) + .collect(Collectors.toList()); + + // 3. Hashtag 목록 조회 + List hashtags = hashtagHistoryRepository.findByHistoryIdWithHashtag(historyId).stream() + .map(hh -> hh.getHashtag().getName()) + .collect(Collectors.toList()); + + // 4. Cloth 목록 조회 + List clothDTOs = + historyClothRepository.findByHistoryIdWithCloth(historyId).stream() + .map(hc -> HistoryResponseDTO.HistoryDayResult.ClothDTO.builder() // DTO 클래스명 변경 + .clothId(hc.getCloth().getId()) + .clothImageUrl(hc.getCloth().getClothUrl()) + .clothName(hc.getCloth().getName()) + .build()) + .collect(Collectors.toList()); + + // 5. 댓글 수 조회 + Long commentCount = historyRepository.countCommentsByHistoryId(historyId); + + // 6. 좋아요 수 조회 + Long likeCount = historyRepository.countLikesByHistoryId(historyId); + + // 7. 현재 요청한 유저가 해당 기록에 좋아요를 눌렀는지 여부 확인 + boolean liked = false; + /* + if (viewerClokeyId != null) { + Member viewerMember = memberRepository.findByClokeyId(viewerClokeyId) + .orElse(null); + if (viewerMember != null) { + liked = historyRepository.existsMemberLikeByHistoryIdAndMemberId(historyId, viewerMember.getId()); + } + } + */ + + // Converter를 사용하여 DTO로 변환 + return HistoryConverter.toHistoryDayResult( // Converter 메소드명 변경 (아래에서 정의) + history, + authorMember, + imageUrls, + hashtags, + clothDTOs, + commentCount.intValue(), + likeCount.intValue(), + liked + ); + } + + @Override + @Transactional + public Long createHistory(String clokeyId, HistoryRequestDTO.HistoryCreateDTO request, MultipartFile imageFile) { + + // 1. 회원 검증 및 조회 + Member member = memberRepository.findByClokeyId(clokeyId) + .orElseThrow(() -> new ClothException(ErrorStatus.NO_SUCH_MEMBER)); + + // 2. History 엔티티 생성 및 저장 + History newHistory = History.builder() + .content(request.getContent()) + .historyDate(request.getDate()) + .member(member) + .likes(0) // 초기 좋아요 수 0 + .build(); + historyRepository.save(newHistory); + + if (request.getContent() != null) { + newHistory.setContent(request.getContent()); + } + + // 3. 이미지 업로드 및 HistoryImage 저장 + if (imageFile != null && !imageFile.isEmpty()) { + String imageUrl = s3Service.uploadFile(imageFile); // S3에 이미지 업로드 + HistoryImage historyImage = HistoryImage.builder() + .imageUrl(imageUrl) + .history(newHistory) + .build(); + historyImageRepository.save(historyImage); + } + + // 4. 옷 착용 횟수 반영 및 HistoryCloth 저장 + if (request.getClothes() != null && !request.getClothes().isEmpty()) { + List clothes = clothRepository.findAllByIdIn(request.getClothes()); // 요청된 모든 옷 ID에 대해 옷 엔티티 조회 + if (clothes.size() != request.getClothes().size()) { + // 요청된 옷 ID 중 유효하지 않은 것이 있을 경우 예외 처리 + throw new ClothException(ErrorStatus.NO_SUCH_CLOTH); // 적절한 예외 메시지로 변경 + } + for (Cloth cloth : clothes) { + clothRepository.incrementWearNum(cloth.getId()); // wearNum 증가 + HistoryCloth historyCloth = HistoryCloth.builder() + .history(newHistory) + .cloth(cloth) + .build(); + historyClothRepository.save(historyCloth); + } + } + + // 5. 해시태그 처리 및 HashtagHistory 저장 + if (request.getHashtags() != null && !request.getHashtags().isEmpty()) { + for (String tagName : request.getHashtags()) { + Hashtag hashtag = hashtagRepository.findByName(tagName) + .orElseGet(() -> hashtagRepository.save(Hashtag.builder().name(tagName).build())); // 없으면 새로 생성 + HashtagHistory hashtagHistory = HashtagHistory.builder() + .history(newHistory) + .hashtag(hashtag) + .build(); + hashtagHistoryRepository.save(hashtagHistory); + } + } + + return newHistory.getId(); // 생성된 History의 ID 반환 + } + + @Override + @Transactional + public void updateHistory( + Long historyId, + String clokeyId, + HistoryRequestDTO.HistoryUpdateDTO request, + MultipartFile imageFile + ) { + + // 1. History 조회 및 존재 여부 확인 + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryException(ErrorStatus.HISTORY_NOT_FOUND)); + + if (!history.getMember().getClokeyId().equals(clokeyId)) { + throw new HistoryException(ErrorStatus._FORBIDDEN); + } + + if (request.getContent() != null) { + history.setContent(request.getContent()); + } + + + // 3. 이미지 처리: 새로운 이미지가 제공된 경우에만 기존 이미지 삭제 및 업데이트 + if (imageFile != null && !imageFile.isEmpty()) { + // 기존 이미지 삭제 + List oldImages = historyImageRepository.findByHistoryId(historyId); + for (HistoryImage oldImage : oldImages) { + try { + s3Service.deleteFile(oldImage.getImageUrl()); + } catch (GeneralException e) { + log.error("Failed to delete old S3 image {}: {}", oldImage.getImageUrl(), e.getMessage()); + throw e; // 삭제 실패 시 예외를 다시 던져 트랜잭션 롤백 + } + historyImageRepository.delete(oldImage); + } + + // 새 이미지 업로드 + String imageUrl; + try { + imageUrl = s3Service.uploadFile(imageFile); // S3에 새 파일 업로드 + } catch (GeneralException e) { + log.error("Failed to upload new S3 image: {}", e.getMessage()); + throw e; // 업로드 실패 시 예외를 다시 던져 트랜잭션 롤백 + } + + // imageUrl이 null이 아닐 때만 저장 (S3Service.uploadFile이 null을 반환할 수 있는 경우) + if (imageUrl != null) { + HistoryImage newImage = HistoryImage.builder() + .imageUrl(imageUrl) + .history(history) + .build(); + historyImageRepository.save(newImage); + } else { + log.warn("New image file {} was provided but upload failed. History updated without this image.", imageFile.getOriginalFilename()); + // 이미지 업로드 실패 시 경고만 남기고 계속 진행 (트랜잭션 롤백 안 함) + } + } + + // 4. 해시태그 업데이트 + if (request.getHashtags() != null && !request.getHashtags().isEmpty()) { + + hashtagHistoryRepository.deleteAllByHistory(history); + + List newHashtagHistories = new ArrayList<>(); + for (String tagName : request.getHashtags()) { + Hashtag hashtag = hashtagRepository.findByName(tagName) + .orElseGet(() -> { + Hashtag newHashtag = Hashtag.builder().name(tagName).build(); + return hashtagRepository.save(newHashtag); + }); + + HashtagHistory hashtagHistory = HashtagHistory.builder() + .hashtag(hashtag) + .history(history) + .build(); + newHashtagHistories.add(hashtagHistory); + } + hashtagHistoryRepository.saveAll(newHashtagHistories); + } + + if (request.getClothes() != null && !request.getClothes().isEmpty()) { + // 요청에 옷 목록이 존재하고 비어있지 않다면, 기존 HistoryCloth 레코드를 모두 삭제 + List existingHistoryClothes = historyClothRepository.findByHistoryIdWithCloth(historyId); + for (HistoryCloth existingHistoryCloth : existingHistoryClothes) { + Cloth clothToDecrease = existingHistoryCloth.getCloth(); + if (clothToDecrease != null) { + clothToDecrease.decreaseWearCount(); + clothRepository.save(clothToDecrease); // 감소된 착용 횟수 저장 + } + } + + historyClothRepository.deleteAllByHistory(history); + + List newHistoryClothes = new ArrayList<>(); + for (Long clothId : request.getClothes()) { + Cloth cloth = clothRepository.findById(clothId) + .orElseThrow(() -> new HistoryException(ErrorStatus.NO_SUCH_CLOTH)); + + HistoryCloth historyCloth = HistoryCloth.builder() + .history(history) + .cloth(cloth) + .build(); + newHistoryClothes.add(historyCloth); + + cloth.increaseWearCount(); + clothRepository.save(cloth); + } + historyClothRepository.saveAll(newHistoryClothes); + } + + // 6. 변경된 History 엔티티 최종 저장 + historyRepository.save(history); + } + + @Override + @Transactional + public void deleteHistory(Long historyId, String clokeyId) { + // 1. History 조회 및 존재 여부 확인 + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryException(ErrorStatus.HISTORY_NOT_FOUND)); + + // 사용자 인증 + if (!history.getMember().getClokeyId().equals(clokeyId)) { + throw new HistoryException(ErrorStatus._FORBIDDEN); + } + + // 2. 기록과 관련된 댓글 전부 삭제 + // Comment 엔티티에 history 필드가 있고, CommentRepository에 deleteAllByHistory(History history) 메서드가 필요 + commentRepository.deleteAllByHistory(history); + log.info("History {} related comments deleted.", historyId); + + // 3. 기록과 관련된 좋아요 전부 삭제 + // Like 엔티티에 history 필드가 있고, LikeRepository에 deleteAllByHistory(History history) 메서드가 필요 + memberLikeRepository.deleteAllByHistory(history); + log.info("History {} related likes deleted.", historyId); + + // 4. 기록-옷 테이블에서 기록과 관련된 row들을 전부 삭제하기 전에, 옷 착용 횟수 감소 + List existingHistoryClothes = historyClothRepository.findByHistory(history); + for (HistoryCloth existingHistoryCloth : existingHistoryClothes) { + Cloth clothToDecrease = existingHistoryCloth.getCloth(); + if (clothToDecrease != null) { + clothToDecrease.decreaseWearCount(); + clothRepository.save(clothToDecrease); // 감소된 착용 횟수 저장 + log.debug("Decreased wear count for Cloth ID: {}", clothToDecrease.getId()); + } + } + historyClothRepository.deleteAllByHistory(history); + log.info("History {} related HistoryCloth entries deleted and wear counts decreased.", historyId); + + // 5. 기록과 관련된 Hashtag_history 전부 삭제 + hashtagHistoryRepository.deleteAllByHistory(history); + log.info("History {} related HashtagHistory entries deleted.", historyId); + + // 6. 기록과 관련된 사진이 모두 삭제 (S3 및 DB) + List historyImages = historyImageRepository.findByHistoryId(history.getId()); + for (HistoryImage image : historyImages) { + try { + s3Service.deleteFile(image.getImageUrl()); + log.debug("Deleted S3 image: {}", image.getImageUrl()); + } catch (GeneralException e) { + log.error("Failed to delete S3 image {}: {}", image.getImageUrl(), e.getMessage()); + // S3 삭제 실패 시에도 DB 레코드 삭제는 진행 (멱등성 고려) + } + historyImageRepository.delete(image); + } + log.info("History {} related images deleted from S3 and DB.", historyId); + + // 7. 최종적으로 History 엔티티 삭제 + historyRepository.delete(history); + log.info("History {} deleted successfully.", historyId); + } +} \ No newline at end of file diff --git a/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java b/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java new file mode 100644 index 0000000..1a5eb2a --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java @@ -0,0 +1,10 @@ +package study.goorm.domain.history.application; + +import org.springframework.web.multipart.MultipartFile; + +public interface S3Service { + + String uploadFile(MultipartFile file); + + void deleteFile(String fileUrl); +} diff --git a/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java b/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java new file mode 100644 index 0000000..b547af6 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java @@ -0,0 +1,101 @@ +package study.goorm.domain.history.application; + +import com.amazonaws.AmazonServiceException; // v1의 일반적인 AWS 서비스 예외 +import com.amazonaws.services.s3.AmazonS3; // AmazonS3 임포트 +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import study.goorm.domain.history.exception.HistoryException; +import study.goorm.global.error.code.status.ErrorStatus; // 예외 처리용 ErrorStatus + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; // URL 디코딩을 위해 임포트 +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class S3ServiceImpl implements S3Service{ + + private final AmazonS3 amazonS3; // AmazonS3 클라이언트 주입 + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + public String uploadFile(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String objectKey = UUID.randomUUID() + extension; // S3 객체 키 (고유한 파일 이름) + + // 메타데이터 설정 (파일 크기, 콘텐츠 타입 등) + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try (InputStream inputStream = file.getInputStream()) { + // PutObjectRequest 생성 및 파일 업로드 (v1 방식) + PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectKey, inputStream, metadata); + amazonS3.putObject(putObjectRequest); + + log.info("File '{}' uploaded to S3 bucket '{}' as '{}'", originalFilename, bucketName, objectKey); + + // S3 퍼블릭 URL 반환 (v1에서는 getUrl() 메소드 사용) + return amazonS3.getUrl(bucketName, objectKey).toString(); + } catch (AmazonServiceException e) { // S3 관련 예외는 AmazonServiceException + log.error("Error uploading file to S3: {}", e.getErrorMessage()); + throw new HistoryException(ErrorStatus.S3_FILE_UPLOAD_FAILED); + } catch (IOException e) { + log.error("IO error during S3 upload: {}", e.getMessage(), e); + } + return originalFilename; + } + + /** + * S3에 저장된 파일을 URL을 통해 삭제합니다. + * @param fileUrl 삭제할 파일의 S3 URL + */ + public void deleteFile(String fileUrl) { + String objectKey; + try { + // S3 URL에서 objectKey 추출 + // 예: https://your-bucket.s3.ap-northeast-2.amazonaws.com/your-object-key.jpg + String path = new java.net.URL(fileUrl).getPath(); + // URL 디코딩이 필요할 수 있습니다 (파일 이름에 특수 문자 포함 시) + // UTF-8로 디코딩, 프로젝트 인코딩에 따라 변경될 수 있음 + objectKey = URLDecoder.decode(path.substring(path.indexOf('/') + 1), StandardCharsets.UTF_8); + + // 객체 키가 버킷 이름으로 시작하는 경우 제거 (URL 형식에 따라 다름) + // 예: /your-bucket/your-object-key.jpg -> your-object-key.jpg + if (objectKey.startsWith(bucketName + "/")) { + objectKey = objectKey.substring(bucketName.length() + 1); + } + + if (objectKey.isEmpty()) { + throw new IllegalArgumentException("Object key could not be extracted from URL: " + fileUrl); + } + } catch (Exception e) { + log.error("Invalid file URL for deletion: {}", fileUrl); + throw new HistoryException(ErrorStatus.S3_FILE_URL_PARSE_FAILED); + } + + try { + // DeleteObjectRequest 생성 및 파일 삭제 (v1 방식) + DeleteObjectRequest deleteObjectRequest = new DeleteObjectRequest(bucketName, objectKey); + amazonS3.deleteObject(deleteObjectRequest); + log.info("File '{}' deleted from S3 bucket '{}'", objectKey, bucketName); + } catch (AmazonServiceException e) { + log.error("Error deleting file from S3: {}", e.getErrorMessage()); + throw new HistoryException(ErrorStatus.S3_FILE_DELETE_FAILED); + } + } +} \ No newline at end of file diff --git a/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java b/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java new file mode 100644 index 0000000..2d4e429 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java @@ -0,0 +1,77 @@ +package study.goorm.domain.history.converter; + +import study.goorm.domain.history.domain.entity.History; +import study.goorm.domain.history.domain.entity.HistoryImage; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.domain.member.domain.entity.Member; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class HistoryConverter { + public static HistoryResponseDTO.HistoryMonthResult toHistoryMonthResult(Member member, List fetchedHistoryImages){ + + // 1. History별로 가장 먼저 발견된 (ID가 낮은) HistoryImage를 선택합니다. + Map firstImagePerHistory = fetchedHistoryImages.stream() + .collect(Collectors.toMap( + HistoryImage::getHistory, + hi -> hi, + (existing, replacement) -> existing, + LinkedHashMap::new + )); + + // 2. Map의 각 엔트리를 HistoryResponseDTO.HistoryDTO로 변환합니다. + List historyDTOs = firstImagePerHistory.entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getKey().getHistoryDate())) // 최종적으로 HistoryDate 기준으로 정렬 + .map(entry -> { + History history = entry.getKey(); + HistoryImage firstImage = entry.getValue(); + + String imageUrl = firstImage.getImageUrl(); + + return HistoryResponseDTO.HistoryImageDTO.builder() + .historyId(history.getId()) + .date(history.getHistoryDate().toString()) + .imageUrl(imageUrl) + .build(); + }) + .collect(Collectors.toList()); + + // 3. 최종 HistoryMonthResult DTO를 빌드하여 반환합니다. + return HistoryResponseDTO.HistoryMonthResult.builder() + .memberId(member.getId()) + .nickName(member.getNickname()) + .histories(historyDTOs) + .build(); + } + + public static HistoryResponseDTO.HistoryDayResult toHistoryDayResult( // 메소드명 변경 + History history, + Member authorMember, + List imageUrls, + List hashtags, + List clothDTOs, // DTO 클래스명 변경 + int commentCount, + int likeCount, + boolean liked + ) { + return HistoryResponseDTO.HistoryDayResult.builder() // DTO 클래스명 변경 + .memberId(authorMember.getId()) + .historyId(history.getId()) + .memberImageUrl(authorMember.getProfileImageUrl()) + .nickName(authorMember.getNickname()) + .clokeyId(authorMember.getClokeyId()) + .contents(history.getContent()) + .imageUrl(imageUrls) + .hashtags(hashtags) + .likeCount(likeCount) + .commentCount(commentCount) + .date(history.getHistoryDate().toString()) + .cloths(clothDTOs) + .liked(liked) + .build(); + } +} \ No newline at end of file diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/entity/History.java b/goorm/src/main/java/study/goorm/domain/history/domain/entity/History.java index 0180b18..589f5bd 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/entity/History.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/entity/History.java @@ -11,7 +11,7 @@ import java.time.LocalDate; @Entity -@Getter +@Getter @Setter @Builder @DynamicUpdate @DynamicInsert diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/CommentRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/CommentRepository.java index c37745a..9595fbd 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/CommentRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/CommentRepository.java @@ -1,7 +1,14 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.transaction.annotation.Transactional; import study.goorm.domain.history.domain.entity.Comment; +import study.goorm.domain.history.domain.entity.History; public interface CommentRepository extends JpaRepository{ + + @Modifying + @Transactional + void deleteAllByHistory(History history); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagHistoryRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagHistoryRepository.java index a2ae404..beaab8c 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagHistoryRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagHistoryRepository.java @@ -1,7 +1,17 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import study.goorm.domain.history.domain.entity.HashtagHistory; +import study.goorm.domain.history.domain.entity.History; + +import java.util.List; public interface HashtagHistoryRepository extends JpaRepository{ + + @Query("SELECT hh FROM HashtagHistory hh JOIN FETCH hh.hashtag h WHERE hh.history.id = :historyId") + List findByHistoryIdWithHashtag(@Param("historyId") Long historyId); + + void deleteAllByHistory(History history); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagRepository.java index 31330e5..bad217c 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagRepository.java @@ -3,5 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import study.goorm.domain.history.domain.entity.Hashtag; +import java.util.Optional; + public interface HashtagRepository extends JpaRepository{ + + Optional findByName(String name); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryClothRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryClothRepository.java index e3e4bf5..a2fe948 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryClothRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryClothRepository.java @@ -1,10 +1,22 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import study.goorm.domain.cloth.domain.entity.Cloth; +import study.goorm.domain.history.domain.entity.History; import study.goorm.domain.history.domain.entity.HistoryCloth; +import java.util.List; + public interface HistoryClothRepository extends JpaRepository{ void deleteAllByCloth(Cloth cloth); + + @Query("SELECT hc FROM HistoryCloth hc JOIN FETCH hc.cloth c WHERE hc.history.id = :historyId") + List findByHistoryIdWithCloth(@Param("historyId") Long historyId); + + void deleteAllByHistory(History history); + + List findByHistory(History history); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryImageRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryImageRepository.java index d07af9b..40460e0 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryImageRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryImageRepository.java @@ -1,7 +1,16 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import study.goorm.domain.history.domain.entity.HistoryImage; +import java.util.List; + public interface HistoryImageRepository extends JpaRepository{ + + @Query("SELECT hi FROM HistoryImage hi JOIN FETCH hi.history h JOIN FETCH h.member m WHERE m.clokeyId = :clokeyId AND FUNCTION('DATE_FORMAT', h.historyDate, '%Y-%m') = :month ORDER BY h.historyDate ASC, hi.id ASC") + List findMonthlyHistoryImagesWithDetails(@Param("clokeyId") String clokeyId, @Param("month") String month); + + List findByHistoryId(Long historyId); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryRepository.java index 47df229..826182d 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HistoryRepository.java @@ -1,7 +1,27 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import study.goorm.domain.history.domain.entity.History; +import java.util.Optional; + public interface HistoryRepository extends JpaRepository{ + + // History와 Member만 FETCH JOIN + @Query("SELECT h FROM History h JOIN FETCH h.member m WHERE h.id = :historyId") + Optional findHistoryAndMemberById(@Param("historyId") Long historyId); + + // 댓글 수 조회 + @Query("SELECT COUNT(c) FROM Comment c WHERE c.history.id = :historyId") + Long countCommentsByHistoryId(@Param("historyId") Long historyId); + + // 좋아요 수 조회 + @Query("SELECT COUNT(ml) FROM MemberLike ml WHERE ml.history.id = :historyId") + Long countLikesByHistoryId(@Param("historyId") Long historyId); + + // 특정 멤버가 해당 기록에 좋아요를 눌렀는지 확인 + @Query("SELECT CASE WHEN COUNT(ml) > 0 THEN TRUE ELSE FALSE END FROM MemberLike ml WHERE ml.history.id = :historyId AND ml.member.id = :memberId") + boolean existsMemberLikeByHistoryIdAndMemberId(@Param("historyId") Long historyId, @Param("memberId") Long memberId); } diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/repository/MemberLikeRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/MemberLikeRepository.java index 2fb6291..8f68909 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/repository/MemberLikeRepository.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/repository/MemberLikeRepository.java @@ -1,7 +1,14 @@ package study.goorm.domain.history.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.transaction.annotation.Transactional; +import study.goorm.domain.history.domain.entity.History; import study.goorm.domain.history.domain.entity.MemberLike; public interface MemberLikeRepository extends JpaRepository{ + + @Modifying + @Transactional + void deleteAllByHistory(History history); } diff --git a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java new file mode 100644 index 0000000..4561b42 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -0,0 +1,33 @@ +package study.goorm.domain.history.dto; + +import lombok.*; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.util.List; + +public class HistoryRequestDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class HistoryCreateDTO { + private String content; // 게시글 내용 + private List clothes; // 옷 ID 리스트 + private List hashtags; // 해시태그 이름 리스트 + + @DateTimeFormat(pattern = "yyyy-MM-dd") // "2025-01-25" 형식 파싱을 위한 어노테이션 + private LocalDate date; // 기록 날짜 (YYYY-MM-DD 형태) + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryUpdateDTO { // HistoryCreateDTO와 유사하지만, 업데이트 목적임을 명시 + private String content; + private List clothes; // 옷 ID 리스트 + private List hashtags; // 해시태그 리스트 + } +} diff --git a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java new file mode 100644 index 0000000..65ce35f --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java @@ -0,0 +1,62 @@ +package study.goorm.domain.history.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class HistoryResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryMonthResult { + private Long memberId; + private String nickName; + private List histories; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryImageDTO { + private Long historyId; + private String date; + private String imageUrl; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryDayResult { + private Long memberId; + private Long historyId; + private String memberImageUrl; + private String nickName; + private String clokeyId; + private String contents; // JSON 필드명에 맞춰 'contents'로 변경 + private List imageUrl; // 이미지 URL은 리스트로 + private List hashtags; + private int likeCount; + private int commentCount; + private String date; + private List cloths; + private boolean liked; // 'liked'는 boolean 타입 + + // 내부에 ClothDTO 정의 + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ClothDTO { + private Long clothId; + private String clothImageUrl; + private String clothName; + } + } +} diff --git a/goorm/src/main/java/study/goorm/domain/history/exception/HistoryException.java b/goorm/src/main/java/study/goorm/domain/history/exception/HistoryException.java new file mode 100644 index 0000000..48cfd24 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/exception/HistoryException.java @@ -0,0 +1,11 @@ +package study.goorm.domain.history.exception; + +import study.goorm.global.error.code.status.BaseErrorCode; +import study.goorm.global.exception.GeneralException; + +public class HistoryException extends GeneralException { + + public HistoryException(BaseErrorCode code) { + super(code); + } +} diff --git a/goorm/src/main/java/study/goorm/global/config/AwsConfig.java b/goorm/src/main/java/study/goorm/global/config/AwsConfig.java new file mode 100644 index 0000000..4d28ef4 --- /dev/null +++ b/goorm/src/main/java/study/goorm/global/config/AwsConfig.java @@ -0,0 +1,31 @@ +package study.goorm.global.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AwsConfig { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} \ No newline at end of file diff --git a/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java b/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java index bdd3bee..0809644 100644 --- a/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java +++ b/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java @@ -27,8 +27,16 @@ public enum ErrorStatus implements BaseErrorCode { PAGE_SIZE_UNDER_ONE(HttpStatus.BAD_REQUEST,"PAGE_4002","페이지 사이즈는 1이상으로 입력해야 합니다."), // Category - NO_SUCH_CATEGORY(HttpStatus.BAD_REQUEST, "CLOTH_4003", "카테고리가 존재하지 않습니다."); + NO_SUCH_CATEGORY(HttpStatus.BAD_REQUEST, "CLOTH_4003", "카테고리가 존재하지 않습니다."), + // history + WRONG_DATE_FORM(HttpStatus.BAD_REQUEST, "HISTORY_4002", "날짜 형태는 YYYY-MM 이어야 합니다."), + HISTORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "HISTORY_4003", "존재하지 않는 HistoryId 입니다."), + + // AWS S3 + S3_FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5001", "S3 파일 업로드에 실패했습니다."), + S3_FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_5002", "S3 파일 삭제에 실패했습니다."), + S3_FILE_URL_PARSE_FAILED(HttpStatus.BAD_REQUEST, "S3_4001", "S3 파일 URL 파싱에 실패했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/goorm/src/main/java/study/goorm/global/error/code/status/SuccessStatus.java b/goorm/src/main/java/study/goorm/global/error/code/status/SuccessStatus.java index 8ba8bf2..4a7af14 100644 --- a/goorm/src/main/java/study/goorm/global/error/code/status/SuccessStatus.java +++ b/goorm/src/main/java/study/goorm/global/error/code/status/SuccessStatus.java @@ -14,7 +14,13 @@ public enum SuccessStatus implements BaseCode { //Cloth CLOTH_VIEW_SUCCESS(HttpStatus.OK,"CLOTH_200","옷이 성공적으로 조회되었습니다."), CLOTH_CREATED(HttpStatus.CREATED, "CLOTH_201"," 옷이 성공적으로 생성되었습니다."), - CLOTH_DELETED(HttpStatus.NO_CONTENT,"CLOTH_202","옷이 성공적으로 삭제되었습니다"); + CLOTH_DELETED(HttpStatus.NO_CONTENT,"CLOTH_202","옷이 성공적으로 삭제되었습니다"), + + //history + HISTORY_MONTH(HttpStatus.OK, "HISTORY_200", "월별 기록이 성공적으로 조회되었습니다."), + HISTORY_DAY(HttpStatus.OK, "HISTORY_201", "일별 기록이 성공적으로 조회되었습니다."), + HISTORY_CREATED(HttpStatus.CREATED, "HISTORY_202", "기록이 성공적으로 생성되었습니다."), + HISTORY_UPDATE(HttpStatus.OK, "HISTORY_203", "기록이 성공적으로 수정되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/goorm/src/main/resources/application.yml b/goorm/src/main/resources/application.yml index fd30bc5..7e50c39 100644 --- a/goorm/src/main/resources/application.yml +++ b/goorm/src/main/resources/application.yml @@ -25,4 +25,13 @@ logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE - org.springframework.jdbc.core: DEBUG \ No newline at end of file + org.springframework.jdbc.core: DEBUG + +cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: ap-northeast-2 # 서울 리전 + s3: + bucket: ${S3_BUCKET} \ No newline at end of file