Skip to content

Commit 1e2e1d6

Browse files
authored
Merge pull request #12 from hyujikoh/feat/like
좋아요 도메인 기능 구현
2 parents dfc7ffd + 0e76810 commit 1e2e1d6

28 files changed

+1884
-108
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.loopers.application.like;
2+
3+
import org.springframework.stereotype.Component;
4+
import org.springframework.transaction.annotation.Transactional;
5+
6+
import com.loopers.domain.like.LikeEntity;
7+
import com.loopers.domain.like.LikeService;
8+
import com.loopers.domain.product.ProductEntity;
9+
import com.loopers.domain.product.ProductService;
10+
import com.loopers.domain.user.UserEntity;
11+
import com.loopers.domain.user.UserService;
12+
import com.loopers.support.error.CoreException;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
/**
17+
* 좋아요 애플리케이션 파사드
18+
* <p>
19+
* 좋아요 관련 유스케이스를 조정합니다.
20+
* 여러 도메인 서비스(User, Product, Like)를 조합하여 완전한 비즈니스 흐름을 구현합니다.
21+
*
22+
* @author hyunjikoh
23+
* @since 2025. 11. 11.
24+
*/
25+
@Component
26+
@RequiredArgsConstructor
27+
public class LikeFacade {
28+
private final ProductService productService;
29+
private final UserService userService;
30+
private final LikeService likeService;
31+
32+
/**
33+
* 좋아요를 등록하거나 복원합니다.
34+
*
35+
* @param username 사용자명
36+
* @param productId 상품 ID
37+
* @return 좋아요 정보 DTO
38+
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
39+
*/
40+
@Transactional
41+
public LikeInfo upsertLike(String username, Long productId) {
42+
// 1. 사용자 검증
43+
UserEntity user = userService.getUserByUsername(username);
44+
45+
// 2. 상품 검증
46+
ProductEntity product = productService.getProductDetail(productId);
47+
48+
// 3. 좋아요 등록/복원
49+
LikeEntity likeEntity = likeService.upsertLike(user.getId(), product.getId());
50+
51+
// 4. 상품의 좋아요 수 증가
52+
productService.increaseLikeCount(product.getId());
53+
54+
// 5. DTO 변환 후 반환
55+
return LikeInfo.of(likeEntity, product, user);
56+
}
57+
58+
/**
59+
* 좋아요를 취소합니다.
60+
*
61+
* @param username 사용자명
62+
* @param productId 상품 ID
63+
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
64+
*/
65+
@Transactional
66+
public void unlikeProduct(String username, Long productId) {
67+
// 1. 사용자 검증
68+
UserEntity user = userService.getUserByUsername(username);
69+
70+
// 2. 상품 검증
71+
ProductEntity product = productService.getProductDetail(productId);
72+
73+
// 3. 좋아요 취소
74+
likeService.findLike(user.getId(), productId)
75+
.ifPresent(like -> {
76+
like.delete();
77+
// 4. 상품의 좋아요 수 감소
78+
productService.decreaseLikeCount(product.getId());
79+
});
80+
}
81+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.loopers.application.like;
2+
3+
import com.loopers.domain.like.LikeEntity;
4+
import com.loopers.domain.product.ProductEntity;
5+
import com.loopers.domain.user.UserEntity;
6+
7+
/**
8+
* @author hyunjikoh
9+
* @since 2025. 11. 12.
10+
*/
11+
public record LikeInfo(
12+
String username,
13+
Long productId,
14+
String productName
15+
) {
16+
public static LikeInfo of(LikeEntity like, ProductEntity product, UserEntity user) {
17+
return new LikeInfo(
18+
user.getUsername(),
19+
product.getId(),
20+
product.getName()
21+
);
22+
}
23+
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,30 @@ public record ProductDetailInfo(
1515
Long likeCount,
1616
Integer stockQuantity,
1717
ProductPriceInfo price,
18-
BrandInfo brand
18+
BrandInfo brand,
19+
Boolean isLiked // 사용자의 좋아요 여부 (null: 비로그인, true: 좋아요함, false: 좋아요 안함)
1920
) {
2021
/**
2122
* ProductEntity와 BrandEntity를 조합하여 ProductDetailInfo를 생성한다.
23+
* (비로그인 사용자용 - isLiked는 null)
2224
*
2325
* @param product 상품 엔티티
2426
* @param brand 브랜드 엔티티
2527
* @return ProductDetailInfo
2628
*/
2729
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand) {
30+
return of(product, brand, false);
31+
}
32+
33+
/**
34+
* ProductEntity, BrandEntity, 좋아요 여부를 조합하여 ProductDetailInfo를 생성한다.
35+
*
36+
* @param product 상품 엔티티
37+
* @param brand 브랜드 엔티티
38+
* @param isLiked 사용자의 좋아요 여부 (null: 비로그인)
39+
* @return ProductDetailInfo
40+
*/
41+
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Boolean isLiked) {
2842
if (product == null) {
2943
throw new IllegalArgumentException("상품 정보는 필수입니다.");
3044
}
@@ -47,7 +61,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand) {
4761
brand.getId(),
4862
brand.getName(),
4963
brand.getDescription()
50-
)
64+
),
65+
isLiked
5166
);
5267
}
5368
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
import com.loopers.domain.brand.BrandEntity;
88
import com.loopers.domain.brand.BrandService;
9+
import com.loopers.domain.like.LikeService;
910
import com.loopers.domain.product.ProductEntity;
1011
import com.loopers.domain.product.ProductService;
1112
import com.loopers.domain.product.dto.ProductSearchFilter;
13+
import com.loopers.domain.user.UserService;
1214

