diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 57c4c52..502fa6d 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -343,4 +343,29 @@ include::{snippetsDir}/loadUserPickAllPopular/1/http-response.adoc[] include::{snippetsDir}/loadUserPickAllPopular/1/response-fields.adoc[] ---- \ No newline at end of file +--- + +=== **14. 해시태그 추천 - 상품 좋아요 api** + +상품에 대한 좋아요 생성/삭제를 위한 api입니다. +좋아요가 눌려져 있지 않은 상태에서 호출되면 좋아요가 생성되며, 좋아요가 눌려져 있는 상태에서 api가 호출되면, 기존 좋아요가 삭제됩니다. + + +==== Request +include::{snippetsDir}/createProductLike/1/http-request.adoc[] + +==== Request Path Parameters +include::{snippetsDir}/createProductLike/1/path-parameters.adoc[] + +==== 성공 Response +include::{snippetsDir}/createProductLike/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/createProductLike/1/response-fields.adoc[] + +==== 실패 Response +include::{snippetsDir}/createProductLike/2/http-response.adoc[] + + + +--- diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/CreateProductLikeController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/CreateProductLikeController.java new file mode 100644 index 0000000..e59a676 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/CreateProductLikeController.java @@ -0,0 +1,30 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.adapter.in.web.post.dto.response.CreateProductLikeResponse; +import com.ftm.server.application.port.in.post.CreateProductLikeUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class CreateProductLikeController { + + private final CreateProductLikeUseCase createProductLikeUseCase; + + @PostMapping("/api/products/{productId}/like") + public ResponseEntity createProductLike( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable(name = "productId") Long productId) { + Boolean isCreated = createProductLikeUseCase.execute(userPrincipal.getId(), productId); + return ResponseEntity.ok( + ApiResponse.success( + SuccessResponseCode.OK, new CreateProductLikeResponse(isCreated))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/CreateProductLikeResponse.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/CreateProductLikeResponse.java new file mode 100644 index 0000000..f6cbd8c --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/CreateProductLikeResponse.java @@ -0,0 +1,11 @@ +package com.ftm.server.adapter.in.web.post.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CreateProductLikeResponse { + + private final Boolean isCreated; +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java index 34c099c..7ee48e5 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java @@ -36,7 +36,10 @@ public class PostDomainPersistenceAdapter DeletePostProductPort, DeletePostProductImagePort, LoadPostWithBookmarkCountPort, - LoadUserForPostDomainPort { + LoadUserForPostDomainPort, + LoadProductLikePort, + SaveProductLikePort, + DeleteProductLikePort { private final PostRepository postRepository; private final PostImageRepository postImageRepository; @@ -44,6 +47,7 @@ public class PostDomainPersistenceAdapter private final PostProductImageRepository postProductImageRepository; private final UserRepository userRepository; private final UserImageRepository userImageRepository; + private final ProductLikeRepository productLikeRepository; private final PostMapper postMapper; private final PostImageMapper postImageMapper; @@ -369,4 +373,36 @@ public List loadPostListByUsers(FindByUserIdsQuery query) { public List loadPostAndAuthorName(FindByIdsQuery query) { return userRepository.findUserNameByUserIds(query.getIds()); } + + @Override + public Optional findOneByUserAndProduct(Long userId, Long postProductId) { + return productLikeRepository.findByUserAndAndPostProduct(userId, postProductId); + } + + @Override + public void saveProductLike(ProductLike productLike) { + Long productId = productLike.getPostProduct(); + Long userId = productLike.getUser(); + + UserJpaEntity userJpaEntity = + userRepository + .findByIdAndIsDeleted(userId, false) + .orElseThrow(() -> new CustomException(ErrorResponseCode.USER_NOT_FOUND)); + PostProductJpaEntity postProductJpaEntity = + postProductRepository + .findById(productId) + .orElseThrow( + () -> + new CustomException( + ErrorResponseCode.POST_PRODUCT_NOT_FOUND)); + + ProductLikeJpaEntity productLikeJpaEntity = + ProductLikeJpaEntity.from(postProductJpaEntity, userJpaEntity); + productLikeRepository.save(productLikeJpaEntity); + } + + @Override + public void deleteProductLike(Long productLikeId) { + productLikeRepository.deleteById(productLikeId); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/mapper/ProductLikeMapper.java b/src/main/java/com/ftm/server/adapter/out/persistence/mapper/ProductLikeMapper.java new file mode 100644 index 0000000..e016d09 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/mapper/ProductLikeMapper.java @@ -0,0 +1,20 @@ +package com.ftm.server.adapter.out.persistence.mapper; + +import com.ftm.server.adapter.out.persistence.model.PostProductJpaEntity; +import com.ftm.server.adapter.out.persistence.model.ProductLikeJpaEntity; +import com.ftm.server.adapter.out.persistence.model.UserJpaEntity; +import com.ftm.server.common.annotation.EntityMapper; +import com.ftm.server.domain.entity.ProductLike; + +@EntityMapper +public class ProductLikeMapper { + public ProductLike toDomainEntity(ProductLikeJpaEntity jpaEntity) { + return ProductLike.create( + jpaEntity.getId(), jpaEntity.getPostProduct().getId(), jpaEntity.getUser().getId()); + } + + public ProductLikeJpaEntity toJpaEntity( + PostProductJpaEntity postProductJpaEntity, UserJpaEntity userJpaEntity) { + return ProductLikeJpaEntity.from(postProductJpaEntity, userJpaEntity); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/ProductLikeJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/ProductLikeJpaEntity.java new file mode 100644 index 0000000..509de71 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/ProductLikeJpaEntity.java @@ -0,0 +1,37 @@ +package com.ftm.server.adapter.out.persistence.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "product_like") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductLikeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_product_id") + private PostProductJpaEntity postProduct; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private UserJpaEntity user; + + @Builder(access = AccessLevel.PRIVATE) + private ProductLikeJpaEntity(PostProductJpaEntity postProduct, UserJpaEntity user) { + this.postProduct = postProduct; + this.user = user; + } + + public static ProductLikeJpaEntity from( + PostProductJpaEntity postProductJpaEntity, UserJpaEntity userJpaEntity) { + return ProductLikeJpaEntity.builder() + .postProduct(postProductJpaEntity) + .user(userJpaEntity) + .build(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java index aff7e50..786f326 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java @@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +@Repository public interface PostProductRepository extends JpaRepository { List findAllByPost(PostJpaEntity post); diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java new file mode 100644 index 0000000..2566522 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java @@ -0,0 +1,17 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import com.ftm.server.adapter.out.persistence.model.ProductLikeJpaEntity; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductLikeRepository extends JpaRepository { + + @Query( + "select p.id from ProductLikeJpaEntity p where p.user.id =:userId and p.postProduct.id =:productId") + Optional findByUserAndAndPostProduct( + @Param("userId") Long userId, @Param("productId") Long productId); +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/CreateProductLikeUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/CreateProductLikeUseCase.java new file mode 100644 index 0000000..f8c5a45 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/CreateProductLikeUseCase.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface CreateProductLikeUseCase { + + Boolean execute(Long userId, Long productId); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeleteProductLikePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeleteProductLikePort.java new file mode 100644 index 0000000..140143c --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeleteProductLikePort.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; + +@Port +public interface DeleteProductLikePort { + + void deleteProductLike(Long productLikeId); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java new file mode 100644 index 0000000..1eb7743 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import java.util.Optional; + +@Port +public interface LoadProductLikePort { + + Optional findOneByUserAndProduct(Long userId, Long postProductId); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/SaveProductLikePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/SaveProductLikePort.java new file mode 100644 index 0000000..620bd94 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/SaveProductLikePort.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.ProductLike; + +@Port +public interface SaveProductLikePort { + void saveProductLike(ProductLike productLike); +} diff --git a/src/main/java/com/ftm/server/application/service/post/product/CreateProductLikeService.java b/src/main/java/com/ftm/server/application/service/post/product/CreateProductLikeService.java new file mode 100644 index 0000000..b87a8ac --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/product/CreateProductLikeService.java @@ -0,0 +1,41 @@ +package com.ftm.server.application.service.post.product; + +import com.ftm.server.application.port.in.post.CreateProductLikeUseCase; +import com.ftm.server.application.port.out.persistence.post.DeleteProductLikePort; +import com.ftm.server.application.port.out.persistence.post.LoadProductLikePort; +import com.ftm.server.application.port.out.persistence.post.SaveProductLikePort; +import com.ftm.server.domain.entity.ProductLike; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CreateProductLikeService implements CreateProductLikeUseCase { + + private final LoadProductLikePort loadProductLikePort; + private final SaveProductLikePort saveProductLikePort; + private final DeleteProductLikePort deleteProductLikePort; + + @Transactional + public Boolean execute(Long userId, Long productId) { + + // 이미 등록된 좋아요 있는지 확인 + Optional optionalProductLikeId = + loadProductLikePort.findOneByUserAndProduct(userId, productId); + + // 없으면 좋아요 생성 + if (optionalProductLikeId.isEmpty()) { + ProductLike productLike = ProductLike.create(productId, userId); + saveProductLikePort.saveProductLike(productLike); + return true; + } + + // 있으면 삭제 + else { + deleteProductLikePort.deleteProductLike(optionalProductLikeId.get()); + return false; + } + } +} diff --git a/src/main/java/com/ftm/server/domain/entity/ProductLike.java b/src/main/java/com/ftm/server/domain/entity/ProductLike.java new file mode 100644 index 0000000..56fb7f6 --- /dev/null +++ b/src/main/java/com/ftm/server/domain/entity/ProductLike.java @@ -0,0 +1,21 @@ +package com.ftm.server.domain.entity; + +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) +public class ProductLike extends BaseTime { + private Long id; + private Long postProduct; + private Long user; + + public static ProductLike create(Long postProduct, Long user) { + return ProductLike.builder().postProduct(postProduct).user(user).build(); + } + + public static ProductLike create(Long id, Long postProduct, Long user) { + return ProductLike.builder().id(id).postProduct(postProduct).user(user).build(); + } +} diff --git a/src/test/java/com/ftm/server/post/CreateProductLikeTest.java b/src/test/java/com/ftm/server/post/CreateProductLikeTest.java new file mode 100644 index 0000000..145fef9 --- /dev/null +++ b/src/test/java/com/ftm/server/post/CreateProductLikeTest.java @@ -0,0 +1,161 @@ +package com.ftm.server.post; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.command.post.SavePostProductCommand; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductPort; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.PostHashtag; +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class CreateProductLikeTest extends BaseTest { + + @Autowired private SavePostPort savePostPort; + + @Autowired private SavePostProductPort savePostProductPort; + + private final ParameterDescriptor pathParameters = + parameterWithName("productId").description("상품 ID"); + + private final List responseFields = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("응답 데이터"), + fieldWithPath("data.isCreated") + .type(BOOLEAN) + .description("좋아요 생성 여부. true면 좋아요 생성, false 면 좋아요 취소")); + + private ResultActions getResultActions(Long productId, MockHttpSession session) + throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.post("/api/products/{productId}/like", productId) + .session(session)); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "createProductLike/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + pathParameters(pathParameters), + responseFields(responseFields), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("해시태그 상품 추천 - 상품 좋아요 api") + .description("해시태그 상품 추천 - 상품 좋아요 api 입니다.") + .responseFields(responseFields) + .build())); + } + + @Test + @Transactional + @DisplayName("테스트 성공") + public void test1() throws Exception { + // given + BaseTest.SessionAndUser sessionAndUser = createUserAndLoginAndReturnUser(); // 로그인 처리 + + User user = sessionAndUser.user(); + + MockHttpSession session = login("test@gmail.com"); + + // test 용 post 생성 + Post post = + savePostPort.savePost( + Post.create( + SavePostCommand.from( + user.getId(), + new SavePostRequest( + "test1", + List.of( + PostHashtag.SUN_CARE, + PostHashtag.CLEANSING), + "content1", + new ArrayList<>()), + new ArrayList<>(), + new ArrayList<>()))); + + List postProduct = + savePostProductPort.savePostProducts( + List.of( + PostProduct.create( + SavePostProductCommand.from( + new SavePostProductRequest( + -1, + "상품 3번", + "이니스프리", + List.of( + ProductHashtag.HAND_CARE, + ProductHashtag.INCENSE))) + .withPostId(post.getId())))); + + // when + ResultActions resultActions = getResultActions(postProduct.get(0).getId(), session); + + // then + resultActions + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.isCreated").value(true)); + + // documentation + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + @DisplayName("테스트 실패") + public void test2() throws Exception { + // given + BaseTest.SessionAndUser sessionAndUser = createUserAndLoginAndReturnUser(); // 로그인 처리 + + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(1000L, session); + + // then + resultActions + .andExpect(status().is(HttpStatus.NOT_FOUND.value())) + .andExpect( + jsonPath("$.code") + .value(ErrorResponseCode.POST_PRODUCT_NOT_FOUND.getCode())); + + // documentation + resultActions.andDo(getDocument(2)); + } +}