Skip to content

Commit

Permalink
ROFO-185 리뷰 좋아요 증가/감소 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
lin-chae committed Sep 2, 2024
1 parent d4fac45 commit c995a19
Show file tree
Hide file tree
Showing 14 changed files with 487 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,12 @@ enum class ErrorCode(
REVIEW_RATING_NEGATIVE(HttpStatus.BAD_REQUEST, -14000, "별점은 0점 이상으로 입력해주세요."),
REVIEW_RATING_TOO_HIGH(HttpStatus.BAD_REQUEST, -14000, "별점은 10점 이하로 입력해주세요."),
REVIEW_ID_NON_POSITIVE(HttpStatus.BAD_REQUEST, -14000, "리뷰 ID는 양수여야 합니다."),
NEGATIVE_NUMBER_OF_LIKED(HttpStatus.BAD_REQUEST, -14000, "리뷰 좋아요 수는 음수가 될 수 없습니다."),
NOT_FOUND_FOOD_SPOTS(HttpStatus.NOT_FOUND, -14001, "해당 음식점이 존재하지 않습니다."),
NOT_FOUND_FOOD_SPOTS_REVIEW(HttpStatus.NOT_FOUND, -14002, "해당 리뷰가 존재하지 않습니다."),
NOT_FOOD_SPOTS_REVIEW_OWNER(HttpStatus.FORBIDDEN, -14003, "해당 리뷰의 소유자가 아닙니다."),
ALREADY_LIKED(HttpStatus.CONFLICT, -14004, "이미 좋아요를 누른 리뷰입니다."),
NOT_LIKED(HttpStatus.NOT_FOUND, -14005, "좋아요를 누르지 않은 리뷰입니다."),

// User API error 15000대
USER_ID_NON_POSITIVE(HttpStatus.BAD_REQUEST, -15000, "유저 ID는 양수여야 합니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ interface FoodSpotsControllerSpec {

@Operation(
description = "음식점의 리뷰 리스트 조회 API",
parameters = [
Parameter(name = "size", description = "조회할 개수", example = "10"),
Parameter(name = "lastId", description = "마지막 ID", example = "1"),
Parameter(
name = "sortType",
description = "정렬 방식",
example = "LATEST",
schema = Schema(implementation = ReviewSortType::class),
),
],
responses = [
ApiResponse(
responseCode = "200",
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
const val USER_ENTITY_LOCK_KEY = "USER-ENTITY-LOCK"
const val REVIEW_LIKE_LOCK_KEY = "REVIEW-LIKE-LOCK"
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ object SwaggerTag {
const val USER: String = "G. 유저 API"
const val REWARDS: String = "H. 리워드 API"
const val RANKING: String = "I. 랭킹 API"
const val LIKE: String = "J. 좋아요 API"
const val ADMIN: String = "Z. 관리자 API"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package kr.weit.roadyfoody.review.application.service

import REVIEW_LIKE_LOCK_KEY
import jakarta.persistence.EntityManager
import kr.weit.roadyfoody.common.exception.ErrorCode
import kr.weit.roadyfoody.common.exception.RoadyFoodyBadRequestException
import kr.weit.roadyfoody.global.annotation.DistributedLock
import kr.weit.roadyfoody.review.domain.ReviewLike
import kr.weit.roadyfoody.review.domain.ReviewLikeId
import kr.weit.roadyfoody.review.repository.FoodSpotsReviewRepository
import kr.weit.roadyfoody.review.repository.ReviewLikeRepository
import kr.weit.roadyfoody.review.repository.getReviewByReviewId
import kr.weit.roadyfoody.user.domain.User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class ReviewLikeCommandService(
private val reviewRepository: FoodSpotsReviewRepository,
private val reviewLikeRepository: ReviewLikeRepository,
private val entityManager: EntityManager,
) {
@Transactional
@DistributedLock(lockName = REVIEW_LIKE_LOCK_KEY, identifier = "reviewId")
fun likeReview(
reviewId: Long,
user: User,
) {
val review = reviewRepository.getReviewByReviewId(reviewId)
if (reviewLikeRepository.existsById(ReviewLikeId(review, user))) {
throw RoadyFoodyBadRequestException(ErrorCode.ALREADY_LIKED)
}
review.increaseLike()
reviewLikeRepository.save(ReviewLike(review, entityManager.merge(user)))
}

@Transactional
@DistributedLock(lockName = REVIEW_LIKE_LOCK_KEY, identifier = "reviewId")
fun unlikeReview(
reviewId: Long,
user: User,
) {
val review = reviewRepository.getReviewByReviewId(reviewId)
if (!reviewLikeRepository.existsById(ReviewLikeId(review, user))) {
throw RoadyFoodyBadRequestException(ErrorCode.NOT_LIKED)
}
if (review.likeTotal <= 0) {
throw RoadyFoodyBadRequestException(ErrorCode.NEGATIVE_NUMBER_OF_LIKED)
}
review.decreaseLike()
reviewLikeRepository.deleteById(ReviewLikeId(review, user))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import jakarta.persistence.ManyToOne
import jakarta.persistence.SequenceGenerator
import jakarta.persistence.Table
import kr.weit.roadyfoody.common.domain.BaseTimeEntity
import kr.weit.roadyfoody.common.exception.ErrorCode
import kr.weit.roadyfoody.common.exception.RoadyFoodyBadRequestException
import kr.weit.roadyfoody.foodSpots.domain.FoodSpots
import kr.weit.roadyfoody.user.domain.User

Expand All @@ -31,7 +33,16 @@ class FoodSpotsReview(
@Column(nullable = false, updatable = false, length = 1200)
val contents: String,
@Column(nullable = false)
val likeTotal: Int,
var likeTotal: Int,
) : BaseTimeEntity() {
constructor() : this(0L, FoodSpots(), User.of("", "defaultNickname"), 0, "", 0)

fun increaseLike() {
this.likeTotal++
}

fun decreaseLike() {
require(this.likeTotal > 0) { throw RoadyFoodyBadRequestException(ErrorCode.NEGATIVE_NUMBER_OF_LIKED) }
this.likeTotal--
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kr.weit.roadyfoody.review.presentation.api

import jakarta.validation.constraints.Positive
import kr.weit.roadyfoody.auth.security.LoginUser
import kr.weit.roadyfoody.review.application.service.ReviewLikeCommandService
import kr.weit.roadyfoody.review.presentation.spec.ReviewLikeControllerSpec
import kr.weit.roadyfoody.user.domain.User
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/reviews/{reviewId}/likes")
class ReviewLikeController(
private val reviewLikeCommandService: ReviewLikeCommandService,
) : ReviewLikeControllerSpec {
@PostMapping
@ResponseStatus(CREATED)
override fun likeReview(
@LoginUser
user: User,
@Positive(message = "리뷰 ID는 양수여야 합니다.")
@PathVariable("reviewId") reviewId: Long,
) {
reviewLikeCommandService.likeReview(reviewId, user)
}

@DeleteMapping
@ResponseStatus(NO_CONTENT)
override fun unlikeReview(
@LoginUser
user: User,
@Positive(message = "리뷰 ID는 양수여야 합니다.")
@PathVariable("reviewId") reviewId: Long,
) {
reviewLikeCommandService.unlikeReview(reviewId, user)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kr.weit.roadyfoody.review.presentation.spec

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.constraints.Positive
import kr.weit.roadyfoody.auth.security.LoginUser
import kr.weit.roadyfoody.common.exception.ErrorCode
import kr.weit.roadyfoody.global.swagger.ApiErrorCodeExamples
import kr.weit.roadyfoody.global.swagger.v1.SwaggerTag
import kr.weit.roadyfoody.user.domain.User
import org.springframework.web.bind.annotation.PathVariable

@Tag(name = SwaggerTag.LIKE)
interface ReviewLikeControllerSpec {
@Operation(
description = "리뷰 좋아요 생성 API",
responses = [
ApiResponse(
responseCode = "201",
description = "리뷰 좋아요 성공",
),
],
)
@ApiErrorCodeExamples(
[
ErrorCode.REVIEW_ID_NON_POSITIVE,
ErrorCode.NOT_FOUND_FOOD_SPOTS_REVIEW,
ErrorCode.ALREADY_LIKED,
],
)
fun likeReview(
@LoginUser
user: User,
@Positive(message = "리뷰 ID는 양수여야 합니다.")
@PathVariable("reviewId")
reviewId: Long,
)

@Operation(
description = "리뷰 좋아요 삭제 API",
responses = [
ApiResponse(
responseCode = "204",
description = "리뷰 좋아요 삭제 성공",
),
],
)
@ApiErrorCodeExamples(
[
ErrorCode.NOT_LIKED,
ErrorCode.NEGATIVE_NUMBER_OF_LIKED,
ErrorCode.REVIEW_ID_NON_POSITIVE,
ErrorCode.NOT_FOUND_FOOD_SPOTS_REVIEW,
],
)
fun unlikeReview(
@LoginUser
user: User,
@Positive(message = "리뷰 ID는 양수여야 합니다.")
@PathVariable("reviewId")
reviewId: Long,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.weit.roadyfoody.review.repository

import kr.weit.roadyfoody.review.domain.ReviewLike
import kr.weit.roadyfoody.review.domain.ReviewLikeId
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface ReviewLikeRepository : JpaRepository<ReviewLike, ReviewLikeId>
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ReviewCommandServiceTest :
val imageService = spyk(ImageService(mockk()))
val executor = mockk<ExecutorService>()
val badgeCommandService = mockk<BadgeCommandService>()
val reportService =
val reviewService =
ReviewCommandService(
reviewRepository,
reviewPhotoRepository,
Expand All @@ -65,7 +65,7 @@ class ReviewCommandServiceTest :
every { badgeCommandService.tryChangeBadgeAndIfPromotedGiveBonus(any()) } just runs
`when`("정상적인 데이터와 이미지가 들어올 경우") {
then("정상적으로 저장되어야 한다.") {
reportService.createReview(
reviewService.createReview(
createTestUser(),
createTestReviewRequest(),
createMockPhotoList(ImageFormat.WEBP),
Expand All @@ -77,7 +77,7 @@ class ReviewCommandServiceTest :
every { foodSpotsRepository.findById(any()) } returns Optional.empty()
then("FoodSpotsNotFoundException 이 발생해야 한다.") {
shouldThrow<FoodSpotsNotFoundException> {
reportService.createReview(
reviewService.createReview(
createTestUser(),
createTestReviewRequest(),
createMockPhotoList(ImageFormat.WEBP),
Expand All @@ -98,7 +98,7 @@ class ReviewCommandServiceTest :
every { reviewPhotoRepository.deleteAll(any()) } returns Unit
`when`("정상적인 삭제 요청이 들어올 경우") {
then("정상적으로 삭제되어야 한다.") {
reportService.deleteWithdrewUserReview(createTestUser())
reviewService.deleteWithdrewUserReview(createTestUser())
}
}
}
Expand All @@ -109,7 +109,7 @@ class ReviewCommandServiceTest :
createMockTestReview(createTestUser(TEST_OTHER_USER_ID))
then("예외가 발생한다.") {
shouldThrow<NotFoodSpotsReviewOwnerException> {
reportService.deleteReview(createTestUser(), TEST_REVIEW_ID)
reviewService.deleteReview(createTestUser(), TEST_REVIEW_ID)
}
}
}
Expand All @@ -127,7 +127,7 @@ class ReviewCommandServiceTest :
every { reviewRepository.delete(any()) } returns Unit
every { badgeCommandService.tryChangeBadgeAndIfPromotedGiveBonus(any()) } just runs
then("정상적으로 삭제되어야 한다.") {
reportService.deleteReview(user, TEST_REVIEW_ID)
reviewService.deleteReview(user, TEST_REVIEW_ID)
verify(exactly = 1) {
imageService.remove(any())
reviewPhotoRepository.deleteAll(any())
Expand Down
Loading

0 comments on commit c995a19

Please sign in to comment.