1315
import lombok.RequiredArgsConstructor;
1416

@@ -21,6 +23,8 @@
2123
public class ProductFacade {
2224
private final ProductService productService;
2325
private final BrandService brandService;
26+
private final LikeService likeService;
27+
private final UserService userService;
2428

2529
@Transactional(readOnly = true)
2630
public Page<ProductInfo> getProducts(ProductSearchFilter productSearchFilter) {
@@ -30,15 +34,29 @@ public Page<ProductInfo> getProducts(ProductSearchFilter productSearchFilter) {
3034
return products.map(ProductInfo::of);
3135
}
3236

37+
38+
/**
39+
* 상품 상세 정보를 좋아요 정보와 함께 조회합니다.
40+
*
41+
* @param productId 상품 ID
42+
* @param username 사용자명 (null인 경우 비로그인 사용자)
43+
* @return 상품 상세 정보 (좋아요 여부 포함)
44+
*/
3345
@Transactional(readOnly = true)
34-
public ProductDetailInfo getProductDetail(Long id) {
46+
public ProductDetailInfo getProductDetail(Long productId, String username) {
3547
// 1. Product 조회
36-
ProductEntity product = productService.getProductDetail(id);
48+
ProductEntity product = productService.getProductDetail(productId);
3749

3850
// 2. Brand 조회
3951
BrandEntity brand = brandService.getBrandById(product.getBrandId());
4052

41-
// 3. DTO 조합 후 반환
42-
return ProductDetailInfo.of(product, brand);
53+
// 3. 사용자의 좋아요 여부 확인
54+
Boolean isLiked = username != null
55+
? likeService.findLike(userService.getUserByUsername(username).getId(), productId)
56+
.map(like -> like.getDeletedAt() == null)
57+
.orElse(false)
58+
: false;
59+
// 4. DTO 조합 후 반환
60+
return ProductDetailInfo.of(product, brand, isLiked);
4361
}
4462
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.loopers.domain.brand;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
5+
6+
/**
7+
* 브랜드 도메인 생성 요청 DTO
8+
*
9+
* 도메인 레이어에서 사용하는 브랜드 생성 요청 정보입니다.
10+
* Application Layer에서 Domain Layer로 전달되는 데이터 구조입니다.
11+
*
12+
* @author hyunjikoh
13+
* @since 2025. 11. 12.
14+
*/
15+
public record BrandDomainCreateRequest(
16+
@NotBlank(message = "브랜드 이름은 필수입니다.")
17+
@Size(max = 100, message = "브랜드 이름은 100자를 초과할 수 없습니다.")
18+
String name,
19+
20+
String description
21+
) {
22+
/**
23+
* 정적 팩토리 메서드
24+
*
25+
* @param name 브랜드 이름
26+
* @param description 브랜드 설명
27+
* @return BrandDomainCreateRequest 인스턴스
28+
*/
29+
public static BrandDomainCreateRequest of(String name, String description) {
30+
return new BrandDomainCreateRequest(name, description);
31+
}
32+
}
33+

apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEntity.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ public BrandEntity(String name, String description) {
3838

3939
}
4040

41-
public static BrandEntity createBrandEntity(String name, String description) {
42-
if (Objects.isNull(name)) {
41+
/**
42+
* 브랜드 엔티티를 생성합니다.
43+
*
44+
* @param request 브랜드 생성 요청 정보
45+
* @return 생성된 브랜드 엔티티
46+
*/
47+
public static BrandEntity createBrandEntity(BrandDomainCreateRequest request) {
48+
if (Objects.isNull(request)) {
49+
throw new IllegalArgumentException("브랜드 생성 요청 정보는 필수입니다.");
50+
}
51+
52+
if (Objects.isNull(request.name()) || request.name().isBlank()) {
4353
throw new IllegalArgumentException("브랜드 이름은 필수 입력값입니다.");
4454
}
4555

46-
return new BrandEntity(name, description);
56+
return new BrandEntity(request.name(), request.description());
4757
}
4858

4959
@Override

apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111

1212
import lombok.RequiredArgsConstructor;
1313

14+
import jakarta.validation.Valid;
15+
1416
/**
17+
* 브랜드 도메인 서비스
18+
*
19+
* 브랜드 도메인의 비즈니스 로직을 처리합니다.
20+
* 단일 책임 원칙에 따라 브랜드 Repository에만 의존합니다.
21+
*
1522
* @author hyunjikoh
1623
* @since 2025. 11. 9.
1724
*/
@@ -20,11 +27,24 @@
2027
public class BrandService {
2128
private final BrandRepository brandRepository;
2229

30+
/**
31+
* 브랜드 목록을 조회합니다.
32+
*
33+
* @param pageable 페이징 정보
34+
* @return 브랜드 목록 페이지
35+
*/
2336
@Transactional(readOnly = true)
2437
public Page<BrandEntity> listBrands(Pageable pageable) {
2538
return brandRepository.listBrands(pageable);
2639
}
2740

41+
/**
42+
* 브랜드 ID로 브랜드를 조회합니다.
43+
*
44+
* @param id 브랜드 ID
45+
* @return 조회된 브랜드 엔티티
46+
* @throws CoreException 브랜드를 찾을 수 없는 경우
47+
*/
2848
@Transactional(readOnly = true)
2949
public BrandEntity getBrandById(long id) {
3050
return brandRepository.getBrandById(id)
@@ -34,6 +54,13 @@ public BrandEntity getBrandById(long id) {
3454
));
3555
}
3656

57+
/**
58+
* 브랜드 이름으로 브랜드를 조회합니다.
59+
*
60+
* @param name 브랜드 이름
61+
* @return 조회된 브랜드 엔티티
62+
* @throws CoreException 브랜드를 찾을 수 없는 경우
63+
*/
3764
@Transactional(readOnly = true)
3865
public BrandEntity getBrandByName(String name) {
3966
return brandRepository.findByName(name)
@@ -43,8 +70,41 @@ public BrandEntity getBrandByName(String name) {
4370
));
4471
}
4572

73+
/**
74+
* 검색 필터 조건으로 브랜드를 조회합니다.
75+
*
76+
* @param filter 검색 필터
77+
* @param pageable 페이징 정보
78+
* @return 검색된 브랜드 목록 페이지
79+
*/
4680
@Transactional(readOnly = true)
4781
public Page<BrandEntity> searchBrands(BrandSearchFilter filter, Pageable pageable) {
4882
return brandRepository.searchBrands(filter, pageable);
4983
}
84+
85+
/**
86+
* 브랜드를 등록합니다.
87+
*
88+
* 브랜드 등록은 단일 도메인 작업이므로 도메인 서비스에서 트랜잭션 처리합니다.
89+
*
90+
* @param request 브랜드 생성 요청 정보
91+
* @return 등록된 브랜드 엔티티
92+
* @throws CoreException 중복된 브랜드 이름이 존재하는 경우
93+
*/
94+
@Transactional
95+
public BrandEntity registerBrand(@Valid BrandDomainCreateRequest request) {
96+
// 중복 브랜드명 검증
97+
brandRepository.findByName(request.name())
98+
.ifPresent(existingBrand -> {
99+
throw new CoreException(
100+
ErrorType.DUPLICATE_BRAND,
101+
String.format("이미 존재하는 브랜드 이름입니다. (이름: %s)", request.name())
102+
);
103+
});
104+
105+
// 브랜드 엔티티 생성
106+
BrandEntity brandEntity = BrandEntity.createBrandEntity(request);
107+
108+
return brandRepository.save(brandEntity);
109+
}
50110
}

0 commit comments

Comments
 (0)