Skip to content

Commit b81700d

Browse files
committed
feat: 상품 좋아요 및 좋아요 취소 기능 구현
1 parent 7c76fad commit b81700d

File tree

7 files changed

+156
-2
lines changed

7 files changed

+156
-2
lines changed

src/main/kotlin/com/dh/baro/product/application/ProductFacade.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.dh.baro.product.application.dto.ProductDetailBundle
55
import com.dh.baro.product.application.dto.ProductSliceBundle
66
import com.dh.baro.product.domain.service.CategoryService
77
import com.dh.baro.product.domain.Product
8+
import com.dh.baro.product.domain.service.ProductLikeService
89
import com.dh.baro.product.domain.service.ProductQueryService
910
import com.dh.baro.product.domain.service.ProductService
1011
import org.springframework.stereotype.Service
@@ -16,6 +17,7 @@ class ProductFacade(
1617
private val productService: ProductService,
1718
private val productQueryService: ProductQueryService,
1819
private val categoryService: CategoryService,
20+
private val productLikeService: ProductLikeService,
1921
) {
2022

2123
@Transactional
@@ -52,4 +54,14 @@ class ProductFacade(
5254
val stores = storeService.getStoresByIds(productSlice.content.map { it.storeId }.toSet())
5355
return ProductSliceBundle(productSlice, stores)
5456
}
57+
58+
@Transactional
59+
fun likeProduct(userId: Long, productId: Long) {
60+
productLikeService.likeProduct(userId, productId)
61+
}
62+
63+
@Transactional
64+
fun cancelProductLike(userId: Long, productId: Long) {
65+
productLikeService.cancelProductLike(userId, productId)
66+
}
5567
}

src/main/kotlin/com/dh/baro/product/domain/ProductLike.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
package com.dh.baro.product.domain
22

33
import com.dh.baro.core.BaseTimeEntity
4+
import com.dh.baro.core.IdGenerator
45
import jakarta.persistence.*
56

