From c3bbe174570d6bd7ecdeef4bcf955a7ea1256309 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 24 May 2025 19:23:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20:=20=EB=8B=AC=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/HistoryImageRepository.java | 7 +++++++ .../history/domain/repository/HistoryRepository.java | 9 +++++++++ .../goorm/global/error/code/status/ErrorStatus.java | 6 +++++- .../goorm/global/error/code/status/SuccessStatus.java | 5 ++++- 4 files changed, 25 insertions(+), 2 deletions(-) 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..93c8a00 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,14 @@ 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); } 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..147a8dc 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,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.History; +import study.goorm.domain.member.domain.entity.Member; + +import java.util.List; +import java.util.Optional; public interface HistoryRepository extends JpaRepository{ + + @Query("SELECT h FROM History h WHERE h.member.id = :memberId AND FUNCTION('DATE_FORMAT', h.historyDate, '%Y-%m') = :month ORDER BY h.historyDate ASC") + List findMonthHistory(@Param("memberId") Long memberId, @Param("month") String month); } 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..35fdf7b 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,7 +27,11 @@ 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 + NO_SUCH_CLOKEY(HttpStatus.BAD_REQUEST, "HISTORY_4001", "존재하지 않는 clokeyId 입니다."), + WRONG_DATE_FORM(HttpStatus.BAD_REQUEST, "HISTORY_4002", "날짜 형태는 YYYY-MM 이어야 합니다."); private final HttpStatus httpStatus; 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..b137751 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,10 @@ 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", "월별 기록이 성공적으로 조회되었습니다."); private final HttpStatus httpStatus; private final String code; From 2b67eadf2679ff704ea93c8662929c271b9cfb15 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 24 May 2025 19:23:39 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20=EB=8B=AC=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../history/api/HistoryRestController.java | 41 +++++++++++++++ .../history/application/HistoryService.java | 8 +++ .../application/HistoryServiceImpl.java | 38 ++++++++++++++ .../history/converter/HistoryConverter.java | 50 +++++++++++++++++++ .../domain/repository/HistoryRepository.java | 5 -- .../domain/history/dto/HistoryRequestDTO.java | 4 ++ .../history/dto/HistoryResponseDTO.java | 33 ++++++++++++ 7 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java 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..9aa5b99 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java @@ -0,0 +1,41 @@ +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.web.bind.annotation.*; +import study.goorm.domain.history.application.HistoryService; +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); + } + + +} 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..fca87ca --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java @@ -0,0 +1,8 @@ +package study.goorm.domain.history.application; + +import study.goorm.domain.history.dto.HistoryResponseDTO; + +public interface HistoryService { + + HistoryResponseDTO.HistoryMonthResult getMonthlyHistories(String clokeyId, String month); +} 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..0d95e12 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -0,0 +1,38 @@ +package study.goorm.domain.history.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import study.goorm.domain.history.converter.HistoryConverter; +import study.goorm.domain.cloth.exception.ClothException; +import study.goorm.domain.history.domain.entity.HistoryImage; +import study.goorm.domain.history.domain.repository.HistoryImageRepository; +import study.goorm.domain.history.domain.repository.HistoryRepository; +import study.goorm.domain.history.dto.HistoryResponseDTO; +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 java.util.List; + +@Service +@RequiredArgsConstructor +public class HistoryServiceImpl implements HistoryService{ + + private final HistoryImageRepository historyImageRepository; + private final MemberRepository memberRepository; + + @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); + } +} \ 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..a9c8d9c --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java @@ -0,0 +1,50 @@ +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(); + } +} \ No newline at end of file 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 147a8dc..cca3491 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 @@ -4,13 +4,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import study.goorm.domain.history.domain.entity.History; -import study.goorm.domain.member.domain.entity.Member; import java.util.List; -import java.util.Optional; public interface HistoryRepository extends JpaRepository{ - - @Query("SELECT h FROM History h WHERE h.member.id = :memberId AND FUNCTION('DATE_FORMAT', h.historyDate, '%Y-%m') = :month ORDER BY h.historyDate ASC") - List findMonthHistory(@Param("memberId") Long memberId, @Param("month") String month); } 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..87594c7 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -0,0 +1,4 @@ +package study.goorm.domain.history.dto; + +public class HistoryRequestDTO { +} 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..b76b9ca --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java @@ -0,0 +1,33 @@ +package study.goorm.domain.history.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import study.goorm.domain.history.domain.entity.History; +import study.goorm.domain.history.domain.entity.HistoryImage; + +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; + } +} From 3294d05943aa98b704a5dc6df885a9f8d63028d2 Mon Sep 17 00:00:00 2001 From: "." Date: Sat, 24 May 2025 20:27:00 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat=20:=20=EC=9D=BC=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../history/api/HistoryRestController.java | 12 ++++ .../history/application/HistoryService.java | 3 + .../application/HistoryServiceImpl.java | 68 +++++++++++++++++++ .../history/converter/HistoryConverter.java | 27 ++++++++ .../repository/HashtagHistoryRepository.java | 7 ++ .../repository/HistoryClothRepository.java | 7 ++ .../repository/HistoryImageRepository.java | 2 + .../domain/repository/HistoryRepository.java | 18 ++++- .../domain/history/dto/HistoryRequestDTO.java | 22 ++++++ .../history/dto/HistoryResponseDTO.java | 31 +++++++++ .../global/error/code/status/ErrorStatus.java | 3 +- .../error/code/status/SuccessStatus.java | 3 +- 12 files changed, 200 insertions(+), 3 deletions(-) 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 index 9aa5b99..66dd1f6 100644 --- a/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java +++ b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import study.goorm.domain.cloth.dto.ClothResponseDTO; import study.goorm.domain.history.application.HistoryService; import study.goorm.domain.history.dto.HistoryResponseDTO; import study.goorm.global.common.response.BaseResponse; @@ -37,5 +38,16 @@ public BaseResponse getMonthlyHistories( 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_200", description = "OK, 성공적으로 조회되었습니다."), + }) + public BaseResponse getDailyHistory( + @PathVariable(name = "historyId") Long historyId + ) { + HistoryResponseDTO.HistoryDayResult result = historyService.getDailyHistory(historyId); + return BaseResponse.onSuccess(SuccessStatus.HISTORY_DAY, result); + } } 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 index fca87ca..d96789d 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java @@ -5,4 +5,7 @@ public interface HistoryService { HistoryResponseDTO.HistoryMonthResult getMonthlyHistories(String clokeyId, String month); + + HistoryResponseDTO.HistoryDayResult getDailyHistory(Long historyId); + } 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 index 0d95e12..5a22bff 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -5,7 +5,10 @@ import org.springframework.transaction.annotation.Transactional; import study.goorm.domain.history.converter.HistoryConverter; import study.goorm.domain.cloth.exception.ClothException; +import study.goorm.domain.history.domain.entity.History; import study.goorm.domain.history.domain.entity.HistoryImage; +import study.goorm.domain.history.domain.repository.HashtagHistoryRepository; +import study.goorm.domain.history.domain.repository.HistoryClothRepository; import study.goorm.domain.history.domain.repository.HistoryImageRepository; import study.goorm.domain.history.domain.repository.HistoryRepository; import study.goorm.domain.history.dto.HistoryResponseDTO; @@ -14,12 +17,16 @@ import study.goorm.global.error.code.status.ErrorStatus; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor 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; @Override @@ -35,4 +42,65 @@ public HistoryResponseDTO.HistoryMonthResult getMonthlyHistories(String clokeyId // 모든 변환 로직을 컨버터의 단일 메소드에 위임합니다. 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 + ); + } } \ 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 index a9c8d9c..2d4e429 100644 --- a/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java +++ b/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java @@ -47,4 +47,31 @@ public static HistoryResponseDTO.HistoryMonthResult toHistoryMonthResult(Member .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/repository/HashtagHistoryRepository.java b/goorm/src/main/java/study/goorm/domain/history/domain/repository/HashtagHistoryRepository.java index a2ae404..581e68e 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,14 @@ 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 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); } 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..b33b2db 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,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.cloth.domain.entity.Cloth; 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); } 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 93c8a00..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 @@ -11,4 +11,6 @@ public interface HistoryImageRepository extends JpaRepository 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 cca3491..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 @@ -5,7 +5,23 @@ import org.springframework.data.repository.query.Param; import study.goorm.domain.history.domain.entity.History; -import java.util.List; +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/dto/HistoryRequestDTO.java b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java index 87594c7..8c94561 100644 --- a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -1,4 +1,26 @@ package study.goorm.domain.history.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +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 형태) + } } 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 index b76b9ca..c17e2f5 100644 --- a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java @@ -30,4 +30,35 @@ public static class HistoryImageDTO { 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/global/error/code/status/ErrorStatus.java b/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java index 35fdf7b..6449b9d 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 @@ -31,7 +31,8 @@ public enum ErrorStatus implements BaseErrorCode { // history NO_SUCH_CLOKEY(HttpStatus.BAD_REQUEST, "HISTORY_4001", "존재하지 않는 clokeyId 입니다."), - WRONG_DATE_FORM(HttpStatus.BAD_REQUEST, "HISTORY_4002", "날짜 형태는 YYYY-MM 이어야 합니다."); + WRONG_DATE_FORM(HttpStatus.BAD_REQUEST, "HISTORY_4002", "날짜 형태는 YYYY-MM 이어야 합니다."), + HISTORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "HISTORY_4003", "존재하지 않는 HistoryId 입니다."); private final HttpStatus httpStatus; 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 b137751..e658b6e 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 @@ -17,7 +17,8 @@ public enum SuccessStatus implements BaseCode { CLOTH_DELETED(HttpStatus.NO_CONTENT,"CLOTH_202","옷이 성공적으로 삭제되었습니다"), //history - HISTORY_MONTH(HttpStatus.OK, "HISTORY_200", "월별 기록이 성공적으로 조회되었습니다."); + HISTORY_MONTH(HttpStatus.OK, "HISTORY_200", "월별 기록이 성공적으로 조회되었습니다."), + HISTORY_DAY(HttpStatus.OK, "HISTORY_201", "일별 기록이 성공적으로 조회되었습니다."); private final HttpStatus httpStatus; private final String code; From c736c94aa466cad4a52afefdfd0ffbfe5e7093c7 Mon Sep 17 00:00:00 2001 From: "." Date: Sun, 25 May 2025 14:53:35 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat=20:=20=EC=B6=94=EA=B0=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=82=AD=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goorm/build.gradle | 2 + .../domain/cloth/domain/entity/Cloth.java | 10 + .../domain/repository/ClothRepository.java | 13 + .../history/api/HistoryRestController.java | 54 +++- .../history/application/HistoryService.java | 8 + .../application/HistoryServiceImpl.java | 259 +++++++++++++++++- .../domain/history/domain/entity/History.java | 2 +- .../domain/repository/CommentRepository.java | 7 + .../repository/HashtagHistoryRepository.java | 3 + .../domain/repository/HashtagRepository.java | 4 + .../repository/HistoryClothRepository.java | 5 + .../repository/MemberLikeRepository.java | 7 + .../domain/history/dto/HistoryRequestDTO.java | 16 +- .../global/error/code/status/ErrorStatus.java | 7 +- .../error/code/status/SuccessStatus.java | 4 +- goorm/src/main/resources/application.yml | 11 +- 16 files changed, 394 insertions(+), 18 deletions(-) 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 index 66dd1f6..e508ba7 100644 --- a/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java +++ b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java @@ -5,9 +5,11 @@ 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 study.goorm.domain.cloth.dto.ClothResponseDTO; +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; @@ -41,13 +43,59 @@ public BaseResponse getMonthlyHistories( @GetMapping("/{historyId}") @Operation(summary = "특정 History에 대한 정보를 조회하는 API", description = "Path Variable로 historyId를 던져주세요.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "OK, 성공적으로 조회되었습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_201", description = "OK, 성공적으로 조회되었습니다."), }) public BaseResponse getDailyHistory( - @PathVariable(name = "historyId") Long historyId + @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 index d96789d..f7bcb4f 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java @@ -1,5 +1,7 @@ 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 { @@ -8,4 +10,10 @@ public interface HistoryService { 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 index 5a22bff..a30879f 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -1,26 +1,32 @@ 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.History; -import study.goorm.domain.history.domain.entity.HistoryImage; -import study.goorm.domain.history.domain.repository.HashtagHistoryRepository; -import study.goorm.domain.history.domain.repository.HistoryClothRepository; -import study.goorm.domain.history.domain.repository.HistoryImageRepository; -import study.goorm.domain.history.domain.repository.HistoryRepository; +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.io.IOException; +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; @@ -28,6 +34,11 @@ public class HistoryServiceImpl implements HistoryService{ 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) @@ -103,4 +114,240 @@ public HistoryResponseDTO.HistoryDayResult getDailyHistory(Long historyId) { 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/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 581e68e..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 @@ -4,6 +4,7 @@ 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; @@ -11,4 +12,6 @@ public interface HashtagHistoryRepository extends JpaRepository 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 b33b2db..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 @@ -4,6 +4,7 @@ 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; @@ -14,4 +15,8 @@ public interface HistoryClothRepository extends JpaRepository 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/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 index 8c94561..9a3c25c 100644 --- a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -1,11 +1,9 @@ package study.goorm.domain.history.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import org.springframework.format.annotation.DateTimeFormat; +import java.beans.Visibility; import java.time.LocalDate; import java.util.List; @@ -23,4 +21,14 @@ public static class HistoryCreateDTO { @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/global/error/code/status/ErrorStatus.java b/goorm/src/main/java/study/goorm/global/error/code/status/ErrorStatus.java index 6449b9d..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 @@ -30,10 +30,13 @@ public enum ErrorStatus implements BaseErrorCode { NO_SUCH_CATEGORY(HttpStatus.BAD_REQUEST, "CLOTH_4003", "카테고리가 존재하지 않습니다."), // history - NO_SUCH_CLOKEY(HttpStatus.BAD_REQUEST, "HISTORY_4001", "존재하지 않는 clokeyId 입니다."), WRONG_DATE_FORM(HttpStatus.BAD_REQUEST, "HISTORY_4002", "날짜 형태는 YYYY-MM 이어야 합니다."), - HISTORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "HISTORY_4003", "존재하지 않는 HistoryId 입니다."); + 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 e658b6e..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 @@ -18,7 +18,9 @@ public enum SuccessStatus implements BaseCode { //history HISTORY_MONTH(HttpStatus.OK, "HISTORY_200", "월별 기록이 성공적으로 조회되었습니다."), - HISTORY_DAY(HttpStatus.OK, "HISTORY_201", "일별 기록이 성공적으로 조회되었습니다."); + 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 From 8dd1c32715730c399fd2244c8bd99f15c97d843c Mon Sep 17 00:00:00 2001 From: "." Date: Sun, 25 May 2025 14:54:36 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat=20:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=EB=AC=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/HistoryServiceImpl.java | 1 - .../domain/history/application/S3Service.java | 12 ++ .../history/application/S3ServiceImpl.java | 106 ++++++++++++++++++ .../domain/history/dto/HistoryRequestDTO.java | 1 - .../history/dto/HistoryResponseDTO.java | 2 - .../history/exception/HistoryException.java | 11 ++ .../study/goorm/global/config/AwsConfig.java | 31 +++++ 7 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 goorm/src/main/java/study/goorm/domain/history/application/S3Service.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java create mode 100644 goorm/src/main/java/study/goorm/domain/history/exception/HistoryException.java create mode 100644 goorm/src/main/java/study/goorm/global/config/AwsConfig.java 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 index a30879f..803f410 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -19,7 +19,6 @@ import study.goorm.global.error.code.status.ErrorStatus; import study.goorm.global.exception.GeneralException; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; 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..f285a84 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java @@ -0,0 +1,12 @@ +package study.goorm.domain.history.application; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface S3Service { + + public 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..5a35e2b --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java @@ -0,0 +1,106 @@ +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.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class S3ServiceImpl implements S3Service{ + + private final AmazonS3 amazonS3; // AmazonS3 클라이언트 주입 + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + /** + * MultipartFile을 S3에 업로드하고, 저장된 파일의 URL을 반환합니다. + * @param file 업로드할 MultipartFile + * @return 업로드된 파일의 S3 URL + * @throws IOException 파일 처리 중 발생할 수 있는 예외 + */ + 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().toString() + 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), "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/dto/HistoryRequestDTO.java b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java index 9a3c25c..4561b42 100644 --- a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -3,7 +3,6 @@ import lombok.*; import org.springframework.format.annotation.DateTimeFormat; -import java.beans.Visibility; import java.time.LocalDate; import java.util.List; 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 index c17e2f5..65ce35f 100644 --- a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java @@ -4,8 +4,6 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import study.goorm.domain.history.domain.entity.History; -import study.goorm.domain.history.domain.entity.HistoryImage; import java.util.List; 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 From 61626bf5787c248be234f62eff1bcbbf974726a1 Mon Sep 17 00:00:00 2001 From: "." Date: Sun, 25 May 2025 14:57:01 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat=20:=20=EA=B2=BD=EA=B3=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../goorm/domain/history/application/S3Service.java | 4 +--- .../domain/history/application/S3ServiceImpl.java | 11 +++-------- 2 files changed, 4 insertions(+), 11 deletions(-) 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 index f285a84..1a5eb2a 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3Service.java @@ -2,11 +2,9 @@ import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; - public interface S3Service { - public String uploadFile(MultipartFile file); + 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 index 5a35e2b..b547af6 100644 --- a/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java +++ b/goorm/src/main/java/study/goorm/domain/history/application/S3ServiceImpl.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.io.InputStream; import java.net.URLDecoder; // URL 디코딩을 위해 임포트 +import java.nio.charset.StandardCharsets; import java.util.UUID; @Service @@ -28,19 +29,13 @@ public class S3ServiceImpl implements S3Service{ @Value("${cloud.aws.s3.bucket}") private String bucketName; - /** - * MultipartFile을 S3에 업로드하고, 저장된 파일의 URL을 반환합니다. - * @param file 업로드할 MultipartFile - * @return 업로드된 파일의 S3 URL - * @throws IOException 파일 처리 중 발생할 수 있는 예외 - */ 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().toString() + extension; // S3 객체 키 (고유한 파일 이름) + String objectKey = UUID.randomUUID() + extension; // S3 객체 키 (고유한 파일 이름) // 메타데이터 설정 (파일 크기, 콘텐츠 타입 등) ObjectMetadata metadata = new ObjectMetadata(); @@ -77,7 +72,7 @@ public void deleteFile(String fileUrl) { String path = new java.net.URL(fileUrl).getPath(); // URL 디코딩이 필요할 수 있습니다 (파일 이름에 특수 문자 포함 시) // UTF-8로 디코딩, 프로젝트 인코딩에 따라 변경될 수 있음 - objectKey = URLDecoder.decode(path.substring(path.indexOf('/') + 1), "UTF-8"); + objectKey = URLDecoder.decode(path.substring(path.indexOf('/') + 1), StandardCharsets.UTF_8); // 객체 키가 버킷 이름으로 시작하는 경우 제거 (URL 형식에 따라 다름) // 예: /your-bucket/your-object-key.jpg -> your-object-key.jpg