diff --git a/.DS_Store b/.DS_Store index edd3b72..a299d22 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index c2065bc..b09f9b1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +goorm/src/main/resources/application-aws.yml + diff --git a/goorm/build.gradle b/goorm/build.gradle index 8a270d2..4ef554f 100644 --- a/goorm/build.gradle +++ b/goorm/build.gradle @@ -35,6 +35,7 @@ dependencies { // testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } 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..f83fb76 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,10 @@ public class Cloth extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; + + @Min(0) + private int wearCount; + public void decreaseWearCount() { + if (this.wearCount > 0) this.wearCount--; + } } 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 a0ead3a..21c1fb2 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 @@ -6,6 +6,8 @@ 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 오름차순 Page findByMemberOrderByWearNumAsc(Member member, Pageable pageable); @@ -18,4 +20,7 @@ public interface ClothRepository extends JpaRepository{ // 4. createdAt 내림차순 Page findByMemberOrderByCreatedAtDesc(Member member, Pageable pageable); + + List findByMemberId(Long memberId); + } 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..62b96b6 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/api/HistoryRestController.java @@ -0,0 +1,233 @@ +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import study.goorm.domain.cloth.dto.ClothResponseDTO; +import study.goorm.domain.history.application.HistoryService; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.domain.member.domain.entity.Member; +import study.goorm.domain.model.enums.ClothSort; +import study.goorm.domain.model.exception.annotation.CheckPage; +import study.goorm.domain.model.exception.annotation.CheckPageSize; +import study.goorm.global.common.response.BaseResponse; +import study.goorm.global.error.code.status.SuccessStatus; +import study.goorm.domain.history.application.HistoryService; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/histories") +@Validated +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 = "clokeyId", description = "클로키 유저의 clokey id, query string 입니다."), + @Parameter(name = "month", description = "월별 값, query string 입니다.") + }) + public BaseResponse getMonthlyHistories( + @RequestParam(value = "clokeyId") String clokeyId, + @RequestParam String month + ) { + HistoryResponseDTO.HistoryGetMonthly result = historyService.getHistoryGetMonthly(clokeyId, month); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_GET_MONTH, result); + } + + // 일별 기록 조회 + @GetMapping("/{historyId}") + @Operation(summary = "특정 회원의 특정 일의 기록을 확인할 수 있는 API", description = "path variable로 historyId를 넘겨주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "OK, 일별 기록이 성공적으로 조회되었습니다."), + }) + @Parameters({ + @Parameter(name = "historyId", description = "기록의 id, path variable 입니다.") + }) + public BaseResponse getDailyHistory( + @PathVariable Long historyId + ) { + HistoryResponseDTO.HistoryGetDaily result = historyService.getHistoryGetDaily(historyId); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_GET_MONTH, result); + } + + // 기록 삭제 + @DeleteMapping("/{historyId}") + @Operation(summary = "특정 기록을 삭제하는 API", description = "path variable로 historyId를 넘겨주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_202", description = "OK, 성공적으로 삭제되었습니다."), + }) + @Parameters({ + @Parameter(name = "historyId", description = "기록의 id, path variable 입니다.") + }) + public BaseResponse deleteHistory( + @PathVariable(value = "historyId") Long historyId + ) { + + historyService.deleteHistory(historyId); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_DELETED, null); + } + + // 옷 기록 추가 + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "새로운 옷 기록을 생성하는 API", description = "request body에 HistoryCreateRequest 형식의 데이터를 전달해주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_201", description = "CREATED, 성공적으로 생성되었습니다."), + }) + public BaseResponse createHistory( + @RequestPart("historyCreateRequest") HistoryRequestDTO.HistoryCreateRequest historyCreateRequest, + @RequestPart("imageFile") List imageFile + ) { + HistoryResponseDTO.HistoryCreateResult result = historyService.createHistory(historyCreateRequest,imageFile); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_CREATED, result); + } + + @PatchMapping(value = "/{historyId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "특정날짜 옷 기록을 수정하는 API", description = "path variable로 historyId를 넘겨주세요.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "OK, 기록이 성공적으로 수정되었습니다."), + }) + @Parameters({ + @Parameter(name = "historyId", description = "기록의 id, path variable 입니다."), + }) + public BaseResponse patchHistory( + @PathVariable Long historyId, + @RequestPart("historyUpdateRequest") @Valid HistoryRequestDTO.HistoryUpdateRequest historyUpdateRequest, + @RequestPart("imageFile") List imageFile + ) { + HistoryResponseDTO.HistoryUpdateResult result = historyService.updateHistory(historyUpdateRequest, imageFile, historyId); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_UPDATED, null); + } + + // 좋아요 기능 + @PostMapping("/like") + @Operation(summary = "특정 기록에 좋아요를 누를 수 있는 API") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "좋아요 상태가 성공적으로 변경되었습니다."), + }) + public BaseResponse like( + Member member, + @RequestBody @Valid HistoryRequestDTO.HistoryLike historyLike + ) { + //isLiked의 상태에 따라서 좋아요 -> 취소 , 좋아요가 없는 상태 -> 좋아요 로 바꿔주게 됩니다. + HistoryResponseDTO.HistoryLikeResult result = historyService.changeLikeStatus(member.getId(), historyLike.getHistoryId(), historyLike.isLiked()); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_LIKE_STATUS_CHANGED, result); + } + + // 좋아요 누른 유저 조회 + @GetMapping("/{historyId}/likes") + @Operation(summary = "특정 기록에 좋아요를 누른 유저의 정보를 확인합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "기록의 좋아요를 누른 유저 정보를 성공적으로 조회했습니다."), + }) + @Parameters({ + @Parameter(name = "historyId", description = "기록의 id, path variable 입니다.") + }) + public BaseResponse getLikedUsers( + Member member, + @PathVariable Long historyId + ) { + + HistoryResponseDTO.HistoryLikedUserResultList result = historyService.getLikedUsers(member.getId(), historyId); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_LIKE_USER, result); + } + + // 댓글 추가 + @PostMapping("/{historyId}/comments") + @Operation(summary = "댓글을 남길 수 있는 API") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_201", description = "성공적으로 댓글이 생성되었습니다."), + }) + @Parameters({ + @Parameter(name = "historyId", description = "댓글을 남기고자 하는 기록의 ID") + }) + public BaseResponse writeComments( + @PathVariable @Valid Long historyId, + @RequestBody @Valid HistoryRequestDTO.HistoryCommentWrite request, + Member member + ) { + return BaseResponse.onSuccess(SuccessStatus.HISTORY_COMMENT_CREATED, historyService.writeComment(historyId, request.getCommentId(), member.getId(), request.getContent())); + + } + + // 댓글 조회 + @GetMapping("/{historyId}/comments") + @Operation(summary = "특정 기록의 댓글을 읽어올 수 있는 API") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_200", description = "OK, 성공적으로 조회되었습니다.") + }) + @Parameters({ + @Parameter(name = "historyId", description = "기록의 id, path variable 입니다."), + @Parameter(name = "page", description = "페이징 관련 query parameter") + + }) + public BaseResponse getComments( + @PathVariable @Valid Long historyId, + @RequestParam(value = "page") @Valid @CheckPage int page + ) { + + HistoryResponseDTO.HistoryCommentResult result = historyService.getComments(historyId, page - 1); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_SUCCESS, result); + } + + @DeleteMapping(value = "/comments/{commentId}") + @Operation(summary = "댓글을 삭제하는 API") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_204", description = "댓글이 성공적으로 삭제되었습니다."), + }) + @Parameters({ + @Parameter(name = "commentId", description = "삭제하고자 하는 댓글의 ID") + }) + public BaseResponse deleteComment( + Member member, + @PathVariable Long commentId + ) { + + historyService.deleteComment(commentId, member.getId()); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_COMMENT_DELETED, null); + } + + @PatchMapping(value = "/comments/{commentId}") + @Operation(summary = "댓글을 수정하는 API") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "HISTORY_204", description = "댓글이 성공적으로 수정되었습니다."), + }) + @Parameters({ + @Parameter(name = "commentId", description = "수정하고자 하는 댓글의 ID") + }) + public BaseResponse updateComment( + @RequestBody @Valid HistoryRequestDTO.HistoryUpdateComment updateCommentRequest, + @PathVariable Long commentId, + Member member + ) { + + historyService.updateComment(updateCommentRequest, commentId, member.getId()); + + return BaseResponse.onSuccess(SuccessStatus.HISTORY_COMMENT_UPDATED, null); + } + +} diff --git a/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryService.java b/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryService.java new file mode 100644 index 0000000..e54a78e --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryService.java @@ -0,0 +1,10 @@ +package study.goorm.domain.history.application; + +import study.goorm.domain.cloth.domain.entity.Cloth; +import study.goorm.domain.history.domain.entity.History; + +import java.util.Map; + +public interface HistoryImageQueryService { + Map getFirstHistoryImageUrlMap(Iterable histories); +} diff --git a/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryServiceImpl.java b/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryServiceImpl.java new file mode 100644 index 0000000..4f13bf4 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryImageQueryServiceImpl.java @@ -0,0 +1,33 @@ +package study.goorm.domain.history.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import study.goorm.domain.history.domain.entity.History; +import study.goorm.domain.history.domain.entity.HistoryImage; +import study.goorm.domain.history.domain.repository.HistoryImageRepository; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +@Service +@RequiredArgsConstructor +public class HistoryImageQueryServiceImpl implements HistoryImageQueryService { + + private final HistoryImageRepository historyImageRepository; + + @Override + public Map getFirstHistoryImageUrlMap(Iterable histories) { + List historyIds = StreamSupport.stream(histories.spliterator(), false) + .map(History::getId) + .toList(); + + List firstHistoryImages = historyImageRepository.findAllById(historyIds); + return firstHistoryImages.stream() + .collect(Collectors.toMap( + image -> image.getHistory().getId(), + HistoryImage::getImageUrl + )); + } +} 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..360867a --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryService.java @@ -0,0 +1,24 @@ +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; +import study.goorm.domain.member.domain.entity.Member; + +import java.time.LocalDate; +import java.util.List; + +public interface HistoryService { + HistoryResponseDTO.HistoryGetMonthly getHistoryGetMonthly(String clokeyId, String month); + HistoryResponseDTO.HistoryGetDaily getHistoryGetDaily(Long historyId); + HistoryResponseDTO.HistoryCreateResult createHistory(HistoryRequestDTO.HistoryCreateRequest historyCreateResult, List image); + HistoryResponseDTO.HistoryUpdateResult updateHistory(HistoryRequestDTO.HistoryUpdateRequest historyUpdateRequest, List imageFile, Long historyId); + void deleteHistory(Long historyId); + + HistoryResponseDTO.HistoryLikeResult changeLikeStatus(Long memberId, Long historyId, boolean isLiked); + HistoryResponseDTO.HistoryLikedUserResultList getLikedUsers(Long memberId, Long historyId); + HistoryResponseDTO.HistoryCommentWriteResult writeComment(Long historyId, Long commentId, Long memberId, String content); + HistoryResponseDTO.HistoryCommentResult getComments(Long historyId, int page); + void deleteComment(Long commentId, Long memberId); + void updateComment(HistoryRequestDTO.HistoryUpdateComment updateCommentRequest, Long commentId, Long memberId); +} 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..0cf9ce1 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/application/HistoryServiceImpl.java @@ -0,0 +1,449 @@ +package study.goorm.domain.history.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.cloth.exception.ClothException; +import study.goorm.domain.history.converter.HistoryConverter; +import study.goorm.domain.history.domain.entity.*; +import study.goorm.domain.history.domain.repository.*; +import study.goorm.domain.history.dto.HistoryCommentParamDTO; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.domain.history.exception.HistoryExeption; +import study.goorm.domain.member.domain.dto.LikedMemberDTO; +import study.goorm.domain.member.domain.entity.Member; +import study.goorm.domain.member.domain.exception.MemberException; +import study.goorm.domain.member.domain.repository.MemberRepository; +import study.goorm.global.error.code.status.ErrorStatus; +import study.goorm.storage.S3UploadService; + +import java.io.File; +import java.io.IOException; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class HistoryServiceImpl implements HistoryService { + + private final HistoryRepository historyRepository; + private final HistoryImageQueryService historyImageQueryService; + private final HistoryImageRepository historyImageRepository; + private final MemberRepository memberRepository; + private final HashtagHistoryRepository hashtagHistoryRepository; + private final HistoryClothRepository historyClothRepository; + private final ClothRepository clothRepository; + private final HashtagRepository hashtagRepository; + private final CommentRepository commentRepository; + private final MemberLikeRepository memberLikeRepository; + private final S3UploadService s3UploadService; + + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryGetMonthly getHistoryGetMonthly(String clokeyId, String month) { + // 사용자 정보 => 변경 사항 + Member member; + // clokeyId가 null인 경우 + if (clokeyId == null) { + member = memberRepository.findByClokeyId("1") + .orElseThrow(() -> new MemberException(ErrorStatus.NO_SUCH_MEMBER)); + } else { // clokeyId가 있는 경우 + member = memberRepository.findByClokeyId(clokeyId) + .orElseThrow(() -> new MemberException(ErrorStatus.NO_SUCH_MEMBER)); + } + + // Month 형식 검사 + try { + YearMonth.parse(month); + } catch (DateTimeException e) { + throw new HistoryExeption(ErrorStatus.INVALID_DATE_FORMAT); + } + + // 기록 조회 + List histories = historyRepository.findHistoriesByMemberIdAndYearMonth(member.getId(), month); + + List historyIds = histories.stream() + .map(History::getId) +// .toList(); + .collect(Collectors.toList()); // => toList와의 차이점은? + + // 사진 조회 + List historyImages = historyImageRepository.findAllByHistoryIdIn(historyIds); + + // 첫번째 이미지 + Map firstImagesOfHistory = historyImages.stream() + .collect(Collectors.groupingBy( + img -> img.getHistory().getId(), + Collectors.mapping(HistoryImage::getImageUrl, Collectors.collectingAndThen( + Collectors.toList(), + list -> list.isEmpty() ? "null" : list.get(0) + )) + )); + + List resultList = histories.stream() + .map(history -> HistoryResponseDTO.HistoryGetMonthlyResult.builder() + .historyId(history.getId()) + .date(LocalDate.parse(history.getHistoryDate().toString())) + .imageUrl(firstImagesOfHistory.getOrDefault(history.getId(), "비공개입니다")) + .build()) + .collect(Collectors.toList()); + + return HistoryConverter.toHistoryGetMonthly(member, resultList); + } + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryGetDaily getHistoryGetDaily(Long historyId) { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_HISTORY)); + + Member member = history.getMember(); + + // 이미지 조회 + List historyImages = historyImageRepository.findAllByHistory(history); + List images = historyImages.stream() + .map(HistoryImage::getImageUrl) + .toList(); + + // 해시태그 조회 + List hashtagHistories = hashtagHistoryRepository.findByHistoryId(historyId); + + //=> fetch join or jpql로 바로 가져오기 + // 기록의 해시태그 조회 + List hashtags = hashtagHistories.stream() + .map(HashtagHistory::getHashtag) + .toList(); + List hashTagName = hashtags.stream() + .map(Hashtag::getName) + .toList(); + + List cloths = clothRepository.findByMemberId(member.getId()); + + List clothList = cloths.stream() + .map(cloth -> HistoryResponseDTO.HistoryGetDailyCloth.builder() + .clothId(cloth.getId()) + .clothImageUrl(cloth.getClothUrl()) // 이미지 필드 맞게 수정 + .clothName(cloth.getName()) + .build()) + .toList(); + + return HistoryConverter.toHistoryGetDaily(history, images, member, hashTagName, clothList); + } + + @Override + @Transactional + public void deleteHistory(Long historyId) { + // History 조회 + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_HISTORY)); + + // 댓글 전부 삭제 + commentRepository.deleteByHistory(history); + + // 좋아요 전부 삭제 + memberLikeRepository.deleteByHistory(history); + + // 옷 착용 횟수 감소 + List historyClothes = historyClothRepository.findByHistory(history); + for (HistoryCloth hc : historyClothes) { + Cloth cloth = hc.getCloth(); + cloth.decreaseWearCount(); + } + + // HashtagHistory 삭제 + hashtagHistoryRepository.deleteByHistory(history); + + // 이미지 삭제 + historyImageRepository.deleteAllByHistory(history); + + // History-Cloth 매핑 row 삭제 + historyClothRepository.deleteByHistory(history); + + // 최종 History 삭제 + historyRepository.delete(history); + } + + @Override + @Transactional + public HistoryResponseDTO.HistoryCreateResult createHistory(HistoryRequestDTO.HistoryCreateRequest historyCreateResult, List image) { + // 이미지 업로드 개수 제한 + if (image.size() >= 10) { + throw new HistoryExeption(ErrorStatus.TOO_MANY_IMAGES); + } + + // 이미 그 날짜에 history 검증. + + // member 1번이 로그인 한 유저라고 가정 + Member member = memberRepository.findById(1L) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_MEMBER)); + + // history 테이블에 내용, 날짜 저장 + History history = History.builder() + .member(member) + .content(historyCreateResult.getContent()) + .historyDate(historyCreateResult.getDate()) + .build(); + + historyRepository.save(history); + + // clothId 검증 + List requestedIds = historyCreateResult.getClothes(); + + for (Long id : requestedIds) { + boolean exists = clothRepository.existsById(id); + if (!exists) { + throw new HistoryExeption(ErrorStatus.NO_SUCH_CLOTH); + } + } + + // ClothId 기록 + List clothes = clothRepository.findAllById(requestedIds); + + List historyClothes = clothes.stream() + .map(cloth -> HistoryCloth.builder() + .history(history) + .cloth(cloth) + .build()) + .toList(); + + historyClothRepository.saveAll(historyClothes); + + List requestedTags = historyCreateResult.getHashtags(); + + // DB에 존재하는 해시태그 조회 + List existingHashtags = hashtagRepository.findAllByNameIn(requestedTags); + Set existingTagNames = existingHashtags.stream() + .map(Hashtag::getName) + .collect(Collectors.toSet()); + + // 없는 해시태그 추출 + List newHashtags = requestedTags.stream() + .filter(tag -> !existingTagNames.contains(tag)) + .map(tag -> Hashtag.builder().name(tag).build()) + .toList(); + + // 새 해시태그 저장 + hashtagRepository.saveAll(newHashtags); + + // 기존 + 신규 해시태그 합치기 + List allHashtags = new ArrayList<>(); + allHashtags.addAll(existingHashtags); + allHashtags.addAll(newHashtags); + + // HashtagHistory 저장 + List hashtagHistories = allHashtags.stream() + .map(tag -> HashtagHistory.builder() + .hashtag(tag) + .history(history) + .build()) + .toList(); + hashtagHistoryRepository.saveAll(hashtagHistories); + + List historyImages = new ArrayList<>(); + + for (MultipartFile file : image) { + if (file.isEmpty()) continue; + + try { + String imageUrl = s3UploadService.saveFile(file); // S3에 업로드 + HistoryImage historyImage = HistoryImage.builder() + .history(history) + .imageUrl(imageUrl) + .build(); + historyImages.add(historyImage); + } catch (IOException e) { + throw new HistoryExeption(ErrorStatus.S3_IMAGE_UPLOAD_FAIL); + } + } + + historyImageRepository.saveAll(historyImages); + + return HistoryConverter.toHistoryCreateResult(history); + } + + @Override + @Transactional + public HistoryResponseDTO.HistoryUpdateResult updateHistory(HistoryRequestDTO.HistoryUpdateRequest historyUpdateRequest, List images, Long historyId) { + if (images.size() >= 10) { + throw new HistoryExeption(ErrorStatus.TOO_MANY_IMAGES); + } + + Member member = memberRepository.findById(1L) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_MEMBER)); + + // 기존 히스토리 조회 + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_HISTORY)); + + // content 수정 + history.update(historyUpdateRequest.getContent()); + + // 기존 clothes & hashtag 관계 삭제 + historyClothRepository.deleteByHistory(history); // custom deleteByHistory + hashtagHistoryRepository.deleteByHistory(history); // custom deleteByHistory + + // 새로운 clothes 저장 + List clothIds = historyUpdateRequest.getClothes(); + List clothes = clothRepository.findAllById(clothIds); + + List historyClothes = clothes.stream() + .map(cloth -> HistoryCloth.builder() + .history(history) + .cloth(cloth) + .build()) + .toList(); + historyClothRepository.saveAll(historyClothes); + + // 새로운 해시태그 저장 (새로운 해시태그 생성 포함) + List tagNames = historyUpdateRequest.getHashtags(); + + List existingTags = hashtagRepository.findAllByNameIn(tagNames); + Set existingTagNames = existingTags.stream() + .map(Hashtag::getName) + .collect(Collectors.toSet()); + + List newTags = tagNames.stream() + .filter(name -> !existingTagNames.contains(name)) + .map(name -> Hashtag.builder().name(name).build()) + .toList(); + + hashtagRepository.saveAll(newTags); + + List allTags = new ArrayList<>(); + allTags.addAll(existingTags); + allTags.addAll(newTags); + + List hashtagHistories = allTags.stream() + .map(tag -> HashtagHistory.builder() + .hashtag(tag) + .history(history) + .build()) + .toList(); + + hashtagHistoryRepository.saveAll(hashtagHistories); + + // DB에서 HistoryImage 삭제 + historyImageRepository.deleteAllByHistory(history); + + // S3 업로드 로직 + List newHistoryImages = new ArrayList<>(); + + for (MultipartFile file : images) { + if (file.isEmpty()) continue; + try { + String imageUrl = s3UploadService.saveFile(file); // Upload to S3 + HistoryImage historyImage = HistoryImage.builder() + .history(history) + .imageUrl(imageUrl) + .build(); + newHistoryImages.add(historyImage); + } catch (IOException e) { + throw new HistoryExeption(ErrorStatus.S3_IMAGE_UPLOAD_FAIL); + } + } + + historyImageRepository.saveAll(newHistoryImages); + + return HistoryConverter.toHistoryUpdateResult(history); + } + + // 좋아요 기능 + @Override + @Transactional + public HistoryResponseDTO.HistoryLikeResult changeLikeStatus(Long memberId, Long historyId, boolean isLiked) { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_HISTORY)); + + // 게시물에 좋아요한 상태일 경우 + if (isLiked) { + history.increaseLikes(); + // 해당 게시물 좋아요를 통해 멤버아이디와 기록아이디 등록 + MemberLike memberLike = MemberLike.builder() + .history(history) + .member(memberRepository.findMemberById(memberId)) + .build(); + memberLikeRepository.save(memberLike); + } else { // 게시물에 좋아요한 상태가 아닐 경우 + history.decreaseLikes(); + // 해당 게시물 좋아요 취소를 통해 멤버아이디와 기록아이디 삭제 + memberLikeRepository.deleteByMemberIdAndHistoryId(memberId, historyId); + } + + return HistoryConverter.toHistoryLikeResult(history, isLiked); + } + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryLikedUserResultList getLikedUsers( + Long loginMemberId, Long historyId + ) { + List likedMembers = + memberRepository.findLikedMembersWithFollowInfo(historyId, loginMemberId); + + return HistoryConverter.toLikedUserResult(likedMembers); + } + + @Override + @Transactional + public HistoryResponseDTO.HistoryCommentWriteResult writeComment(Long historyId, Long commentId, Long memberId, String content) { + History history = historyRepository.findById(historyId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_HISTORY)); + + Member member = memberRepository.findMemberById(memberId); + + Comment parentComment = null; + + if (commentId != null) { + parentComment = commentRepository.findById(commentId) + .orElseThrow(() -> new HistoryExeption(ErrorStatus.NO_SUCH_COMMENT)); + ; + } + + Comment comment = Comment.builder() + .content(content) + .comment(parentComment) + .history(history) + .member(member) + .build(); + + Comment savedComment = commentRepository.save(comment); + + return HistoryConverter.toCommentWriteResult(savedComment); + } + + @Override + @Transactional(readOnly = true) + public HistoryResponseDTO.HistoryCommentResult getComments(Long historyId, int page) { + // 한 페이지당 10개씩 + Pageable pageable = PageRequest.of(page, 10); + List commentsDTO = + historyRepository.findFlatCommentsByHistoryId(historyId, pageable); + + int totalRootCount = commentRepository.countActiveRootComments(historyId); + return HistoryConverter.toHistoryCommentResult(commentsDTO, page, 10, totalRootCount); + } + + @Override + @Transactional + public void deleteComment(Long commentId, Long memberId) { + commentRepository.deleteChildrenComment(commentId); + commentRepository.deleteById(commentId); + } + + @Override + @Transactional + public void updateComment(HistoryRequestDTO.HistoryUpdateComment updateCommentRequest, Long commentId, Long memberId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(()-> new HistoryExeption(ErrorStatus.NO_SUCH_COMMENT)); + comment.updateContent(updateCommentRequest.getContent()); + } +} 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..b6f562a --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/converter/HistoryConverter.java @@ -0,0 +1,139 @@ +package study.goorm.domain.history.converter; + +import study.goorm.domain.history.domain.entity.Comment; +import study.goorm.domain.history.domain.entity.History; +import study.goorm.domain.history.domain.entity.HistoryImage; +import study.goorm.domain.history.dto.HistoryCommentParamDTO; +import study.goorm.domain.history.dto.HistoryRequestDTO; +import study.goorm.domain.history.dto.HistoryResponseDTO; +import study.goorm.domain.member.domain.dto.LikedMemberDTO; +import study.goorm.domain.member.domain.entity.Member; +import study.goorm.domain.cloth.domain.entity.Cloth; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class HistoryConverter { + + public static HistoryResponseDTO.HistoryGetMonthly toHistoryGetMonthly(Member member, List result) { + + return HistoryResponseDTO.HistoryGetMonthly.builder() + .memberId(member.getId()) + .nickName(member.getNickname()) + .histories(result) + .build(); + } + + public static HistoryResponseDTO.HistoryGetDaily toHistoryGetDaily(History history, List images, Member member, List hashtags, List cloths) { + return HistoryResponseDTO.HistoryGetDaily.builder() + .memberId(member.getId()) + .historyId(history.getId()) + .memberImageUrl(member.getProfileImageUrl()) + .nickName(member.getNickname()) + .clokeyId(member.getClokeyId()) + .content(history.getContent()) + .imageUrl(images) + .hashtags(hashtags) // 해시태그는 외부에서 받아서 설정 + .likeCount(history.getLikes()) // 기본값 또는 실제 값 추가 필요 + .commentCount(history.getComments()) // 기본값 또는 실제 값 추가 필요 + .date(history.getHistoryDate()) + .cloths(cloths) // 실제 의류 정보 변환 + .liked(false) // 실제 좋아요 여부는 사용자 컨텍스트 필요 + .build(); + } + + public static HistoryResponseDTO.HistoryCreateResult toHistoryCreateResult(History history) { + return HistoryResponseDTO.HistoryCreateResult.builder() + .historyId(history.getId()) + .build(); + } + + public static HistoryResponseDTO.HistoryUpdateResult toHistoryUpdateResult(History history) { + return HistoryResponseDTO.HistoryUpdateResult.builder() + .historyId(history.getId()) + .build(); + } + + public static HistoryResponseDTO.HistoryLikeResult toHistoryLikeResult(History history, boolean isLiked) { + return HistoryResponseDTO.HistoryLikeResult.builder() + .historyId(history.getId()) + .liked(isLiked) + .likeCount(history.getLikes()) + .build(); + } + + public static HistoryResponseDTO.HistoryLikedUserResultList toLikedUserResult(List likedMembers) { + List likedUserResults = new ArrayList<>(); + for (int i = 0; i < likedMembers.size(); i++) { + LikedMemberDTO member = likedMembers.get(i); + likedUserResults.add(HistoryResponseDTO.HistoryLikedUserResult.builder() + .clokeyId(member.getClokeyId()) + .imageUrl(member.getImageUrl()) + .followStatus(member.getIsFollowed()) + .memberId(member.getMemberId()) + .nickname(member.getNickname()) + .isMe(member.getIsMe()) + .build()); + } + return HistoryResponseDTO.HistoryLikedUserResultList.builder() + .likedUsers(likedUserResults) + .build(); + } + + public static HistoryResponseDTO.HistoryCommentWriteResult toCommentWriteResult(Comment comment) { + return HistoryResponseDTO.HistoryCommentWriteResult.builder() + .commentId(comment.getId()) + .build(); + } + + public static HistoryResponseDTO.HistoryCommentResult toHistoryCommentResult( + List flatComments, + int page, + int pageSize, + int totalRootCount + ) { + // 부모 댓글 id를 기준 + Map> repliesGrouped = flatComments.stream() + .filter(dto -> !dto.isRoot()) // 대댓글 + .collect(Collectors.groupingBy(HistoryCommentParamDTO::getParentId)); + + // 댓글과 대댓글 연결해주기 + List rootResults = flatComments.stream() + .filter(HistoryCommentParamDTO::isRoot) + .map(root -> HistoryResponseDTO.CommentResult.builder() + .commentId(root.getCommentId()) + .content(root.getContent()) + .clokeyId(root.getClokeyId()) + .nickName(root.getNickname()) + .userImageUrl(root.getProfileImageUrl()) + .replyResults( + repliesGrouped.getOrDefault(root.getCommentId(), List.of()).stream() + .map(reply -> HistoryResponseDTO.ReplyResult.builder() + .commentId(reply.getCommentId()) + .content(reply.getContent()) + .clokeyId(reply.getClokeyId()) + .nickName(reply.getNickname()) + .userImageUrl(reply.getProfileImageUrl()) + .build()) + .toList() + ) + .build()) + .toList(); + + int totalPage = (int) Math.ceil((double) totalRootCount / pageSize); + int totalElements = rootResults.stream() + .mapToInt(r -> 1 + (r.getReplyResults() != null ? r.getReplyResults().size() : 0)) + .sum(); + + return HistoryResponseDTO.HistoryCommentResult.builder() + .comments(rootResults) + .totalPage(totalPage) + .totalElements(totalElements) + .isFirst(page == 0) + .isLast(page + 1 == totalPage) + .build(); + } +} diff --git a/goorm/src/main/java/study/goorm/domain/history/domain/entity/Comment.java b/goorm/src/main/java/study/goorm/domain/history/domain/entity/Comment.java index 2b8a337..17c3dac 100644 --- a/goorm/src/main/java/study/goorm/domain/history/domain/entity/Comment.java +++ b/goorm/src/main/java/study/goorm/domain/history/domain/entity/Comment.java @@ -30,4 +30,13 @@ public class Comment extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") private Comment comment; + + @Column(nullable = false) + private boolean banned = false; + + public void updateContent(String content) { + if (content != null && !content.isEmpty()) { + this.content = content; + } + } } 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..ab01e42 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 @@ -24,18 +24,36 @@ public class History extends BaseEntity { private Long id; @Column(nullable = false) + @Setter private LocalDate historyDate; @Min(0) @Column(nullable = false, columnDefinition = "integer default 0") private int likes; + @Min(0) + @Column(nullable = false, columnDefinition = "integer default 0") + private int comments; + @Column(length = 200) + @Setter private String content; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; + // update 메서드 추가 + public void update(String content) { + this.content = content; + } + + public void decreaseLikes() { + this.likes = likes-1; + } + + public void increaseLikes() { + this.likes = likes+1; + } } 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..34a6ff8 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,28 @@ package study.goorm.domain.history.domain.repository; 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 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{ + @Query("SELECT COUNT(c) FROM Comment c WHERE c.history.id = :historyId") + Long countByHistoryId(@Param("historyId") Long historyId); + + @Modifying + @Query("DELETE FROM Comment c WHERE c.history = :history") + void deleteByHistory(@Param("history") History history); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.history.id = :historyId AND c.comment IS NULL AND c.banned = false") + int countActiveRootComments(@Param("historyId") Long historyId); + + // 대댓 삭제 + @Transactional + @Modifying + @Query("DELETE FROM Comment c WHERE c.comment.id = :commentId") + void deleteChildrenComment(@Param("commentId") Long commentId); + } 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..5f0b9b5 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 @@ -2,6 +2,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import study.goorm.domain.history.domain.entity.HashtagHistory; +import study.goorm.domain.history.domain.entity.History; +import study.goorm.domain.history.domain.entity.HistoryImage; + +import java.util.List; public interface HashtagHistoryRepository extends JpaRepository{ + List findByHistoryId(Long historyId); + void deleteByHistory(History history); + 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..34a7125 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,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import study.goorm.domain.history.domain.entity.Hashtag; +import java.util.List; +import java.util.Optional; + public interface HashtagRepository extends JpaRepository{ + List findAllByNameIn(List names); + boolean existsByName(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 ce17a30..576fcd9 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 @@ -2,8 +2,19 @@ import org.springframework.data.jpa.repository.JpaRepository; import study.goorm.domain.cloth.domain.entity.Cloth; +import study.goorm.domain.history.domain.entity.HashtagHistory; +import study.goorm.domain.history.domain.entity.History; import study.goorm.domain.history.domain.entity.HistoryCloth; +import java.util.List; +import java.util.Optional; + public interface HistoryClothRepository extends JpaRepository{ + // Cloth void deleteAllByCloth(Cloth cloth); + + // History + List findByHistory(History history); + void deleteByHistory(History history); + void deleteAllByHistory(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..da03965 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 study.goorm.domain.history.domain.entity.History; import study.goorm.domain.history.domain.entity.HistoryImage; +import java.util.List; + public interface HistoryImageRepository extends JpaRepository{ + List findAllByHistoryIdIn(List historyIds); + List findByHistoryId(Long historyId); + List findAllByHistory(History history); + void deleteAllByHistory(History history); } 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..a40b44e 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,44 @@ package study.goorm.domain.history.domain.repository; +import org.springframework.data.domain.Pageable; 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.history.dto.HistoryCommentParamDTO; +import study.goorm.domain.member.domain.entity.Member; +import study.goorm.domain.history.domain.entity.Comment; + +import java.time.LocalDate; +import java.util.List; public interface HistoryRepository extends JpaRepository{ + // 이렇게도 쓸 수 있구나! + @Query("SELECT h FROM History h " + + "WHERE h.member.id = :memberId AND FUNCTION('DATE_FORMAT', h.historyDate, '%Y-%m') = :yearMonth") + List findHistoriesByMemberIdAndYearMonth(@Param("memberId") Long memberId, @Param("yearMonth") String yearMonth); + + History findById(long historyId); + + @Query( + "SELECT new study.goorm.domain.history.dto.HistoryCommentParamDTO(" + + " c.id, " + // commentId + " c.content, " + // content + " CASE WHEN c.comment.id IS NULL THEN true ELSE false END, " + // isRoot + " c.comment.id, " + // parentId + " c.member.clokeyId, " + // clokeyId + " c.member.nickname, " + // nickname + " c.member.profileImageUrl, " + // profileImageUrl + " c.createdAt" + // createdAt + ") " + + "FROM Comment c " + + "WHERE c.history.id = :historyId " + + "ORDER BY " + + " COALESCE(c.comment.id, c.id) ASC, " + + " c.createdAt ASC" + ) + List findFlatCommentsByHistoryId( + @Param("historyId") Long historyId, + Pageable pageable + ); } 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..55bf31c 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,16 @@ package study.goorm.domain.history.domain.repository; 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.history.domain.entity.History; import study.goorm.domain.history.domain.entity.MemberLike; public interface MemberLikeRepository extends JpaRepository{ + @Modifying + @Query("DELETE FROM MemberLike l WHERE l.history = :history") + void deleteByHistory(@Param("history") History history); + + void deleteByMemberIdAndHistoryId(Long memberId, Long historyId); } diff --git a/goorm/src/main/java/study/goorm/domain/history/dto/HistoryCommentParamDTO.java b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryCommentParamDTO.java new file mode 100644 index 0000000..753e9b6 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryCommentParamDTO.java @@ -0,0 +1,21 @@ +package study.goorm.domain.history.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class HistoryCommentParamDTO { + private Long commentId; + private String content; + private boolean isRoot; + private Long parentId; + private String clokeyId; + private String nickname; + private String profileImageUrl; + private LocalDateTime createdAt; +} 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..5b71bb4 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryRequestDTO.java @@ -0,0 +1,85 @@ +package study.goorm.domain.history.dto; + +import jakarta.persistence.Column; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import study.goorm.domain.cloth.exception.annotation.CheckLowerUpperTempBound; +import study.goorm.domain.model.enums.Season; +import study.goorm.domain.model.enums.ThicknessLevel; + +import java.time.LocalDate; +import java.util.List; + +public class HistoryRequestDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryCreateRequest { + + @Column(length = 200) + private String content; + + private List clothes; + + private List hashtags; + + private LocalDate date; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryUpdateRequest { + + @Column(length = 200) + private String content; + + private List clothes; + + private List hashtags; + +// private Visibility visibility; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryLike { + + private Long historyId; + + private boolean liked; + + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryCommentWrite { + + Long commentId; + + @NotBlank + String content; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryUpdateComment { + @NotBlank + String content; + } + +} 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..997803e --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/dto/HistoryResponseDTO.java @@ -0,0 +1,163 @@ +package study.goorm.domain.history.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import jakarta.persistence.Column; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import study.goorm.domain.history.domain.entity.HistoryImage; + +import java.time.LocalDate; +import java.util.List; + +public class HistoryResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryGetMonthly { + private Long memberId; + private String nickName; + private List histories; + } + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryGetMonthlyResult { + private Long historyId; + private LocalDate date; + private String imageUrl; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryGetDaily { + private Long memberId; + private String memberImageUrl; + private String nickName; + private String clokeyId; + private String content; + private List imageUrl; + private List hashtags; + private int likeCount; + private int commentCount; + private boolean liked; + private LocalDate date; + private List cloths; + private long clothId; + private long historyId; + } + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryGetDailyCloth { + private Long clothId; + private String clothImageUrl; + private String clothName; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryCreateResult { + private Long historyId; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryUpdateResult { + private Long historyId; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryLikeResult { + + private Long historyId; + + private boolean liked; + + private int likeCount; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryLikedUserResultList { + List likedUsers; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryLikedUserResult { + private Long memberId; + private String clokeyId; + private String nickname; + private boolean followStatus; + private String imageUrl; + private boolean isMe; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryCommentWriteResult { + Long commentId; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class HistoryCommentResult { + List comments; + int totalPage; + int totalElements; + private boolean isFirst; + private boolean isLast; + + } + + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @JsonPropertyOrder({"commentId", "clokeyId", "nickName", "userImageUrl", "content", "replyResults"}) + public static class CommentResult { + Long commentId; + String clokeyId; + String nickName; + String userImageUrl; + String content; + List replyResults; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ReplyResult { + Long commentId; + String clokeyId; + String nickName; + String userImageUrl; + String content; + } +} diff --git a/goorm/src/main/java/study/goorm/domain/history/exception/HistoryExeption.java b/goorm/src/main/java/study/goorm/domain/history/exception/HistoryExeption.java new file mode 100644 index 0000000..0d1b165 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/history/exception/HistoryExeption.java @@ -0,0 +1,10 @@ +package study.goorm.domain.history.exception; + +import study.goorm.global.error.code.BaseErrorCode; +import study.goorm.global.exception.GeneralException; + +public class HistoryExeption extends GeneralException { + public HistoryExeption(BaseErrorCode code) { + super(code); + } +} diff --git a/goorm/src/main/java/study/goorm/domain/member/domain/dto/LikedMemberDTO.java b/goorm/src/main/java/study/goorm/domain/member/domain/dto/LikedMemberDTO.java new file mode 100644 index 0000000..ff433b0 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/member/domain/dto/LikedMemberDTO.java @@ -0,0 +1,17 @@ +package study.goorm.domain.member.domain.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LikedMemberDTO { + private Long memberId; + private String clokeyId; + private String imageUrl; + private String nickname; + Boolean isFollowed; + Boolean isMe; +} diff --git a/goorm/src/main/java/study/goorm/domain/member/domain/entity/Member.java b/goorm/src/main/java/study/goorm/domain/member/domain/entity/Member.java index 4acc346..ef4692b 100644 --- a/goorm/src/main/java/study/goorm/domain/member/domain/entity/Member.java +++ b/goorm/src/main/java/study/goorm/domain/member/domain/entity/Member.java @@ -8,6 +8,10 @@ import study.goorm.domain.model.enums.MemberStatus; import study.goorm.domain.model.enums.SocialType; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Entity @Getter @Setter diff --git a/goorm/src/main/java/study/goorm/domain/member/domain/entity/MemberFollow.java b/goorm/src/main/java/study/goorm/domain/member/domain/entity/MemberFollow.java new file mode 100644 index 0000000..d42dc19 --- /dev/null +++ b/goorm/src/main/java/study/goorm/domain/member/domain/entity/MemberFollow.java @@ -0,0 +1,18 @@ +package study.goorm.domain.member.domain.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "member_follow") +public class MemberFollow { + @Id + @GeneratedValue + private Long id; + + @ManyToOne @JoinColumn(name = "follower_id") + private Member follower; + + @ManyToOne @JoinColumn(name = "followed_id") + private Member followed; + +} diff --git a/goorm/src/main/java/study/goorm/domain/member/domain/repository/MemberRepository.java b/goorm/src/main/java/study/goorm/domain/member/domain/repository/MemberRepository.java index 31faac7..9e38e5b 100644 --- a/goorm/src/main/java/study/goorm/domain/member/domain/repository/MemberRepository.java +++ b/goorm/src/main/java/study/goorm/domain/member/domain/repository/MemberRepository.java @@ -1,10 +1,33 @@ package study.goorm.domain.member.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.member.domain.dto.LikedMemberDTO; import study.goorm.domain.member.domain.entity.Member; +import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository{ Optional findByClokeyId(String clokeyId); + + Member findMemberById(Long memberId); + + @Query( + "SELECT new study.goorm.domain.member.domain.dto.LikedMemberDTO(" + + "m.id, m.clokeyId, m.profileImageUrl, m.nickname, " + + "CASE WHEN EXISTS(" + + "SELECT 1 FROM MemberFollow f " + + "WHERE f.follower.id = :loginMemberId AND f.followed.id = m.id" + + ") THEN true ELSE false END, " + + "CASE WHEN m.id = :loginMemberId THEN true ELSE false END" + + ") " + + "FROM MemberLike ml JOIN ml.member m " + + "WHERE ml.history.id = :historyId" + ) + List findLikedMembersWithFollowInfo( + @Param("historyId") Long historyId, + @Param("loginMemberId") Long loginMemberId + ); } diff --git a/goorm/src/main/java/study/goorm/global/config/S3Config.java b/goorm/src/main/java/study/goorm/global/config/S3Config.java new file mode 100644 index 0000000..306eea6 --- /dev/null +++ b/goorm/src/main/java/study/goorm/global/config/S3Config.java @@ -0,0 +1,28 @@ +package study.goorm.global.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +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 S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} 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 e8cd8b0..e1739e0 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 @@ -20,7 +20,7 @@ public enum ErrorStatus implements BaseErrorCode { NO_SUCH_CLOTH(HttpStatus.BAD_REQUEST, "CLOTH_4001","옷이 존재하지 않습니다"), NO_ClOTH_IMAGE(HttpStatus.BAD_REQUEST,"CLOTH_4002","옷의 사진이 존재하지 않습니다."), // Member - NO_SUCH_MEMBER(HttpStatus.BAD_REQUEST,"MEMBER_4001","멤버가 존재하지 않습니다."), + NO_SUCH_MEMBER(HttpStatus.BAD_REQUEST,"MEMBER_4003","멤버가 존재하지 않습니다."), // Page PAGE_UNDER_ONE(HttpStatus.BAD_REQUEST,"PAGE_4001","페이지는 1이상으로 입력해야 합니다."), @@ -28,7 +28,18 @@ public enum ErrorStatus implements BaseErrorCode { // ErrorStatus에 에러 추가 ! NO_SUCH_CATEGORY(HttpStatus.BAD_REQUEST, "CLOTH_4003", "카테고리가 존재하지 않습니다."), - LOWER_TEMP_BIGGER_THAN_UPPER_TEMP(HttpStatus.BAD_REQUEST,"CLOTH_4004","옷의 하한 온도가 상한 온도 보다 높습니다."); + LOWER_TEMP_BIGGER_THAN_UPPER_TEMP(HttpStatus.BAD_REQUEST,"CLOTH_4004","옷의 하한 온도가 상한 온도 보다 높습니다."), + + // History + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "HISTORY_4001", "잘못된 날짜 형식입니다."), + NO_SUCH_HISTORY(HttpStatus.BAD_REQUEST, "HISTORY_4002","존재하지 않는 기록 ID 입니다."), + TOO_MANY_IMAGES(HttpStatus.BAD_REQUEST, "HISTORY_4002","이미지 업로드 개수를 초과했습니다"), + NO_PERMISSION_FOR_RECORD(HttpStatus.BAD_REQUEST, "HISTORY_4006","기록에 접근 권한이 없습니다."), + INVALID_LIKED(HttpStatus.BAD_REQUEST,"HISTORY_4004","잘못된 isLiked 값을 입력했습니다."), + NO_SUCH_COMMENT(HttpStatus.NOT_FOUND,"HISTORY_4005","존재하지 않는 댓글 ID입니다."), + + // S3 + S3_IMAGE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S3_001", "S3 이미지 업로드 실패"); 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 d29d3b2..36437d2 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 @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; +import study.goorm.domain.history.domain.entity.History; import study.goorm.global.error.code.BaseCode; import study.goorm.global.error.code.ReasonDTO; @@ -16,7 +17,20 @@ 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_GET_MONTH(HttpStatus.OK, "HISTORY_200","월별 기록이 성공적으로 조회되었습니다."), + HISTORY_CREATED(HttpStatus.CREATED,"HISTORY_201","옷 기록이 성공적으로 생성되었습니다."), + HISTORY_DELETED(HttpStatus.NO_CONTENT, "HISTORY_202","기록이 성공적으로 삭제되었습니다."), + HISTORY_UPDATED(HttpStatus.NO_CONTENT,"HISTORY_200","성공적으로 수정되었습니다"), + HISTORY_LIKE_STATUS_CHANGED(HttpStatus.OK,"HISTORY_200","좋아요 상태가 성공적으로 변경되었습니다."), + HISTORY_LIKE_USER(HttpStatus.OK,"HISTORY_200","기록의 좋아요를 누른 유저 정보를 성공적으로 조회했습니다."), + HISTORY_COMMENT_CREATED(HttpStatus.CREATED,"HISTORY_201","성공적으로 댓글이 생성되었습니다."), + HISTORY_SUCCESS(HttpStatus.OK, "HISTORY_200", "성공적으로 조회되었습니다."), + HISTORY_COMMENT_DELETED(HttpStatus.NO_CONTENT,"HISTORY_204","댓글이 성공적으로 삭제되었습니다"), + HISTORY_COMMENT_UPDATED(HttpStatus.NO_CONTENT,"HISTORY_204","댓글이 성공적으로 수정되었습니다"), + NOT_MY_COMMENT(HttpStatus.BAD_REQUEST,"HISTORY_4010","나의 댓글이 아닙니다"),; private final HttpStatus httpStatus; private final String code; diff --git a/goorm/src/main/java/study/goorm/storage/S3UploadService.java b/goorm/src/main/java/study/goorm/storage/S3UploadService.java new file mode 100644 index 0000000..a20dec7 --- /dev/null +++ b/goorm/src/main/java/study/goorm/storage/S3UploadService.java @@ -0,0 +1,31 @@ +package study.goorm.storage; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class S3UploadService { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String saveFile(MultipartFile multipartFile) throws IOException { + String originalFilename = multipartFile.getOriginalFilename(); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(multipartFile.getSize()); + metadata.setContentType(multipartFile.getContentType()); + + amazonS3.putObject(bucket, originalFilename, multipartFile.getInputStream(), metadata); + return amazonS3.getUrl(bucket, originalFilename).toString(); + } +} diff --git a/goorm/src/main/resources/application.yml b/goorm/src/main/resources/application.yml index 252c411..c99ce3d 100644 --- a/goorm/src/main/resources/application.yml +++ b/goorm/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + profiles: + include: jwt, aws #jwt.yml 불러오기 datasource: username: ${DB_USERNAME} password: ${DB_PASSWORD} @@ -26,3 +28,16 @@ logging: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.springframework.jdbc.core: DEBUG + +cloud: + aws: + s3: + bucket: ${BUCKET} + credentials: + access-key: ${ACCESS_KEY} + secret-key: ${SECRET_KEY} + region: + static: ap-southeast-2 + auto: false + stack: + auto: false