Skip to content

Commit

Permalink
feature: 상품 후기 추천 api (#77)
Browse files Browse the repository at this point in the history
* feat: 리뷰 추천 도메인 추가

* feat: 리뷰 추천 로직 추가

* feat: 리뷰 추천 API 추가

* docs: Swagger docs 추가

* docs: Swagger 인증 정보 추가
  • Loading branch information
TaeyeonRoyce authored Feb 16, 2024
1 parent e47b962 commit fb07730
Show file tree
Hide file tree
Showing 12 changed files with 332 additions and 2 deletions.
15 changes: 15 additions & 0 deletions src/main/kotlin/com/petqua/application/product/dto/ProductDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.petqua.domain.product.dto.ProductReadCondition
import com.petqua.domain.product.dto.ProductResponse
import com.petqua.domain.product.dto.ProductSearchCondition
import com.petqua.domain.product.dto.ProductWithInfoResponse
import com.petqua.domain.product.review.ProductReviewRecommendation
import io.swagger.v3.oas.annotations.media.Schema

data class ProductDetailResponse(
Expand Down Expand Up @@ -273,3 +274,17 @@ data class ProductKeywordResponse(
)
val keyword: String,
)


data class UpdateReviewRecommendationCommand(
val memberId: Long,
val productReviewId: Long,
) {

fun toReviewRecommendation(): ProductReviewRecommendation {
return ProductReviewRecommendation(
memberId = memberId,
productReviewId = productReviewId
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ import com.petqua.application.product.dto.ProductReviewReadQuery
import com.petqua.application.product.dto.ProductReviewResponse
import com.petqua.application.product.dto.ProductReviewStatisticsResponse
import com.petqua.application.product.dto.ProductReviewsResponse
import com.petqua.application.product.dto.UpdateReviewRecommendationCommand
import com.petqua.common.domain.existByIdOrThrow
import com.petqua.common.domain.findByIdOrThrow
import com.petqua.domain.member.MemberRepository
import com.petqua.domain.product.dto.ProductReviewWithMemberResponse
import com.petqua.domain.product.review.ProductReviewImageRepository
import com.petqua.domain.product.review.ProductReviewRecommendation
import com.petqua.domain.product.review.ProductReviewRecommendationRepository
import com.petqua.domain.product.review.ProductReviewRepository
import com.petqua.domain.product.review.ProductReviewStatistics
import com.petqua.exception.member.MemberException
import com.petqua.exception.member.MemberExceptionType
import com.petqua.exception.product.review.ProductReviewException
import com.petqua.exception.product.review.ProductReviewExceptionType.NOT_FOUND_PRODUCT_REVIEW
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

Expand All @@ -16,6 +26,8 @@ import org.springframework.transaction.annotation.Transactional
class ProductReviewService(
private val productReviewRepository: ProductReviewRepository,
private val productReviewImageRepository: ProductReviewImageRepository,
private val memberRepository: MemberRepository,
private val productReviewRecommendationRepository: ProductReviewRecommendationRepository,
) {

@Transactional(readOnly = true)
Expand Down Expand Up @@ -44,4 +56,30 @@ class ProductReviewService(
val productReviewStatistics = ProductReviewStatistics.from(reviewScoreWithCounts)
return ProductReviewStatisticsResponse.from(productReviewStatistics)
}

fun updateReviewRecommendation(command: UpdateReviewRecommendationCommand) {
memberRepository.existByIdOrThrow(command.memberId, MemberException(MemberExceptionType.NOT_FOUND_MEMBER))
productReviewRecommendationRepository.findByProductReviewIdAndMemberId(
command.productReviewId,
command.memberId,
)?.let { delete(it) } ?: save(command.toReviewRecommendation())
}

private fun save(productReviewRecommendation: ProductReviewRecommendation) {
productReviewRecommendationRepository.save(productReviewRecommendation)
val productReview = productReviewRepository.findByIdOrThrow(
productReviewRecommendation.productReviewId,
ProductReviewException(NOT_FOUND_PRODUCT_REVIEW)
)
productReview.increaseRecommendCount()
}

private fun delete(productReviewRecommendation: ProductReviewRecommendation) {
productReviewRecommendationRepository.delete(productReviewRecommendation)
val productReview = productReviewRepository.findByIdOrThrow(
productReviewRecommendation.productReviewId,
ProductReviewException(NOT_FOUND_PRODUCT_REVIEW)
)
productReview.decreaseRecommendCount()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ class ProductReview(
val score: Int,

@Column(nullable = false)
val recommendCount: Int = 0,
var recommendCount: Int = 0,

@Column(nullable = false)
val hasPhotos: Boolean = false,
) : BaseEntity() {

fun increaseRecommendCount() {
recommendCount += 1
}

fun decreaseRecommendCount() {
recommendCount -= 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.petqua.domain.product.review

import com.petqua.common.domain.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id

@Entity
class ProductReviewRecommendation(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,

@Column(nullable = false)
val productReviewId: Long,

@Column(nullable = false)
val memberId: Long,
) : BaseEntity() {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.petqua.domain.product.review

import org.springframework.data.jpa.repository.JpaRepository

interface ProductReviewRecommendationRepository : JpaRepository<ProductReviewRecommendation, Long> {

fun findByProductReviewIdAndMemberId(productReviewId: Long, memberId: Long): ProductReviewRecommendation?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.petqua.exception.product.review

import com.petqua.common.exception.BaseException
import com.petqua.common.exception.BaseExceptionType

class ProductReviewException(
private val exceptionType: ProductReviewExceptionType,
) : BaseException() {

override fun exceptionType(): BaseExceptionType {
return exceptionType
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.petqua.exception.product.review

import com.petqua.common.exception.BaseExceptionType
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.NOT_FOUND

enum class ProductReviewExceptionType(
private val httpStatus: HttpStatus,
private val code: String,
private val errorMessage: String,
) : BaseExceptionType {

NOT_FOUND_PRODUCT_REVIEW(NOT_FOUND, "PR01", "존재하지 않는 리뷰입니다."),
;

override fun httpStatus(): HttpStatus {
return httpStatus
}

override fun code(): String {
return code
}

override fun errorMessage(): String {
return errorMessage
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ package com.petqua.presentation.product
import com.petqua.application.product.dto.ProductReviewStatisticsResponse
import com.petqua.application.product.dto.ProductReviewsResponse
import com.petqua.application.product.review.ProductReviewService
import com.petqua.common.config.ACCESS_TOKEN_SECURITY_SCHEME_KEY
import com.petqua.domain.auth.Auth
import com.petqua.domain.auth.LoginMember
import com.petqua.presentation.product.dto.ReadAllProductReviewsRequest
import com.petqua.presentation.product.dto.UpdateReviewRecommendationRequest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@Tag(name = "ProductReview", description = "상품 후기 관련 API 명세")
Expand Down Expand Up @@ -44,4 +51,19 @@ class ProductReviewController(
val response = productReviewService.readReviewCountStatistics(productId)
return ResponseEntity.ok(response)
}

@Operation(summary = "상품 후기 추천 토글 API", description = "상품 후기의 추천 여부를 토글합니다")
@ApiResponse(responseCode = "204", description = "상품 후기 추천 토글 성공")
@SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY)
@PostMapping("/product-reviews/recommendation")
fun updateRecommendation(
@Auth loginMember: LoginMember,
@RequestBody request: UpdateReviewRecommendationRequest
): ResponseEntity<Void> {
val command = request.toCommand(loginMember.memberId)
productReviewService.updateReviewRecommendation(command)
return ResponseEntity
.noContent()
.build()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.petqua.presentation.product.dto

import com.petqua.application.product.dto.ProductReviewReadQuery
import com.petqua.application.product.dto.UpdateReviewRecommendationCommand
import com.petqua.common.domain.dto.PAGING_LIMIT_CEILING
import com.petqua.domain.product.review.ProductReviewSorter
import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC
Expand Down Expand Up @@ -51,3 +52,18 @@ data class ReadAllProductReviewsRequest(
)
}
}

data class UpdateReviewRecommendationRequest(
@Schema(
description = "상품 후기 id",
example = "1"
)
val productReviewId: Long,
) {
fun toCommand(memberId: Long): UpdateReviewRecommendationCommand {
return UpdateReviewRecommendationCommand(
memberId = memberId,
productReviewId = productReviewId
)
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
package com.petqua.application.product.review

import com.petqua.application.product.dto.ProductReviewReadQuery
import com.petqua.application.product.dto.UpdateReviewRecommendationCommand
import com.petqua.common.domain.findByIdOrThrow
import com.petqua.domain.member.MemberRepository
import com.petqua.domain.product.ProductRepository
import com.petqua.domain.product.review.ProductReviewImageRepository
import com.petqua.domain.product.review.ProductReviewRecommendation
import com.petqua.domain.product.review.ProductReviewRecommendationRepository
import com.petqua.domain.product.review.ProductReviewRepository
import com.petqua.domain.product.review.ProductReviewSorter.RECOMMEND_DESC
import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC
import com.petqua.domain.store.StoreRepository
import com.petqua.exception.product.review.ProductReviewException
import com.petqua.exception.product.review.ProductReviewExceptionType.NOT_FOUND_PRODUCT_REVIEW
import com.petqua.test.DataCleaner
import com.petqua.test.fixture.member
import com.petqua.test.fixture.product
import com.petqua.test.fixture.productReview
import com.petqua.test.fixture.productReviewImage
import com.petqua.test.fixture.store
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.inspectors.forAll
import io.kotest.matchers.collections.shouldBeSortedWith
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import java.math.BigDecimal
import org.springframework.boot.test.context.SpringBootTest
Expand All @@ -32,6 +40,7 @@ class ProductReviewServiceTest(
private val storeRepository: StoreRepository,
private val productReviewRepository: ProductReviewRepository,
private val productReviewImageRepository: ProductReviewImageRepository,
private val productReviewRecommendationRepository: ProductReviewRecommendationRepository,
private val dataCleaner: DataCleaner,
) : BehaviorSpec({

Expand Down Expand Up @@ -190,7 +199,7 @@ class ProductReviewServiceTest(
)
)

val savedProductReviews = productReviewRepository.saveAll(
productReviewRepository.saveAll(
listOf(
productReview(
productId = product.id,
Expand Down Expand Up @@ -245,6 +254,79 @@ class ProductReviewServiceTest(
}
}

Given("상품 후기 추천을 토글 할 때") {
val store = storeRepository.save(store(name = "펫쿠아"))
val member = memberRepository.save(member(nickname = "쿠아"))
val product = productRepository.save(
product(
name = "상품1",
storeId = store.id,
discountPrice = BigDecimal.ZERO,
reviewCount = 0,
reviewTotalScore = 0
)
)

val savedProductReview = productReviewRepository.save(
productReview(
productId = product.id,
reviewerId = member.id,
score = 5,
recommendCount = 1,
hasPhotos = false,
)
)

When("상품 후기 ID와 회원 ID를 입력 하면") {
val command = UpdateReviewRecommendationCommand(
productReviewId = savedProductReview.id,
memberId = member.id,
)
productReviewService.updateReviewRecommendation(command)

Then("상품 후기를 추천 한다") {
savedProductReview.recommendCount shouldBe 1
}
}

When("이미 추천한 상품 후기를 다시 추천 하면") {
productReviewRecommendationRepository.save(
ProductReviewRecommendation(
productReviewId = savedProductReview.id,
memberId = member.id,
)
)

val command = UpdateReviewRecommendationCommand(
productReviewId = savedProductReview.id,
memberId = member.id,
)
productReviewService.updateReviewRecommendation(command)

Then("상품 후기 추천을 취소 한다") {
productReviewRecommendationRepository.findByProductReviewIdAndMemberId(
savedProductReview.id,
member.id,
)?.shouldBeNull()

productReviewRepository.findByIdOrThrow(savedProductReview.id).recommendCount shouldBe 0
}
}

When("존재 하지 않는 상품 후기의 ID를 입력하면") {
val command = UpdateReviewRecommendationCommand(
productReviewId = Long.MIN_VALUE,
memberId = member.id,
)

Then("상품 후기를 찾을 수 없다는 예외를 반환한다") {
shouldThrow<ProductReviewException> {
productReviewService.updateReviewRecommendation(command)
}.exceptionType() shouldBe NOT_FOUND_PRODUCT_REVIEW
}
}
}

afterContainer {
dataCleaner.clean()
}
Expand Down
Loading

0 comments on commit fb07730

Please sign in to comment.