diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java index 9b41d751c..7ac3e3572 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java @@ -61,7 +61,7 @@ public BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userI } // 유저가 저장한 책 목록 조회 - SavedBooks savedBooks = savedQueryPort.findByUserId(userId); + SavedBooks savedBooks = savedQueryPort.findSavedBooksByUserId(userId); if (isSave) { // 저장 요청 시 이미 저장되어 있으면 예외 발생 diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 378c98a14..0da903803 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -132,6 +132,9 @@ public enum ErrorCode implements ResponseCode { TAG_NOT_FOUND(HttpStatus.NOT_FOUND, 160002, "존재하지 않는 TAG 입니다."), INVALID_FEED_COMMAND(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성/수정 요청 입니다."), FEED_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, 160004, "피드 수정 권한이 없습니다."), + DUPLICATED_FEEDS_IN_COLLECTION(HttpStatus.INTERNAL_SERVER_ERROR, 160005, "중복된 피드가 존재합니다."), + FEED_ALREADY_SAVED(HttpStatus.BAD_REQUEST, 160006, "사용자가 이미 저장한 피드입니다."), + FEED_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 160007, "사용자가 저장하지 않은 피드는 저장삭제 할 수 없습니다."), /** * 170000 : Image File error diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java index 713a0e429..c43488005 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java @@ -4,9 +4,12 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.feed.adapter.in.web.request.FeedCreateRequest; +import konkuk.thip.feed.adapter.in.web.request.FeedIsSavedRequest; import konkuk.thip.feed.adapter.in.web.request.FeedUpdateRequest; import konkuk.thip.feed.adapter.in.web.response.FeedIdResponse; +import konkuk.thip.feed.adapter.in.web.response.FeedIsSavedResponse; import konkuk.thip.feed.application.port.in.FeedCreateUseCase; +import konkuk.thip.feed.application.port.in.FeedSavedUseCase; import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +25,7 @@ public class FeedCommandController { private final FeedCreateUseCase feedCreateUseCase; private final FeedUpdateUseCase feedUpdateUseCase; + private final FeedSavedUseCase feedSavedUseCase; //피드 작성 @PostMapping("/feeds") @@ -40,4 +44,13 @@ public BaseResponse updateFeed(@RequestBody @Valid final FeedUpd return BaseResponse.ok(FeedIdResponse.of(feedUpdateUseCase.updateFeed(request.toCommand(userId,feedId)))); } + + //피드 저장상태 변경: true -> 저장, false -> 저장해제(삭제) + @PostMapping("/feeds/{feedId}/saved") + public BaseResponse changeSavedFeed(@RequestBody final FeedIsSavedRequest request, + @PathVariable("feedId") final Long feedId, + @UserId final Long userId) { + return BaseResponse.ok(FeedIsSavedResponse.of(feedSavedUseCase.changeSavedFeed(FeedIsSavedRequest.toCommand(userId,feedId,request.type())))); + } + } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java new file mode 100644 index 000000000..efb6c0f50 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java @@ -0,0 +1,13 @@ +package konkuk.thip.feed.adapter.in.web.request; + +import jakarta.validation.constraints.NotNull; +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand; + +public record FeedIsSavedRequest( + @NotNull(message = "type은 필수입니다.") + boolean type +) { + public static FeedIsSavedCommand toCommand(Long userId, Long feedId, Boolean type) { + return new FeedIsSavedCommand(userId, feedId, type); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java new file mode 100644 index 000000000..df2559e66 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java @@ -0,0 +1,12 @@ +package konkuk.thip.feed.adapter.in.web.response; + +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult; + +public record FeedIsSavedResponse( + Long feedId, + boolean isSaved +) { + public static FeedIsSavedResponse of(FeedIsSavedResult feedIsSavedResult) { + return new FeedIsSavedResponse(feedIsSavedResult.feedId(), feedIsSavedResult.isSaved()); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index 3ff52382b..84c0d789e 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -21,6 +21,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import static konkuk.thip.common.exception.code.ErrorCode.*; @@ -36,16 +37,17 @@ public class FeedCommandPersistenceAdapter implements FeedCommandPort { private final FeedMapper feedMapper; private final ContentMapper contentMapper; - @Override - public Feed findById(Long id) { - FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(id) - .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); - - List tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId()); - return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList); + @Override + public Optional findById(Long id) { + return feedJpaRepository.findById(id) + .map(feedJpaEntity -> { + List tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId()); + return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList); + }); } + @Override public Long save(Feed feed) { diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java index f8d37dcbd..cf5519abc 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java @@ -2,15 +2,24 @@ import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; +import konkuk.thip.saved.application.port.out.dto.FeedIdAndTagProjection; 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 java.util.List; + public interface FeedTagJpaRepository extends JpaRepository{ @Modifying @Query("DELETE FROM FeedTagJpaEntity ft WHERE ft.feedJpaEntity = :feedJpaEntity") void deleteAllByFeedJpaEntity(@Param("feedJpaEntity") FeedJpaEntity feedJpaEntity); + @Query(""" + SELECT ft.feedJpaEntity.postId as feedId, ft.tagJpaEntity as tagJpaEntity + FROM FeedTagJpaEntity ft + WHERE ft.feedJpaEntity.postId IN :feedIds + """) + List findFeedIdAndTagsByFeedIds(@Param("feedIds") List feedIds); } diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java new file mode 100644 index 000000000..8f2951f62 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand; +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult; + +public interface FeedSavedUseCase { + FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand); +} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java deleted file mode 100644 index b260ba6b2..000000000 --- a/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java +++ /dev/null @@ -1,10 +0,0 @@ -package konkuk.thip.feed.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class DummyResult { - -} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java new file mode 100644 index 000000000..11fc6b5aa --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java @@ -0,0 +1,12 @@ +package konkuk.thip.feed.application.port.in.dto; + +public record FeedIsSavedCommand( + + Long userId, + + Long feedId, + + Boolean isSaved +) +{ +} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java new file mode 100644 index 000000000..5b91f418b --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java @@ -0,0 +1,11 @@ +package konkuk.thip.feed.application.port.in.dto; + +public record FeedIsSavedResult( + Long feedId, + boolean isSaved +) +{ + public static FeedIsSavedResult of(Long feedId, boolean isSaved) { + return new FeedIsSavedResult(feedId, isSaved); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index ffbdc3621..31c86b5c2 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -1,10 +1,19 @@ package konkuk.thip.feed.application.port.out; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.feed.domain.Feed; +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.FEED_NOT_FOUND; + public interface FeedCommandPort { Long save(Feed feed); Long update(Feed feed); - Feed findById(Long id); + Optional findById(Long id); + default Feed getByIdOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + } } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java b/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java new file mode 100644 index 000000000..9825e8d78 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java @@ -0,0 +1,45 @@ +package konkuk.thip.feed.application.service; + +import jakarta.transaction.Transactional; +import konkuk.thip.feed.application.port.in.FeedSavedUseCase; +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand; +import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult; +import konkuk.thip.feed.application.port.out.FeedCommandPort; +import konkuk.thip.feed.domain.Feed; +import konkuk.thip.feed.domain.SavedFeeds; +import konkuk.thip.saved.application.port.out.SavedCommandPort; +import konkuk.thip.saved.application.port.out.SavedQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FeedSavedService implements FeedSavedUseCase { + + private final FeedCommandPort feedCommandPort; + private final SavedCommandPort savedCommandPort; + private final SavedQueryPort savedQueryPort; + + @Override + @Transactional + public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand) { + + // 1. 피드 검증 및 조회 + Feed feed = feedCommandPort.getByIdOrThrow(feedIsSavedCommand.feedId()); + + // 2. 유저가 저장한 피드 목록 조회 + SavedFeeds savedFeeds = savedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId()); + + if (feedIsSavedCommand.isSaved()) { + // 저장 요청 시 이미 저장되어 있으면 예외 발생 + savedFeeds.validateNotAlreadySaved(feed); + savedCommandPort.saveFeed(feedIsSavedCommand.userId(), feed.getId()); + } else { + // 삭제 요청 시 저장되어 있지 않으면 예외 발생 + savedFeeds.validateCanDelete(feed); + savedCommandPort.deleteFeed(feedIsSavedCommand.userId(), feed.getId()); + } + + return FeedIsSavedResult.of(feed.getId(), feedIsSavedCommand.isSaved()); + } +} diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java index 182f236c8..a9da22a54 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java @@ -28,7 +28,7 @@ public Long updateFeed(FeedUpdateCommand command) { Feed.validateImageCount(command.remainImageUrls() != null ? command.remainImageUrls().size() : 0); // 2. 피드 조회 - Feed feed = feedCommandPort.findById(command.feedId()); + Feed feed = feedCommandPort.getByIdOrThrow(command.feedId()); // 3. 도메인 내에서 내부 상태 변경 및 검증 applyPartialFeedUpdate(feed, command); diff --git a/src/main/java/konkuk/thip/feed/domain/Feed.java b/src/main/java/konkuk/thip/feed/domain/Feed.java index 1f50c9e5c..eb0ab0447 100644 --- a/src/main/java/konkuk/thip/feed/domain/Feed.java +++ b/src/main/java/konkuk/thip/feed/domain/Feed.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -41,6 +42,19 @@ public class Feed extends BaseDomainEntity { @Builder.Default private List contentList = new ArrayList<>(); + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Feed feed = (Feed) o; + return Objects.equals(id, feed.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + public static Feed withoutId(String content, Long creatorId, Boolean isPublic, Long targetBookId, List tagValues, List imageUrls) { diff --git a/src/main/java/konkuk/thip/feed/domain/SavedFeeds.java b/src/main/java/konkuk/thip/feed/domain/SavedFeeds.java new file mode 100644 index 000000000..e5c739477 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/domain/SavedFeeds.java @@ -0,0 +1,42 @@ +package konkuk.thip.feed.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import lombok.Getter; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@Getter +public class SavedFeeds { + + private final Set feeds; + + public SavedFeeds(List feeds) { + Set feedSet = new HashSet<>(feeds); + if (feedSet.size() != feeds.size()) { + throw new InvalidStateException(DUPLICATED_FEEDS_IN_COLLECTION); + } + this.feeds = Collections.unmodifiableSet(feedSet); + } + + // 중복 저장 검증 + public void validateNotAlreadySaved(Feed feed) { + if (feeds.contains(feed)) { + throw new InvalidStateException(FEED_ALREADY_SAVED); + } + } + + // 삭제 가능 여부 검증 + public void validateCanDelete(Feed feed) { + if (!feeds.contains(feed)) { + throw new InvalidStateException(FEED_NOT_SAVED_CANNOT_DELETE); + } + } + +} + + diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java index 108e7be9c..cc61dd5cf 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java @@ -3,7 +3,10 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; +import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity; import konkuk.thip.saved.adapter.out.mapper.SavedBookMapper; import konkuk.thip.saved.adapter.out.mapper.SavedFeedMapper; import konkuk.thip.saved.adapter.out.persistence.repository.SavedBookJpaRepository; @@ -22,6 +25,7 @@ public class SavedCommandPersistenceAdapter implements SavedCommandPort { private final UserJpaRepository userJpaRepository; private final BookJpaRepository bookJpaRepository; + private final FeedJpaRepository feedJpaRepository; private final SavedBookJpaRepository savedBookJpaRepository; private final SavedFeedJpaRepository savedFeedJpaRepository; private final SavedBookMapper savedBookMapper; @@ -40,10 +44,28 @@ public void saveBook(Long userId, Long bookId) { savedBookJpaRepository.save(entity); } - - //삭제 전략 도입 전 @Override public void deleteBook(Long userId, Long bookId) { savedBookJpaRepository.deleteByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookId); } + + @Override + public void saveFeed(Long userId, Long feedId) { + UserJpaEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + FeedJpaEntity feed = feedJpaRepository.findById(feedId) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + SavedFeedJpaEntity entity = SavedFeedJpaEntity.builder() + .userJpaEntity(user) + .feedJpaEntity(feed) + .build(); + savedFeedJpaRepository.save(entity); + } + + @Override + public void deleteFeed(Long userId, Long feedId) { + savedFeedJpaRepository.deleteByUserIdAndFeedId(userId, feedId); + } + + } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java index ec563a0e9..b7aff7ee7 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java @@ -3,19 +3,26 @@ import konkuk.thip.book.adapter.out.mapper.BookMapper; import konkuk.thip.book.domain.Book; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; +import konkuk.thip.feed.adapter.out.mapper.FeedMapper; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedTag.FeedTagJpaRepository; +import konkuk.thip.feed.domain.Feed; +import konkuk.thip.feed.domain.SavedFeeds; import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; -import konkuk.thip.saved.adapter.out.mapper.SavedBookMapper; -import konkuk.thip.saved.adapter.out.mapper.SavedFeedMapper; +import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity; import konkuk.thip.saved.adapter.out.persistence.repository.SavedBookJpaRepository; import konkuk.thip.saved.adapter.out.persistence.repository.SavedFeedJpaRepository; import konkuk.thip.saved.application.port.out.SavedQueryPort; import konkuk.thip.book.domain.SavedBooks; +import konkuk.thip.saved.application.port.out.dto.FeedIdAndTagProjection; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; @@ -27,9 +34,9 @@ public class SavedQueryPersistenceAdapter implements SavedQueryPort { private final SavedBookJpaRepository savedBookJpaRepository; private final SavedFeedJpaRepository savedFeedJpaRepository; private final UserJpaRepository userJpaRepository; - private final SavedBookMapper savedBookMapper; private final BookMapper bookMapper; - private final SavedFeedMapper savedFeedMapper; + private final FeedMapper feedMapper; + private final FeedTagJpaRepository feedTagJpaRepository; @Override public boolean existsByUserIdAndBookId(Long userId, Long bookId) { @@ -37,7 +44,7 @@ public boolean existsByUserIdAndBookId(Long userId, Long bookId) { } @Override - public SavedBooks findByUserId(Long userId) { + public SavedBooks findSavedBooksByUserId(Long userId) { UserJpaEntity user = userJpaRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); @@ -51,5 +58,36 @@ public SavedBooks findByUserId(Long userId) { return new SavedBooks(books); } + @Override + public SavedFeeds findSavedFeedsByUserId(Long userId) { + UserJpaEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + List savedFeedEntities = + savedFeedJpaRepository.findAllByUserId(user.getUserId()); + + List feedIds = savedFeedEntities.stream() + .map(entity -> entity.getFeedJpaEntity().getPostId()) + .toList(); + + // Projection 기반 조회 + List results = feedTagJpaRepository.findFeedIdAndTagsByFeedIds(feedIds); + + Map> feedTagsMap = results.stream() + .collect(Collectors.groupingBy( + FeedIdAndTagProjection::getFeedId, + Collectors.mapping(FeedIdAndTagProjection::getTagJpaEntity, Collectors.toList()) + )); + + List feeds = savedFeedEntities.stream() + .map(entity -> { + FeedJpaEntity feedJpa = entity.getFeedJpaEntity(); + List tags = feedTagsMap.getOrDefault(feedJpa.getPostId(), List.of()); + return feedMapper.toDomainEntity(feedJpa, tags); + }) + .toList(); + + return new SavedFeeds(feeds); + } } diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java index be10fae92..36e02a0fe 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java @@ -2,6 +2,17 @@ import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity; 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 java.util.List; public interface SavedFeedJpaRepository extends JpaRepository { + @Modifying + @Query(value = "DELETE FROM saved_feeds WHERE user_id = :userId AND post_id = :feedId", nativeQuery = true) + void deleteByUserIdAndFeedId(@Param("userId") Long userId, @Param("feedId") Long feedId); + + @Query(value = "SELECT * FROM saved_feeds WHERE user_id = :userId", nativeQuery = true) + List findAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java b/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java index a4a12157f..21e26934f 100644 --- a/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java +++ b/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java @@ -4,4 +4,6 @@ public interface SavedCommandPort { void saveBook(Long userId, Long bookId); void deleteBook(Long userId, Long bookId); + void saveFeed(Long userId, Long feedId); + void deleteFeed(Long userId, Long feedId); } diff --git a/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java b/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java index 1ed5ae9e6..2c77fa3d7 100644 --- a/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java +++ b/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java @@ -1,8 +1,10 @@ package konkuk.thip.saved.application.port.out; import konkuk.thip.book.domain.SavedBooks; +import konkuk.thip.feed.domain.SavedFeeds; public interface SavedQueryPort { boolean existsByUserIdAndBookId(Long userId, Long bookId); - SavedBooks findByUserId(Long userId); + SavedBooks findSavedBooksByUserId(Long userId); + SavedFeeds findSavedFeedsByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java b/src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java new file mode 100644 index 000000000..ab1ef2c5d --- /dev/null +++ b/src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java @@ -0,0 +1,8 @@ +package konkuk.thip.saved.application.port.out.dto; + +import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; + +public interface FeedIdAndTagProjection { + Long getFeedId(); + TagJpaEntity getTagJpaEntity(); +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index e03ddb30a..240df2cd5 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -12,6 +12,7 @@ import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity; import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; @@ -222,4 +223,10 @@ public static FeedJpaEntity createFeedWithContents(UserJpaEntity user, BookJpaEn } + public static SavedFeedJpaEntity createSavedFeed(UserJpaEntity user, FeedJpaEntity feed) { + return SavedFeedJpaEntity.builder() + .feedJpaEntity(feed) + .userJpaEntity(user) + .build(); + } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java new file mode 100644 index 000000000..76b976d60 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java @@ -0,0 +1,179 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.in.web.request.FeedIsSavedRequest; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.Tag.TagJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity; +import konkuk.thip.saved.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 피드 저장 상태 변경 api 통합 테스트") +class FeedChangeSavedAPITest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private TagJpaRepository tagJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity book; + private FeedJpaEntity feed; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + tagJpaRepository.save(TestEntityFactory.createTag(category, "소설추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category, "책추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category, "오늘의책")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + } + + @Test + @DisplayName("피드를 처음 저장하면 [저장 성공]") + void saveFeed_success() throws Exception { + // given + FeedIsSavedRequest request = new FeedIsSavedRequest(true); + Long feedId = feed.getPostId(); + + // when + ResultActions result = mockMvc.perform(post("/feeds/{feedId}/saved", feedId) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").value(feedId)) + .andExpect(jsonPath("$.data.isSaved").value(true)); + + // 실제 저장되었는지 검증 + List savedFeeds = savedFeedJpaRepository.findAllByUserId(user.getUserId()); + boolean exists = savedFeeds.stream() + .anyMatch(entity -> entity.getFeedJpaEntity().getPostId().equals(feed.getPostId())); + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("이미 저장한 피드를 다시 저장하려하면 [400 에러 발생]") + void saveFeed_alreadySaved_fail() throws Exception { + // given + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(user, feed)); + FeedIsSavedRequest request = new FeedIsSavedRequest(true); + + // when + ResultActions result = mockMvc.perform(post("/feeds/{feedId}/saved", feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.FEED_ALREADY_SAVED.getCode())); + } + + @Test + @DisplayName("피드를 저장한 이후 삭제 요청하면 [피드 저장 삭제 성공]") + void deleteFeed_success() throws Exception { + // given + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(user, feed)); + FeedIsSavedRequest request = new FeedIsSavedRequest(false); + + // when + ResultActions result = mockMvc.perform(post("/feeds/{feedId}/saved", feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").value(feed.getPostId())) + .andExpect(jsonPath("$.data.isSaved").value(false)); + + List savedFeeds = savedFeedJpaRepository.findAllByUserId(user.getUserId()); + boolean exists = savedFeeds.stream() + .anyMatch(entity -> entity.getFeedJpaEntity().getPostId().equals(feed.getPostId())); + assertThat(exists).isFalse(); + + } + + @Test + @DisplayName("저장하지 않은 피드를 삭제하려고 하면 [400 에러 발생]") + void deleteFeed_notSaved_fail() throws Exception { + // given + FeedIsSavedRequest request = new FeedIsSavedRequest(false); + + // when + ResultActions result = mockMvc.perform(post("/feeds/{feedId}/saved", feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.FEED_NOT_SAVED_CANNOT_DELETE.getCode())); + } + + @Test + @DisplayName("존재하지 않는 피드를 저장/삭제 요청시 [404 에러 발생]") + void changeFeedSaveStatus_whenFeedNotExist_thenFail() throws Exception { + + // given + Long invalidFeedId = 99999L; + FeedIsSavedRequest request = new FeedIsSavedRequest(true); + + // when + ResultActions result = mockMvc.perform(post("/feeds/{feedId}/saved", invalidFeedId) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.FEED_NOT_FOUND.getCode())); + } + +}