67
@Entity
7-
@Table(name = "product_likes")
8-
class ProductLike(
8+
@Table(
9+
name = "product_likes",
10+
uniqueConstraints = [
11+
UniqueConstraint(
12+
name = "uk_product_likes_user_product",
13+
columnNames = ["user_id", "product_id"]
14+
)
15+
],
16+
indexes = [
17+
Index(name = "idx_product_likes_product_id", columnList = "product_id")
18+
]
19+
)
20+
class ProductLike private constructor(
921
@Id
1022
@Column(name = "id")
1123
val id: Long,
@@ -19,4 +31,13 @@ class ProductLike(
1931

2032
override fun getId(): Long = id
2133

34+
companion object {
35+
fun create(userId: Long, productId: Long): ProductLike {
36+
return ProductLike(
37+
id = IdGenerator.generate(),
38+
userId = userId,
39+
productId = productId,
40+
)
41+
}
42+
}
2243
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.dh.baro.product.domain.repository
2+
3+
import com.dh.baro.product.domain.ProductLike
4+
import org.springframework.data.jpa.repository.JpaRepository
5+
6+
interface ProductLikeRepository : JpaRepository<ProductLike, Long> {
7+
8+
fun existsByUserIdAndProductId(userId: Long, productId: Long): Boolean
9+
10+
fun deleteByUserIdAndProductId(userId: Long, productId: Long): Int
11+
}

src/main/kotlin/com/dh/baro/product/domain/repository/ProductRepository.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,20 @@ interface ProductRepository : JpaRepository<Product, Long> {
6969
@Param("cursorId") cursorId: Long?,
7070
pageable: Pageable,
7171
): Slice<Product>
72+
73+
@Modifying(clearAutomatically = true, flushAutomatically = true)
74+
@Query("""
75+
UPDATE Product p
76+
SET p.likesCount = p.likesCount + 1
77+
WHERE p.id = :id
78+
""")
79+
fun incrementLikesCount(@Param("id") id: Long): Int
80+
81+
@Modifying(clearAutomatically = true, flushAutomatically = true)
82+
@Query("""
83+
UPDATE Product p
84+
SET p.likesCount = p.likesCount - 1
85+
WHERE p.id = :id AND p.likesCount > 0
86+
""")
87+
fun decrementLikesCount(@Param("id") id: Long): Int
7288
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.dh.baro.product.domain.service
2+
3+
import com.dh.baro.product.domain.ProductLike
4+
import com.dh.baro.product.domain.repository.ProductLikeRepository
5+
import com.dh.baro.product.domain.repository.ProductRepository
6+
import org.springframework.stereotype.Service
7+
import org.springframework.transaction.annotation.Transactional
8+
9+
@Service
10+
class ProductLikeService(
11+
private val productLikeRepository: ProductLikeRepository,
12+
private val productRepository: ProductRepository,
13+
) {
14+
15+
@Transactional
16+
fun likeProduct(userId: Long, productId: Long) {
17+
if (productLikeRepository.existsByUserIdAndProductId(userId, productId)) {
18+
return
19+
}
20+
21+
val productLike = ProductLike.create(userId, productId)
22+
productLikeRepository.save(productLike)
23+
productRepository.incrementLikesCount(productId)
24+
}
25+
26+
@Transactional
27+
fun cancelProductLike(userId: Long, productId: Long) {
28+
val deletedCount = productLikeRepository.deleteByUserIdAndProductId(userId, productId)
29+
if (deletedCount > 0) {
30+
productRepository.decrementLikesCount(productId)
31+
}
32+
}
33+
}

src/main/kotlin/com/dh/baro/product/presentation/ProductController.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.dh.baro.core.Cursor
44
import com.dh.baro.core.ErrorMessage
55
import com.dh.baro.core.SliceResponse
66
import com.dh.baro.core.annotation.CheckAuth
7+
import com.dh.baro.core.annotation.CurrentUser
78
import com.dh.baro.identity.domain.UserRole
89
import com.dh.baro.product.application.ProductFacade
910
import com.dh.baro.product.presentation.dto.*
@@ -70,6 +71,24 @@ class ProductController(
7071
)
7172
}
7273

74+
@PostMapping("/{productId}/likes")
75+
@ResponseStatus(HttpStatus.NO_CONTENT)
76+
override fun likeProduct(
77+
@CurrentUser userId: Long,
78+
@PathVariable productId: String,
79+
) {
80+
productFacade.likeProduct(userId, productId.toLong())
81+
}
82+
83+
@DeleteMapping("/{productId}/likes")
84+
@ResponseStatus(HttpStatus.NO_CONTENT)
85+
override fun cancelProductLike(
86+
@CurrentUser userId: Long,
87+
@PathVariable productId: String,
88+
) {
89+
productFacade.cancelProductLike(userId, productId.toLong())
90+
}
91+
7392
companion object {
7493
private const val DEFAULT_PAGE_SIZE = "21"
7594
}

src/main/kotlin/com/dh/baro/product/presentation/swagger/ProductSwagger.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,48 @@ interface ProductSwagger {
159159
@RequestParam(defaultValue = "21") size: Int
160160
): SliceResponse<ProductListItem>
161161

162+
/* ───────────────────────────── 상품 좋아요 ───────────────────────────── */
163+
@Operation(
164+
summary = "상품 좋아요",
165+
description = """
166+
상품에 좋아요를 추가합니다.
167+
이미 좋아요한 상품의 경우 중복 요청이 무시됩니다 (멱등성 보장).
168+
인증된 사용자만 사용 가능합니다.
169+
""",
170+
parameters = [
171+
Parameter(`in` = ParameterIn.PATH, name = "productId", description = "상품 PK", example = "11", required = true)
172+
],
173+
responses = [
174+
ApiResponse(responseCode = "204", description = "좋아요 성공")
175+
]
176+
)
177+
@PostMapping("/{productId}/likes")
178+
fun likeProduct(
179+
userId: Long,
180+
@PathVariable productId: String,
181+
)
182+
183+
/* ───────────────────────────── 상품 좋아요 취소 ───────────────────────────── */
184+
@Operation(
185+
summary = "상품 좋아요 취소",
186+
description = """
187+
상품 좋아요를 취소합니다.
188+
좋아요하지 않은 상품의 경우 요청이 무시됩니다 (멱등성 보장).
189+
인증된 사용자만 사용 가능합니다.
190+
""",
191+
parameters = [
192+
Parameter(`in` = ParameterIn.PATH, name = "productId", description = "상품 PK", example = "11", required = true)
193+
],
194+
responses = [
195+
ApiResponse(responseCode = "204", description = "좋아요 취소 성공")
196+
]
197+
)
198+
@DeleteMapping("/{productId}/likes")
199+
fun cancelProductLike(
200+
userId: Long,
201+
@PathVariable productId: String,
202+
)
203+
162204
/* ──────────────── 예시 DTO (Swagger 문서 전용) ──────────────── */
163205
@Schema(hidden = true)
164206
private class SlicePopularExample(

0 commit comments

Comments
 (0)