diff --git a/Round3.md b/Round3.md new file mode 100644 index 000000000..84b9ccfa3 --- /dev/null +++ b/Round3.md @@ -0,0 +1,47 @@ +## 도메인 & 객체 설계 전략 + +비즈니스 규칙 캡슐화: 도메인 객체(Entity, VO)는 데이터만 가진 구조체가 아니라, 자신의 비즈니스 규칙을 스스로 검증하고 수행해야 합니다. + + +애플리케이션 서비스의 역할: 서로 다른 도메인 객체들을 조합하고 로직을 조정(Orchestration)하여 기능을 완성하는 데 집중하며, 핵심 비즈니스 로직은 도메인으로 위임합니다. + + + +규칙의 위치: 특정 규칙이 여러 서비스에서 중복되어 나타난다면, 해당 규칙은 도메인 객체의 책임일 가능성이 높으므로 도메인 내부로 옮깁니다. + + +의도적인 설계: 각 기능의 책임 소재와 객체 간 결합도에 대해 개발자의 의도를 명확히 반영하여 개발을 진행합니다. + +## 아키텍처 및 패키지 구성 전략 +본 프로젝트는 **레이어드 아키텍처(Layered Architecture)**를 기반으로 하며, **DIP(의존성 역전 원칙)**를 jpa 관점에서 적당히 편리한 만큼만 적용한다. + +패키지 구조 (Layer + Domain) +패키징은 4개의 계층을 최상위에 두고, 그 하위에 도메인별로 구성합니다. + + + +/interfaces/api: Presentation 레이어로 API 컨트롤러와 요청/응답 객체가 위치합니다. + + + +/application/..: Application 레이어로 도메인 레이어를 조합하여 유스케이스 기능을 제공합니다. + + + +/domain/..: Domain 레이어로 도메인 객체(Entity, VO, Domain Service)와 Repository 인터페이스가 위치합니다. + + + +/infrastructure/..: Infrastructure 레이어로 JPA, Redis 등 기술적인 Repository 구현체를 제공합니다. + + +데이터 전달 객체(DTO) 정책 + +DTO 분리: API 계층에서 사용하는 Request/Response DTO와 Application 계층에서 사용하는 DTO를 엄격히 분리하여 작성합니다. + +의존성 및 테스트 전략 +DIP 적용: 의존성 방향은 항상 Domain을 향해야 합니다. Infrastructure 구현체는 Domain에 정의된 인터페이스를 상속합니다. + + +단위 테스트: 핵심 도메인 로직은 외부 의존성이 분리된 상태에서 Fake 또는 Stub을 사용하여 테스트 가능한 구조로 설계하고 검증합니다. ++2 \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..b592d7405 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + @Transactional + public BrandInfo register(String name, String description) { + Brand brand = brandService.register(name, description); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getBrand(Long id) { + return BrandInfo.from(brandService.getBrand(id)); + } + + @Transactional(readOnly = true) + public List getBrands() { + return brandService.getBrands().stream() + .map(BrandInfo::from) + .toList(); + } + + @Transactional + public void deleteBrand(Long id) { + productService.deleteProductsByBrandId(id); + brandService.deleteBrand(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..5e848e729 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +public record BrandInfo(Long id, String name, String description) { + + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName(), brand.getDescription()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..a635074ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,58 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final UserService userService; + private final BrandService brandService; + + @Transactional(readOnly = true) + public List getLikedProducts(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + List productIds = likeService.getLikedProductIds(user.getId()); + // 상품 목록을 IN 쿼리로 한 번에 조회 (N+1 방지) + List products = productService.getProductsByIds(productIds); + // 브랜드도 IN 쿼리로 한 번에 조회 후 Map으로 변환 + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + return products.stream() + .map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))) + .toList(); + } + + @Transactional + public void addLike(String loginId, String rawPassword, Long productId) { + User user = userService.authenticate(loginId, rawPassword); + Product product = productService.getProduct(productId); + likeService.addLike(user.getId(), productId); + product.increaseLikes(); + } + + @Transactional + public void removeLike(String loginId, String rawPassword, Long productId) { + User user = userService.authenticate(loginId, rawPassword); + Product product = productService.getProduct(productId); + likeService.removeLike(user.getId(), productId); + product.decreaseLikes(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..c5a4625ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,126 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final UserService userService; + private final BrandService brandService; + + @Transactional + public OrderInfo createOrder(String loginId, String rawPassword, + List items) { + User user = userService.authenticate(loginId, rawPassword); + + List products = new ArrayList<>(); + long totalAmount = 0L; + for (OrderRequest.OrderItemRequest item : items) { + Product product = productService.getProduct(item.productId()); + product.decreaseStock(item.quantity()); + totalAmount += (long) product.getPrice() * item.quantity(); + products.add(product); + } + + user.deductBalance(totalAmount); + + // 주문 시점의 브랜드명을 스냅샷으로 저장하기 위해 브랜드 정보를 한 번에 조회 (N+1 방지) + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + + String orderNumber = orderService.generateOrderNumber(); + Order order = orderService.createOrder(user.getId(), orderNumber, totalAmount); + + // 상품명, 브랜드명, 이미지 URL, 단가를 스냅샷으로 저장 (이후 상품 정보 변경에도 주문 내역 보존) + for (int i = 0; i < items.size(); i++) { + Product product = products.get(i); + OrderRequest.OrderItemRequest item = items.get(i); + Brand brand = brandMap.get(product.getBrandId()); + orderService.createOrderItem(order.getId(), product.getId(), + product.getName(), brand.getName(), product.getImageUrl(), product.getPrice(), item.quantity()); + } + + List orderItems = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional(readOnly = true) + public Page getOrders(String loginId, String rawPassword, Pageable pageable) { + User user = userService.authenticate(loginId, rawPassword); + Page orders = orderService.getOrders(user.getId(), pageable); + + List orderIds = orders.stream().map(Order::getId).toList(); + Map> itemsByOrderId = orderService.getOrderItemsByOrderIds(orderIds).stream() + .collect(Collectors.groupingBy( + OrderItem::getOrderId, + Collectors.mapping(OrderItemInfo::from, Collectors.toList()) + )); + + return orders.map(order -> + OrderInfo.from(order, itemsByOrderId.getOrDefault(order.getId(), List.of())) + ); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId) { + User user = userService.authenticate(loginId, rawPassword); + Order order = orderService.getOrder(orderId); + if (!order.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional + public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { + User user = userService.authenticate(loginId, rawPassword); + Order found = orderService.getOrder(orderId); + if (!found.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + Order order = orderService.cancelOrder(found); + + List orderItems = orderService.getOrderItems(orderId); + for (OrderItem item : orderItems) { + Product product = productService.getProduct(item.getProductId()); + product.increaseStock(item.getQuantity()); + } + + user.restoreBalance(order.getTotalAmount()); + + return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional + public OrderInfo approveOrder(String loginId, String rawPassword, Long orderId) { + userService.authenticate(loginId, rawPassword); + Order order = orderService.approveOrder(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..d1d03ddaf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public record OrderInfo( + Long id, + Long userId, + String orderNumber, + OrderStatus status, + Long totalAmount, + List items +) { + public static OrderInfo from(Order order, List items) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getOrderNumber(), + order.getStatus(), + order.getTotalAmount(), + items + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..d0f0cdec2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long id, + Long productId, + String productName, + String brandName, + String imageUrl, + Integer price, + Integer quantity +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getBrandName(), + item.getImageUrl(), + item.getPrice(), + item.getQuantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java new file mode 100644 index 000000000..b93d68d15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -0,0 +1,12 @@ +package com.loopers.application.order; + +import java.util.List; + +public record OrderRequest( + List items +) { + public record OrderItemRequest( + Long productId, + Integer quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..9e62ea2b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,83 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public ProductInfo createProduct(Long brandId, String name, Integer price, Integer stock, + String description, String imageUrl) { + Brand brand = brandService.getBrand(brandId); + Product product = productService.createProduct(brandId, name, price, stock, description, imageUrl); + return ProductInfo.from(product, brand); + } + + @Transactional(readOnly = true) + public ProductInfo getProductDetail(Long id) { + Product product = productService.getProduct(id); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort) + ); + Page products = productService.getProducts(brandId, sortedPageable); + + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + + return products.map(p -> { + Brand brand = brandMap.get(p.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return ProductInfo.from(p, brand); + }); + } + + @Transactional + public ProductInfo updateProduct(Long id, String name, Integer price, Integer stock, + String description, String imageUrl) { + Product product = productService.updateProduct(id, name, price, stock, description, imageUrl); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + @Transactional + public void deleteProduct(Long id) { + productService.deleteProduct(id); + } + + private Sort resolveSort(String sort) { + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likesCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..9c1517619 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,41 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl +) { + public static ProductInfo from(Product product, Brand brand) { + if (product == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 정보가 없습니다."); + } + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드 정보를 찾을 수 없습니다."); + } + if (!product.getBrandId().equals(brand.getId())) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품-브랜드 매핑이 올바르지 않습니다."); + } + return new ProductInfo( + product.getId(), + product.getBrandId(), + brand.getName(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getLikesCount(), + product.getDescription(), + product.getImageUrl() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..aaf94f042 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,20 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + return UserInfo.fromWithMaskedName(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index dbfcf4789..bf09becc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -2,14 +2,15 @@ import com.loopers.domain.user.User; -public record UserInfo(String loginId, String name, String birthday, String email) { +public record UserInfo(String loginId, String name, String birthday, String email, Long balance) { public static UserInfo from(User user) { return new UserInfo( user.getLoginId(), user.getName(), user.getBirthday(), - user.getEmail() + user.getEmail(), + user.getBalance() ); } @@ -18,7 +19,8 @@ public static UserInfo fromWithMaskedName(User user) { user.getLoginId(), user.getMaskedName(), user.getBirthday(), - user.getEmail() + user.getEmail(), + user.getBalance() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..7047d89c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,31 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "brands") +@Getter +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + protected Brand() {} + + public Brand(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..910967cac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.brand; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + Optional findById(Long id); + Optional findByName(String name); + List findAll(); + List findAllByIds(Collection ids); + Brand save(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..678fa6ad1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,49 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public Brand register(String name, String description) { + brandRepository.findByName(name).ifPresent(b -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + return brandRepository.save(new Brand(name, description)); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getBrands() { + return brandRepository.findAll(); + } + + @Transactional(readOnly = true) + public List getBrandsByIds(Collection ids) { + return brandRepository.findAllByIds(ids); + } + + @Transactional + public void deleteBrand(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + brand.delete(); + brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..688a16263 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) +) +@Getter +public class Like extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected Like() {} + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 null일 수 없습니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 null일 수 없습니다."); + } + this.userId = userId; + this.productId = productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..ea4ff98c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findByUserId(Long userId); + + Like save(Like like); + + void delete(Like like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..cc2ffb43a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + @Transactional(readOnly = true) + public List getLikedProductIds(Long userId) { + return likeRepository.findByUserId(userId).stream() + .map(Like::getProductId) + .toList(); + } + + @Transactional + public Like addLike(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(l -> { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + }); + return likeRepository.save(new Like(userId, productId)); + } + + @Transactional + public void removeLike(Long userId, Long productId) { + Like like = likeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); + likeRepository.delete(like); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..d5f70267f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,60 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "orders") +@Getter +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "order_number", nullable = false, unique = true, length = 50) + private String orderNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + protected Order() {} + + public Order(Long userId, String orderNumber, Long totalAmount) { + if (totalAmount == null || totalAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 0보다 커야 합니다."); + } + this.userId = userId; + this.orderNumber = orderNumber; + this.totalAmount = totalAmount; + this.status = OrderStatus.PENDING; + } + + public void approve() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "대기 중인 주문만 승인할 수 있습니다."); + } + this.status = OrderStatus.CONFIRMED; + } + + public void cancel() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "대기 중인 주문만 취소할 수 있습니다."); + } + this.status = OrderStatus.CANCELLED; + } + + public boolean isPending() { + return this.status == OrderStatus.PENDING; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..db17586fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,47 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "order_items") +@Getter +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "brand_name", length = 100) + private String brandName; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + protected OrderItem() {} + + public OrderItem(Long orderId, Long productId, String productName, String brandName, + String imageUrl, Integer price, Integer quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.brandName = brandName; + this.imageUrl = imageUrl; + this.price = price; + this.quantity = quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..65e3c7e02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List findByOrderId(Long orderId); + + OrderItem save(OrderItem orderItem); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..8f43beb91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface OrderRepository { + + Optional findById(Long id); + + Page findByUserId(Long userId, Pageable pageable); + + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..e997598bf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,65 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public Order createOrder(Long userId, String orderNumber, Long totalAmount) { + return orderRepository.save(new Order(userId, orderNumber, totalAmount)); + } + + @Transactional + public OrderItem createOrderItem(Long orderId, Long productId, String productName, + String brandName, String imageUrl, Integer price, Integer quantity) { + return orderItemRepository.save(new OrderItem(orderId, productId, productName, brandName, imageUrl, price, quantity)); + } + + @Transactional(readOnly = true) + public Order getOrder(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getOrders(Long userId, Pageable pageable) { + return orderRepository.findByUserId(userId, pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findByOrderId(orderId); + } + + @Transactional + public Order cancelOrder(Order order) { + order.cancel(); + return orderRepository.save(order); + } + + @Transactional + public Order approveOrder(Long id) { + Order order = getOrder(id); + order.approve(); + return orderRepository.save(order); + } + + public String generateOrderNumber() { + String date = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + String uuid = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + return "ORD-" + date + "-" + uuid; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..ee3d84a77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + PENDING, + CONFIRMED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..47d2395fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,99 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; + +@Entity +@Table(name = "products") +@Getter +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Version + @Column(name = "version") + private Integer version; + + @Column(name = "likes_count", nullable = false) + private Integer likesCount = 0; + + protected Product() {} + + public Product(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null || price <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다."); + } + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.imageUrl = imageUrl; + } + + public void decreaseStock(int quantity) { + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; + } + + public void increaseStock(int quantity) { + this.stock += quantity; + } + + public void increaseLikes() { + this.likesCount++; + } + + public void decreaseLikes() { + if (this.likesCount > 0) { + this.likesCount--; + } + } + + public void update(String name, Integer price, Integer stock, String description, String imageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null || price <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다."); + } + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.imageUrl = imageUrl; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..44c89128e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Optional findById(Long id); + Page findProducts(Long brandId, Pageable pageable); + List findAllByBrandId(Long brandId); + List findAllByIds(List ids); + Product save(Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..8aed757e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,63 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public Product createProduct(Long brandId, String name, Integer price, Integer stock, + String description, String imageUrl) { + return productRepository.save(new Product(brandId, name, price, stock, description, imageUrl)); + } + + @Transactional(readOnly = true) + public Product getProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getProductsByIds(List ids) { + return productRepository.findAllByIds(ids); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, Pageable pageable) { + return productRepository.findProducts(brandId, pageable); + } + + @Transactional + public Product updateProduct(Long id, String name, Integer price, Integer stock, + String description, String imageUrl) { + Product product = getProduct(id); + product.update(name, price, stock, description, imageUrl); + return productRepository.save(product); + } + + @Transactional + public void deleteProduct(Long id) { + Product product = getProduct(id); + product.delete(); + productRepository.save(product); + } + + @Transactional + public void deleteProductsByBrandId(Long brandId) { + productRepository.findAllByBrandId(brandId).forEach(product -> { + product.delete(); + productRepository.save(product); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 6d7937fb3..346f71626 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -32,6 +32,9 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false, length = 100) private String email; + @Column(name = "balance", nullable = false) + private Long balance = 0L; + protected User() {} public User(String loginId, String encryptedPassword, String name, String birthday, String email) { @@ -42,6 +45,17 @@ public User(String loginId, String encryptedPassword, String name, String birthd this.email = email; } + public void deductBalance(Long amount) { + if (this.balance < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "잔액이 부족합니다."); + } + this.balance -= amount; + } + + public void restoreBalance(Long amount) { + this.balance += amount; + } + public String getMaskedName() { if (name.length() <= 1) { return "*"; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index bc76ebd6c..62773c5b5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -4,5 +4,6 @@ public interface UserRepository { Optional findByLoginId(String loginId); + Optional findById(Long id); User save(User user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 804e42a06..8af671664 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.application.user.UserInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -29,7 +28,7 @@ public User signUp(String loginId, String rawPassword, String name, String birth } @Transactional(readOnly = true) - public UserInfo getMyInfo(String loginId, String rawPassword) { + public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); @@ -37,6 +36,6 @@ public UserInfo getMyInfo(String loginId, String rawPassword) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); } - return UserInfo.fromWithMaskedName(user); + return user; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..2cc4b7bf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByNameAndDeletedAtIsNull(String name); + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); + List findAllByIdInAndDeletedAtIsNull(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..f0b63b006 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtIsNull(name); + } + + @Override + public List findAll() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByIds(Collection ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..4e54fc889 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..3bf31108d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findByUserId(Long userId) { + return likeJpaRepository.findByUserId(userId); + } + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..575d16975 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..bd29c4611 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List findByOrderId(Long orderId) { + return orderItemJpaRepository.findByOrderId(orderId); + } + + @Override + public OrderItem save(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..1402eb26f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findById(Long id); + + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..af55e3c2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public Page findByUserId(Long userId, Pageable pageable) { + return orderJpaRepository.findByUserId(userId, pageable); + } + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..b314f2f25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + List findAllByIdInAndDeletedAtIsNull(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..76dbeb278 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Page findProducts(Long brandId, Pageable pageable) { + if (brandId != null) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..d89ee854b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..8cb4c2810 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..e55b9c69c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse register( + @RequestBody BrandV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } + + @GetMapping + public ApiResponse> getBrands() { + List infos = brandFacade.getBrands(); + return ApiResponse.success(infos.stream().map(BrandV1Dto.BrandResponse::from).toList()); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..63e88f9a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record CreateRequest(String name, String description) { + } + + public record BrandResponse(Long id, String name, String description) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..578ad3d86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/likes") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @GetMapping + public ApiResponse> getLikedProducts( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword + ) { + List products = likeFacade.getLikedProducts(loginId, rawPassword); + return ApiResponse.success(products.stream().map(LikeV1Dto.LikedProductResponse::from).toList()); + } + + @PostMapping + public ApiResponse addLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @RequestBody LikeV1Dto.AddLikeRequest request + ) { + if (request == null || request.productId() == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다."); + } + likeFacade.addLike(loginId, rawPassword, request.productId()); + return ApiResponse.success(null); + } + + @DeleteMapping("/{productId}") + public ApiResponse removeLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long productId + ) { + likeFacade.removeLike(loginId, rawPassword, productId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..2d1ad6755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.product.ProductInfo; + +public class LikeV1Dto { + + public record AddLikeRequest( + Long productId + ) {} + + public record LikedProductResponse( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl + ) { + public static LikedProductResponse from(ProductInfo info) { + return new LikedProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likesCount(), + info.description(), + info.imageUrl() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..5f83f96bb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,76 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderInfo info = orderFacade.createOrder(loginId, rawPassword, request.toOrderItemRequests()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @GetMapping + public ApiResponse> getOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PageableDefault(size = 20) Pageable pageable + ) { + Page infos = orderFacade.getOrders(loginId, rawPassword, pageable); + return ApiResponse.success(infos.map(OrderV1Dto.OrderResponse::from)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.getOrderDetail(loginId, rawPassword, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @DeleteMapping("/{orderId}") + public ApiResponse cancelOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.cancelOrder(loginId, rawPassword, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @PatchMapping("/{orderId}/approve") + public ApiResponse approveOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.approveOrder(loginId, rawPassword, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..740b7c181 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,64 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderRequest; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + List items + ) { + public record OrderItemRequest( + Long productId, + Integer quantity + ) {} + + public List toOrderItemRequests() { + return items.stream() + .map(i -> new OrderRequest.OrderItemRequest(i.productId(), i.quantity())) + .toList(); + } + } + + public record OrderItemResponse( + Long id, + Long productId, + String productName, + Integer price, + Integer quantity + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.id(), + info.productId(), + info.productName(), + info.price(), + info.quantity() + ); + } + } + + public record OrderResponse( + Long id, + Long userId, + String orderNumber, + OrderStatus status, + Long totalAmount, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.userId(), + info.orderNumber(), + info.status(), + info.totalAmount(), + info.items().stream().map(OrderItemResponse::from).toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..8fda8236a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse createProduct( + @RequestBody ProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.brandId(), request.name(), request.price(), + request.stock(), request.description(), request.imageUrl() + ); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @PageableDefault(size = 20) Pageable pageable + ) { + Page infos = productFacade.getProducts(brandId, sort, pageable); + return ApiResponse.success(infos.map(ProductV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @PatchMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable Long productId, + @RequestBody ProductV1Dto.UpdateRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, request.name(), request.price(), + request.stock(), request.description(), request.imageUrl() + ); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct(@PathVariable Long productId) { + productFacade.deleteProduct(productId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..796e6e0cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +public class ProductV1Dto { + + public record CreateRequest( + Long brandId, + String name, + Integer price, + Integer stock, + String description, + String imageUrl + ) {} + + public record UpdateRequest( + String name, + Integer price, + Integer stock, + String description, + String imageUrl + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likesCount(), + info.description(), + info.imageUrl() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 9f4a0ec62..b967734a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; @@ -18,6 +19,7 @@ public class UserV1Controller { private final UserService userService; + private final UserFacade userFacade; @PostMapping public ApiResponse signUp( @@ -39,7 +41,7 @@ public ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - UserInfo info = userService.getMyInfo(loginId, loginPw); + UserInfo info = userFacade.getMyInfo(loginId, loginPw); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 4f33c5b42..1db320b01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -7,9 +7,9 @@ public class UserV1Dto { public record SignUpRequest(String loginId, String password, String name, String birthday, String email) { } - public record UserResponse(String loginId, String name, String birthday, String email) { + public record UserResponse(String loginId, String name, String birthday, String email, Long balance) { public static UserResponse from(UserInfo info) { - return new UserResponse(info.loginId(), info.name(), info.birthday(), info.email()); + return new UserResponse(info.loginId(), info.name(), info.birthday(), info.email(), info.balance()); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..7f19bb338 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,137 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock + private OrderService orderService; + + @Mock + private ProductService productService; + + @Mock + private UserService userService; + + @Mock + private BrandService brandService; + + private OrderFacade orderFacade; + + @BeforeEach + void setUp() { + orderFacade = new OrderFacade(orderService, productService, userService, brandService); + } + + @DisplayName("주문 상세 조회") + @Nested + class GetOrderDetail { + + @DisplayName("본인 주문을 조회하면, 주문 정보를 반환한다.") + @Test + void returnsOrderInfo_whenUserOwnsTheOrder() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L (BaseEntity 기본값), 동일한 userId로 주문 생성 + Order order = new Order(user.getId(), "ORD-20240101-ABCD1234", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.getOrder(1L)).willReturn(order); + given(orderService.getOrderItems(1L)).willReturn(List.of()); + + // Act + OrderInfo result = orderFacade.getOrderDetail(loginId, rawPassword, 1L); + + // Assert + assertThat(result.orderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + assertThat(result.totalAmount()).isEqualTo(50000L); + } + + @DisplayName("다른 사용자의 주문을 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenUserDoesNotOwnTheOrder() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L, 다른 사용자(99L) 소유의 주문 + Order order = new Order(99L, "ORD-20240101-ABCD9999", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.getOrder(1L)).willReturn(order); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + orderFacade.getOrderDetail(loginId, rawPassword, 1L) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 승인") + @Nested + class ApproveOrder { + + @DisplayName("인증에 성공하면, 승인된 주문 정보를 반환한다.") + @Test + void returnsApprovedOrderInfo_whenAuthenticated() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L (BaseEntity 기본값) + Order order = new Order(0L, "ORD-20240101-ABCD1234", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.approveOrder(1L)).willReturn(order); + given(orderService.getOrderItems(1L)).willReturn(List.of()); + + // Act + OrderInfo result = orderFacade.approveOrder(loginId, rawPassword, 1L); + + // Assert + assertThat(result.orderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + } + + @DisplayName("인증에 실패하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAuthenticationFails() { + // Arrange + given(userService.authenticate("nouser", "Test1234!")) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + orderFacade.approveOrder("nouser", "Test1234!", 1L) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..46a3990d8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,92 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + productFacade = new ProductFacade(productService, brandService); + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("모든 브랜드 정보가 존재하면, 상품 목록을 반환한다.") + @Test + void returnsProductPage_whenAllBrandsExist() { + // Arrange + Long brandId = 1L; + // Brand.getId() == 0L (BaseEntity 기본값), product.getBrandId()를 동일하게 맞춤 + Product product = new Product(0L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + Page productPage = new PageImpl<>(List.of(product)); + + given(productService.getProducts(eq(brandId), any(Pageable.class))).willReturn(productPage); + given(brandService.getBrandsByIds(any())).willReturn(List.of(brand)); + + // Act + Page result = productFacade.getProducts(brandId, "latest", PageRequest.of(0, 10)); + + // Assert + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).name()).isEqualTo("상품A"); + assertThat(result.getContent().get(0).brandName()).isEqualTo("브랜드A"); + } + + @DisplayName("일부 상품의 브랜드 정보가 누락되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsMissingForProduct() { + // Arrange + Long brandId = 1L; + // brandId=99L 상품인데, brandService는 해당 브랜드를 반환하지 않음 + Product product = new Product(99L, "고아상품", 5000, 10, "설명", "https://img.url"); + Page productPage = new PageImpl<>(List.of(product)); + + given(productService.getProducts(eq(brandId), any(Pageable.class))).willReturn(productPage); + given(brandService.getBrandsByIds(any())).willReturn(List.of()); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + productFacade.getProducts(brandId, "latest", PageRequest.of(0, 10)) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java new file mode 100644 index 000000000..a22ced20d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductInfoTest { + + @DisplayName("ProductInfo.from()") + @Nested + class From { + + @DisplayName("유효한 상품과 브랜드로 생성하면, ProductInfo를 반환한다.") + @Test + void returnsProductInfo_whenProductAndBrandAreValid() { + // Arrange + // Brand.getId() == 0L (BaseEntity 기본값), product.getBrandId()를 동일하게 맞춤 + Product product = new Product(0L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + ProductInfo result = ProductInfo.from(product, brand); + + // Assert + assertThat(result.name()).isEqualTo("상품A"); + assertThat(result.brandName()).isEqualTo("브랜드A"); + } + + @DisplayName("product가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIsNull() { + // Arrange + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(null, brand) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("brand가 null이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsNull() { + // Arrange + Product product = new Product(1L, "상품A", 10000, 100, "설명", "https://img.url"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(product, null) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("상품의 brandId와 브랜드의 id가 다르면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdMismatch() { + // Arrange + // product.getBrandId() == 99L, brand.getId() == 0L (BaseEntity 기본값) → 불일치 + Product product = new Product(99L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(product, brand) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java new file mode 100644 index 000000000..a8af548ee --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java @@ -0,0 +1,85 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserFacadeTest { + + @Mock + private UserService userService; + + private UserFacade userFacade; + + @BeforeEach + void setUp() { + userFacade = new UserFacade(userService); + } + + @DisplayName("내정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("인증에 성공하면, 마스킹된 회원 정보를 반환한다.") + @Test + void returnsMaskedUserInfo_whenAuthenticated() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + + // Act + UserInfo result = userFacade.getMyInfo(loginId, rawPassword); + + // Assert + assertThat(result.loginId()).isEqualTo("testuser"); + assertThat(result.name()).isEqualTo("홍길*"); + } + + @DisplayName("존재하지 않는 loginId이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLoginIdDoesNotExist() { + // Arrange + given(userService.authenticate("nouser", "Test1234!")) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userFacade.getMyInfo("nouser", "Test1234!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("비밀번호가 틀리면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsWrong() { + // Arrange + given(userService.authenticate("testuser", "wrongpw1!")) + .willThrow(new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userFacade.getMyInfo("testuser", "wrongpw1!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..15bbccf1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,154 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("이미 존재하는 이름이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenNameAlreadyExists() { + // Arrange + given(brandRepository.findByName("나이키")) + .willReturn(Optional.of(new Brand("나이키", "스포츠 브랜드"))); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.register("나이키", "또 다른 설명")); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("유효한 정보로 등록하면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenInputIsValid() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findByName("나이키")).willReturn(Optional.empty()); + given(brandRepository.save(any(Brand.class))).willReturn(brand); + + // Act + Brand result = brandService.register("나이키", "스포츠 브랜드"); + + // Assert + assertThat(result.getName()).isEqualTo("나이키"); + } + } + + @DisplayName("브랜드 단건 조회") + @Nested + class GetBrand { + + @DisplayName("존재하는 ID로 조회하면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenIdExists() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // Act + Brand result = brandService.getBrand(1L); + + // Assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.getBrand(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetBrands { + + @DisplayName("전체 브랜드 목록을 반환한다.") + @Test + void returnsBrandList() { + // Arrange + given(brandRepository.findAll()) + .willReturn(List.of(new Brand("나이키", ""), new Brand("아디다스", ""))); + + // Act + List result = brandService.getBrands(); + + // Assert + assertThat(result).hasSize(2); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class DeleteBrand { + + @DisplayName("존재하는 ID로 삭제하면, 브랜드가 soft delete 된다.") + @Test + void softDeletesBrand_whenIdExists() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + given(brandRepository.save(brand)).willReturn(brand); + + // Act + brandService.deleteBrand(1L); + + // Assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.deleteBrand(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..5146063b8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, + () -> new Brand(null, "스포츠 브랜드")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + CoreException exception = assertThrows(CoreException.class, + () -> new Brand(" ", "스포츠 브랜드")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 이름으로 생성하면, 브랜드가 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..a341bfdab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,131 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @Mock + private LikeRepository likeRepository; + + private LikeService likeService; + + @BeforeEach + void setUp() { + likeService = new LikeService(likeRepository); + } + + @DisplayName("좋아요한 상품 ID 목록 조회") + @Nested + class GetLikedProductIds { + + @DisplayName("좋아요 목록이 있으면, productId 목록을 반환한다.") + @Test + void returnsProductIds_whenLikesExist() { + // Arrange + given(likeRepository.findByUserId(1L)) + .willReturn(List.of(new Like(1L, 2L), new Like(1L, 3L))); + + // Act + List result = likeService.getLikedProductIds(1L); + + // Assert + assertThat(result).containsExactly(2L, 3L); + } + + @DisplayName("좋아요 목록이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoLikesExist() { + // Arrange + given(likeRepository.findByUserId(1L)).willReturn(List.of()); + + // Act + List result = likeService.getLikedProductIds(1L); + + // Assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("좋아요 추가") + @Nested + class AddLike { + + @DisplayName("이미 좋아요를 눌렀으면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenAlreadyLiked() { + // Arrange + given(likeRepository.findByUserIdAndProductId(1L, 2L)) + .willReturn(Optional.of(new Like(1L, 2L))); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> likeService.addLike(1L, 2L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("좋아요를 누르지 않은 상태면, Like를 반환한다.") + @Test + void returnsLike_whenNotYetLiked() { + // Arrange + Like like = new Like(1L, 2L); + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.empty()); + given(likeRepository.save(any(Like.class))).willReturn(like); + + // Act + Like result = likeService.addLike(1L, 2L); + + // Assert + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getProductId()).isEqualTo(2L); + } + } + + @DisplayName("좋아요 취소") + @Nested + class RemoveLike { + + @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + // Arrange + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> likeService.removeLike(1L, 2L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("좋아요가 존재하면, 좋아요를 삭제한다.") + @Test + void deletesLike_whenLikeExists() { + // Arrange + Like like = new Like(1L, 2L); + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.of(like)); + + // Act & Assert (no exception thrown) + likeService.removeLike(1L, 2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..121999e68 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,47 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("좋아요 생성") + @Nested + class Create { + + @DisplayName("유효한 userId, productId로 생성하면, Like가 반환된다.") + @Test + void returnsLike_whenInputIsValid() { + // Act + Like like = new Like(1L, 2L); + + // Assert + assertThat(like.getUserId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(2L); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Like(null, 2L)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Like(1L, null)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..31483d981 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,153 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderItemRepository orderItemRepository; + + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderService(orderRepository, orderItemRepository); + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("유효한 정보로 생성하면, 주문을 반환한다.") + @Test + void returnsOrder_whenInputIsValid() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.save(any(Order.class))).willReturn(order); + + // Act + Order result = orderService.createOrder(1L, "ORD-20240101-ABCD1234", 50000L); + + // Assert + assertThat(result.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING); + } + } + + @DisplayName("주문 단건 조회") + @Nested + class GetOrder { + + @DisplayName("존재하는 ID로 조회하면, 주문을 반환한다.") + @Test + void returnsOrder_whenIdExists() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.findById(1L)).willReturn(Optional.of(order)); + + // Act + Order result = orderService.getOrder(1L); + + // Assert + assertThat(result.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> orderService.getOrder(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 목록 조회") + @Nested + class GetOrders { + + @DisplayName("userId로 주문 목록을 페이지로 반환한다.") + @Test + void returnsOrderPage_byUserId() { + // Arrange + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of( + new Order(1L, "ORD-20240101-ABCD1234", 50000L) + )); + given(orderRepository.findByUserId(1L, pageable)).willReturn(page); + + // Act + Page result = orderService.getOrders(1L, pageable); + + // Assert + assertThat(result.getContent()).hasSize(1); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + + @DisplayName("PENDING 상태의 주문을 취소하면, CANCELLED 상태가 된다.") + @Test + void cancelsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.save(order)).willReturn(order); + + // Act + Order result = orderService.cancelOrder(order); + + // Assert + assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + } + + @DisplayName("주문 승인") + @Nested + class ApproveOrder { + + @DisplayName("PENDING 상태의 주문을 승인하면, CONFIRMED 상태가 된다.") + @Test + void approvesOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.findById(1L)).willReturn(Optional.of(order)); + given(orderRepository.save(order)).willReturn(order); + + // Act + Order result = orderService.approveOrder(1L); + + // Assert + assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..fee7b5bef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("주문 항목 생성") + @Nested + class CreateOrderItem { + + @DisplayName("유효한 정보로 생성하면, 스냅샷 정보가 저장된다.") + @Test + void savesSnapshot_whenInputIsValid() { + // Act + OrderItem item = new OrderItem(1L, 2L, "나이키 신발", "나이키", "url", 10000, 3); + + // Assert + assertThat(item.getProductName()).isEqualTo("나이키 신발"); + assertThat(item.getBrandName()).isEqualTo("나이키"); + assertThat(item.getImageUrl()).isEqualTo("url"); + assertThat(item.getPrice()).isEqualTo(10000); + assertThat(item.getQuantity()).isEqualTo(3); + } + } + + @DisplayName("주문 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, PENDING 상태의 주문이 반환된다.") + @Test + void returnsOrder_withPendingStatus() { + // Act + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @DisplayName("totalAmount가 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountIsNotPositive() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Order(1L, "ORD-20240101-ABCD1234", 0L)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 승인") + @Nested + class Approve { + + @DisplayName("PENDING 상태의 주문을 승인하면, CONFIRMED 상태가 된다.") + @Test + void confirmsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Act + order.approve(); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("PENDING이 아닌 주문을 승인하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIsNotPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + order.approve(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, order::approve); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 취소") + @Nested + class Cancel { + + @DisplayName("PENDING 상태의 주문을 취소하면, CANCELLED 상태가 된다.") + @Test + void cancelsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Act + order.cancel(); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("PENDING이 아닌 주문을 취소하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIsNotPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + order.cancel(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, order::cancel); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..685ba36a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,220 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(productRepository); + } + + @DisplayName("상품 등록") + @Nested + class CreateProduct { + + @DisplayName("유효한 정보로 등록하면, 상품을 반환한다.") + @Test + void returnsProduct_whenInputIsValid() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.save(any(Product.class))).willReturn(product); + + // Act + Product result = productService.createProduct(1L, "나이키 신발", 10000, 100, "설명", "url"); + + // Assert + assertThat(result.getName()).isEqualTo("나이키 신발"); + } + } + + @DisplayName("상품 단건 조회") + @Nested + class GetProduct { + + @DisplayName("존재하는 ID로 조회하면, 상품을 반환한다.") + @Test + void returnsProduct_whenIdExists() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + + // Act + Product result = productService.getProduct(1L); + + // Assert + assertThat(result.getName()).isEqualTo("나이키 신발"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productService.getProduct(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("상품 목록을 페이지로 반환한다.") + @Test + void returnsProductPage() { + // Arrange + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of( + new Product(1L, "나이키 신발", 10000, 100, "설명", "url") + )); + given(productRepository.findProducts(null, pageable)).willReturn(page); + + // Act + Page result = productService.getProducts(null, pageable); + + // Assert + assertThat(result.getContent()).hasSize(1); + } + } + + @DisplayName("상품 ID 목록 조회") + @Nested + class GetProductsByIds { + + @DisplayName("ID 목록으로 조회하면, 해당 상품 목록을 반환한다.") + @Test + void returnsProducts_whenIdsExist() { + // Arrange + List ids = List.of(1L, 2L); + List products = List.of( + new Product(1L, "나이키 신발", 10000, 100, "설명", "url1"), + new Product(1L, "아디다스 티셔츠", 20000, 50, "설명", "url2") + ); + given(productRepository.findAllByIds(ids)).willReturn(products); + + // Act + List result = productService.getProductsByIds(ids); + + // Assert + assertThat(result).hasSize(2); + } + + @DisplayName("해당하는 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoProductsFound() { + // Arrange + List ids = List.of(999L); + given(productRepository.findAllByIds(ids)).willReturn(List.of()); + + // Act + List result = productService.getProductsByIds(ids); + + // Assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("브랜드별 상품 일괄 삭제") + @Nested + class DeleteProductsByBrandId { + + @DisplayName("브랜드에 속한 상품이 있으면, 모두 soft delete 된다.") + @Test + void softDeletesAllProducts_whenBrandHasProducts() { + // Arrange + Product product1 = new Product(1L, "나이키 신발", 10000, 100, "설명", "url1"); + Product product2 = new Product(1L, "나이키 모자", 5000, 50, "설명", "url2"); + given(productRepository.findAllByBrandId(1L)).willReturn(List.of(product1, product2)); + given(productRepository.save(any(Product.class))).willAnswer(i -> i.getArgument(0)); + + // Act + productService.deleteProductsByBrandId(1L); + + // Assert + assertThat(product1.getDeletedAt()).isNotNull(); + assertThat(product2.getDeletedAt()).isNotNull(); + verify(productRepository, times(2)).save(any(Product.class)); + } + + @DisplayName("브랜드에 속한 상품이 없으면, 아무것도 삭제하지 않는다.") + @Test + void doesNothing_whenBrandHasNoProducts() { + // Arrange + given(productRepository.findAllByBrandId(999L)).willReturn(List.of()); + + // Act + productService.deleteProductsByBrandId(999L); + + // Assert + verify(productRepository, times(0)).save(any(Product.class)); + } + } + + @DisplayName("상품 삭제") + @Nested + class DeleteProduct { + + @DisplayName("존재하는 ID로 삭제하면, 상품이 soft delete 된다.") + @Test + void softDeletesProduct_whenIdExists() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(productRepository.save(product)).willReturn(product); + + // Act + productService.deleteProduct(1L); + + // Assert + assertThat(product.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productService.deleteProduct(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..9c26915f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,123 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, null, 10000, 100, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsZeroOrNegative() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 신발", 0, 100, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsNegative() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 신발", 10000, -1, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 정보로 생성하면, 상품이 생성된다.") + @Test + void createsProduct_whenInputIsValid() { + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + + assertThat(product.getName()).isEqualTo("나이키 신발"); + assertThat(product.getPrice()).isEqualTo(10000); + assertThat(product.getStock()).isEqualTo(100); + assertThat(product.getLikesCount()).isEqualTo(0); + } + } + + @DisplayName("재고 차감") + @Nested + class DecreaseStock { + + @DisplayName("재고가 충분하면, 차감 후 재고가 감소한다.") + @Test + void decreasesStock_whenStockIsSufficient() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + + product.decreaseStock(3); + + assertThat(product.getStock()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + Product product = new Product(1L, "나이키 신발", 10000, 2, "설명", "url"); + + CoreException exception = assertThrows(CoreException.class, + () -> product.decreaseStock(5)); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 복구") + @Nested + class IncreaseStock { + + @DisplayName("재고를 복구하면, 재고가 증가한다.") + @Test + void increasesStock() { + Product product = new Product(1L, "나이키 신발", 10000, 5, "설명", "url"); + + product.increaseStock(3); + + assertThat(product.getStock()).isEqualTo(8); + } + } + + @DisplayName("좋아요 수 증감") + @Nested + class LikesCount { + + @DisplayName("좋아요를 등록하면, likesCount가 1 증가한다.") + @Test + void increasesLikesCount() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + + product.increaseLikes(); + + assertThat(product.getLikesCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 취소하면, likesCount가 1 감소한다.") + @Test + void decreasesLikesCount() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + product.increaseLikes(); + + product.decreaseLikes(); + + assertThat(product.getLikesCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 06c6a8103..d658ad68e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -8,8 +8,6 @@ import org.junit.jupiter.api.Test; import org.springframework.security.crypto.password.PasswordEncoder; -import com.loopers.application.user.UserInfo; - import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -132,18 +130,17 @@ void savesUser_whenInputIsValid() { User result = userService.signUp(loginId, rawPassword, "홍길동", "19900101", "test@example.com"); // Assert - assertThat(result).isNotNull(); assertThat(result.getLoginId()).isEqualTo("newuser"); } } - @DisplayName("내정보 조회") + @DisplayName("인증") @Nested - class GetMyInfo { + class Authenticate { - @DisplayName("인증에 성공하면, 마스킹된 회원 정보를 반환한다.") + @DisplayName("loginId와 비밀번호가 일치하면, User를 반환한다.") @Test - void returnsMaskedUserInfo_whenAuthenticated() { + void returnsUser_whenCredentialsMatch() { // Arrange String loginId = "testuser"; String rawPassword = "Test1234!"; @@ -153,11 +150,10 @@ void returnsMaskedUserInfo_whenAuthenticated() { when(passwordEncoder.matches(rawPassword, "encrypted")).thenReturn(true); // Act - UserInfo result = userService.getMyInfo(loginId, rawPassword); + User result = userService.authenticate(loginId, rawPassword); // Assert - assertThat(result.loginId()).isEqualTo("testuser"); - assertThat(result.name()).isEqualTo("홍길*"); + assertThat(result.getLoginId()).isEqualTo(loginId); } @DisplayName("존재하지 않는 loginId이면, NOT_FOUND 예외가 발생한다.") @@ -168,7 +164,7 @@ void throwsNotFound_whenLoginIdDoesNotExist() { // Act CoreException exception = assertThrows(CoreException.class, () -> - userService.getMyInfo("nouser", "Test1234!") + userService.authenticate("nouser", "Test1234!") ); // Assert @@ -187,7 +183,7 @@ void throwsBadRequest_whenPasswordIsWrong() { // Act CoreException exception = assertThrows(CoreException.class, () -> - userService.getMyInfo(loginId, "wrongpw1!") + userService.authenticate(loginId, "wrongpw1!") ); // Assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index bc6cc5cf2..26f8f2356 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -1,10 +1,13 @@ package com.loopers.domain.user; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class UserTest { @@ -38,4 +41,57 @@ void returnsStar_whenNameIsSingleChar() { assertThat(maskedName).isEqualTo("*"); } } + + @DisplayName("잔액 차감") + @Nested + class DeductBalance { + + @DisplayName("잔액이 충분하면, 차감 후 잔액이 감소한다.") + @Test + void deductsBalance_whenBalanceIsSufficient() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(10000L); + + // Act + user.deductBalance(3000L); + + // Assert + assertThat(user.getBalance()).isEqualTo(7000L); + } + + @DisplayName("잔액이 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBalanceIsInsufficient() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(1000L); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> user.deductBalance(3000L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("잔액 복구") + @Nested + class RestoreBalance { + + @DisplayName("잔액을 복구하면, 잔액이 증가한다.") + @Test + void restoresBalance() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(5000L); + + // Act + user.restoreBalance(3000L); + + // Assert + assertThat(user.getBalance()).isEqualTo(8000L); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java new file mode 100644 index 000000000..29b660af6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java @@ -0,0 +1,88 @@ +package com.loopers.interfaces.api.like; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiControllerAdvice; +import com.loopers.interfaces.api.ApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LikeV1ControllerStandaloneTest { + + private MockMvc mockMvc; + private LikeFacade likeFacade; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + likeFacade = mock(LikeFacade.class); + LikeV1Controller controller = new LikeV1Controller(likeFacade); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new ApiControllerAdvice()) + .build(); + } + + @DisplayName("좋아요 추가") + @Nested + class AddLike { + + @DisplayName("요청 본문이 없으면, 400을 반환한다.") + @Test + void returns400_whenBodyIsMissing() throws Exception { + // Act & Assert + mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("productId가 null이면, 400을 반환한다.") + @Test + void returns400_whenProductIdIsNull() throws Exception { + // Act + String json = mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"productId": null} + """)) + .andExpect(status().isBadRequest()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + // Assert + ApiResponse response = objectMapper.readValue(json, new TypeReference>() {}); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL); + } + + @DisplayName("유효한 요청이면, 200을 반환한다.") + @Test + void returns200_whenRequestIsValid() throws Exception { + // Act & Assert + mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"productId": 1} + """)) + .andExpect(status().isOk()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java index c4eb1c121..e6da2341d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.domain.user.UserService; import com.loopers.interfaces.api.ApiControllerAdvice; @@ -31,12 +32,14 @@ class UserV1ControllerStandaloneTest { private MockMvc mockMvc; private UserService userService; + private UserFacade userFacade; private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { userService = mock(UserService.class); - UserV1Controller controller = new UserV1Controller(userService); + userFacade = mock(UserFacade.class); + UserV1Controller controller = new UserV1Controller(userService, userFacade); mockMvc = MockMvcBuilders.standaloneSetup(controller) .setControllerAdvice(new ApiControllerAdvice()) @@ -100,8 +103,8 @@ class GetMyInfo { @Test void returns200WithMaskedUserInfo() throws Exception { // Arrange - when(userService.getMyInfo("testuser", "password1!")) - .thenReturn(new UserInfo("testuser", "홍길*", "19900101", "test@example.com")); + when(userFacade.getMyInfo("testuser", "password1!")) + .thenReturn(new UserInfo("testuser", "홍길*", "19900101", "test@example.com", 0L)); // Act String json = mockMvc.perform(get("/api/v1/users/me") @@ -127,7 +130,7 @@ void returns200WithMaskedUserInfo() throws Exception { @Test void returns404_whenUserNotFound() throws Exception { // Arrange - when(userService.getMyInfo("nouser", "password1!")) + when(userFacade.getMyInfo("nouser", "password1!")) .thenThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); // Act & Assert @@ -141,7 +144,7 @@ void returns404_whenUserNotFound() throws Exception { @Test void returns400_whenPasswordIsWrong() throws Exception { // Arrange - when(userService.getMyInfo("testuser", "wrongpw1!")) + when(userFacade.getMyInfo("testuser", "wrongpw1!")) .thenThrow(new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.")); // Act & Assert diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index cdde08f30..47329a7f2 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -46,29 +46,7 @@ - 좋아요 취소 시 likes_count 감소 -[시나리오 3] 장바구니 담기 및 관리 - -설명: -사용자는 관심 상품을 장바구니에 담고, 수량을 조절하거나 삭제한다. - -흐름: -1. 상품 상세 페이지에서 장바구니 담기 -2. 장바구니 목록 조회 -3. 수량 변경 -4. 장바구니에서 삭제 - -관련 기능: -- 장바구니 담기 -- 장바구니 목록 조회 -- 장바구니 수량 변경 -- 장바구니 삭제 - -핵심 제약: -- 동일 상품 중복 담기 시 수량 합산 -- 장바구니 최대 상품 개수: 100개 - - -[시나리오 4] 상품 주문 +[시나리오 3] 상품 주문 설명: 사용자가 원하는 상품을 주문한다. @@ -77,9 +55,10 @@ 1. 상품 상세 조회 2. 주문 요청 3. 재고 확인 -4. 주문 생성 -5. 주문 목록 조회 -6. 단일 주문 상세 조회 +4. 잔액 확인 +5. 주문 생성 (재고 차감 + 잔액 차감) +6. 주문 목록 조회 +7. 단일 주문 상세 조회 관련 기능: - 주문 생성 @@ -88,10 +67,11 @@ 핵심 제약: - 재고 부족 시 주문 실패 -- 주문 생성과 재고 차감은 하나의 트랜잭션으로 처리 +- 잔액 부족 시 주문 실패 +- 주문 생성, 재고 차감, 잔액 차감은 하나의 트랜잭션으로 처리 - 주문 시점의 상품 정보는 스냅샷으로 저장 - 사용자는 PENDING 상태의 주문을 취소할 수 있다. -- 주문 취소 시 재고가 복구된다. +- 주문 취소 시 재고와 잔액이 복구된다. [시나리오 5] 관리자 상품 및 주문 관리 @@ -132,6 +112,7 @@ - 사용자는 회원가입을 할 수 있다. - 사용자는 자신의 정보를 조회할 수 있다. - 사용자는 비밀번호를 변경할 수 있다. +- 사용자는 자신의 잔액을 조회할 수 있다. 상품 조회: - 사용자는 전체 상품 목록을 조회할 수 있다. @@ -145,22 +126,16 @@ - 사용자는 좋아요를 취소할 수 있다. - 사용자는 자신이 좋아요한 상품 목록을 조회할 수 있다. -장바구니: -- 사용자는 상품을 장바구니에 담을 수 있다. -- 사용자는 장바구니 목록을 조회할 수 있다. -- 사용자는 장바구니 상품의 수량을 변경할 수 있다. -- 사용자는 장바구니에서 상품을 삭제할 수 있다. -- 동일 상품 중복 담기 시 수량이 합산된다. - 주문: - 사용자는 상품을 주문할 수 있다. - 주문 시 재고를 확인해야 한다. +- 주문 시 유저의 잔액이 충분한지 확인한다. - 주문 성공 시 재고가 차감된다. -- 주문 성공 시 장바구니 데이터가 삭제된다. +- 주문 성공 시 잔액이 차감된다. - 사용자는 자신의 주문 목록을 조회할 수 있다. - 사용자는 단일 주문 상세 정보를 조회할 수 있다. - 사용자는 PENDING 상태의 주문을 취소할 수 있다. -- 주문 취소 시 재고가 복구된다. +- 주문 취소 시 재고와 잔액이 복구된다. 관리자: - 관리자는 브랜드를 등록/수정/삭제할 수 있다. @@ -179,9 +154,12 @@ - 좋아요는 중복 등록을 방지한다. - 삭제는 soft delete 정책을 따른다. - 모든 조회 API는 페이징을 지원한다. -- 장바구니 최대 상품 개수: 100개 - 비밀번호 암호화 알고리즘: BCrypt - 인증 방식: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) +- likes_count 동시성: Product 엔티티의 @Version 낙관적 락으로 동시 갱신 충돌을 방지한다. +- 주문번호 생성: UUID 기반 (ORD-yyyyMMdd-{UUID 앞 8자리})으로 중복을 방지한다. +- 주문 취소 시 재고 복구, 잔액 복구, 상태 업데이트는 단일 @Transactional 범위에서 원자적으로 처리한다. +- ORDERS → USERS, ORDER_ITEMS → ORDERS 관계는 물리적 FK 제약조건으로 참조 무결성을 보장한다. diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 52c90087a..e881b88b5 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -27,7 +27,8 @@ participant ProdRepo as Product Repository Repo-->>Service: 결과 반환 alt 미존재 Service->>Repo: INSERT (Like) - Service->>ProdRepo: UPDATE (likes_count + 1) + Service->>ProdRepo: UPDATE (likes_count + 1) — @Version 낙관적 락 + Note right of Service: OptimisticLockException 발생 시 재시도 Service-->>API: 성공 else 이미 존재 Service-->>API: 400 Bad Request @@ -35,7 +36,7 @@ participant ProdRepo as Product Repository else 취소 요청 Service->>Repo: DELETE (Like) Note right of Service: 삭제 성공 시에만 count 감소 - Service->>ProdRepo: UPDATE (likes_count - 1) + Service->>ProdRepo: UPDATE (likes_count - 1) — @Version 낙관적 락 Service-->>API: 성공 end @@ -53,38 +54,44 @@ participant ProdRepo as Product Repository ```mermaid sequenceDiagram -title 주문 생성 (재고 차감 및 장바구니 삭제) +title 주문 생성 (재고 차감 및 잔액 차감) actor User as 사용자 participant API as Order API participant Service as Order Service + participant UserRepo as User Repository participant ProdRepo as Product Repository participant OrderRepo as Order Repository - participant CartRepo as Cart Repository - User->>API: 주문 생성 요청 + User->>API: 주문 생성 요청 (items: [{productId, quantity}, ...]) activate API API->>Service: 주문 생성 트랜잭션 시작 activate Service - Service->>ProdRepo: 재고 차감 요청 - + Service->>Service: generateOrderNumber() — UUID 기반 주문번호 생성 + Service->>ProdRepo: 재고 확인 및 차감 요청 ProdRepo-->>Service: 성공 여부 반환 alt 재고 부족 Service-->>API: 재고 부족 예외 던짐 API-->>User: 400 Bad Request (품절) else 재고 충분 및 차감 완료 - Service->>OrderRepo: 주문(Order) & 상세(OrderItems) 저장 - OrderRepo-->>Service: 저장 완료 - - Service->>CartRepo: 장바구니 데이터 삭제 - CartRepo-->>Service: 삭제 완료 - - Service-->>API: 주문 성공 응답 - deactivate Service - API-->>User: 201 Created (주문 완료) + Service->>UserRepo: 잔액 확인 요청 + UserRepo-->>Service: 현재 잔액 반환 + + alt 잔액 부족 + Service-->>API: 잔액 부족 예외 던짐 + API-->>User: 400 Bad Request (잔액 부족) + else 잔액 충분 + Service->>UserRepo: 잔액 차감 (balance - totalAmount) + Service->>OrderRepo: 주문(Order) & 상세(OrderItems) 저장 + OrderRepo-->>Service: 저장 완료 + + Service-->>API: 주문 성공 응답 + deactivate Service + API-->>User: 201 Created (주문 완료) + end end deactivate API ``` @@ -105,6 +112,7 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) participant S as Order Service participant OR as Order Repository participant PR as Product Repository + participant UR as User Repository U->>API: 상태 변경 요청 (Approve/Cancel) activate API @@ -122,7 +130,9 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) alt 관리자 승인 (Approve) S->>OR: 상태를 'CONFIRMED'로 업데이트 else 사용자 취소 (Cancel) + Note over S,UR: @Transactional — 재고 복구 + 잔액 복구 + 상태 업데이트 원자적 처리 S->>PR: 재고 복구 (stock + n) + S->>UR: 잔액 복구 (balance + totalAmount) S->>OR: 상태를 'CANCELLED'로 업데이트 end S-->>API: 성공 응답 diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 6a14eaa3a..c82c6c07e 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -16,6 +16,7 @@ classDiagram +String password +String name +String email + +Long balance } class Brand { @@ -47,14 +48,7 @@ classDiagram +Long productId } - class Cart { - <> - +Long id - +Long userId - +Long productId - +Integer quantity - +updateQuantity(quantity) void - } + class Order { <> @@ -92,10 +86,8 @@ classDiagram Brand "1" --> "N" Product : 보유 User "1" --> "N" Like : 좋아요 - User "1" --> "N" Cart : 장바구니 User "1" --> "N" Order : 주문 Product "1" --> "N" Like : 받음 - Product "1" --> "N" Cart : 담김 Product "1" --> "N" OrderItem : 주문됨 Order "1" --> "N" OrderItem : 포함 Order --> OrderStatus : 상태 @@ -117,11 +109,23 @@ classDiagram +cancelOrder(orderNumber) void +getOrders(userId, pageable) Page~Order~ +getOrderDetail(orderId) Order + +generateOrderNumber() String + } + + %% ============================================ + %% Facade 정의 + %% ============================================ + + class OrderFacade { + <> + +createOrder(userId, items) Order + +cancelOrder(orderNumber) void } - LikeService --> Product : likesCount 증감 + LikeService --> Product : likesCount 증감 (@Version 낙관적 락) + OrderFacade --> OrderService : 주문 처리 위임 OrderService --> Product : 재고 차감/복구 - OrderService --> Cart : 주문 시 장바구니 삭제 + OrderService --> User : 잔액 확인/차감/복구 OrderService --> OrderItem : 스냅샷 저장 ``` diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 5488ae51e..7c19e1680 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -10,6 +10,7 @@ erDiagram varchar(100) name "사용자 이름" varchar(255) email "이메일" varchar(20) phone "연락처" + bigint balance "잔액 (0 이상)" timestamp created_at "가입일시" timestamp updated_at "수정일시" timestamp deleted_at "삭제일시 (soft delete)" @@ -43,7 +44,7 @@ erDiagram } %% ======================================== - %% 좋아요 / 장바구니 + %% 좋아요 %% ======================================== LIKES { bigint id PK "자동증가" @@ -52,21 +53,12 @@ erDiagram timestamp created_at "좋아요 등록일시" } - CART { - bigint id PK "자동증가" - bigint user_id UK "사용자 ID (user_id+product_id 복합 중복불가)" - bigint product_id UK "상품 ID (user_id+product_id 복합 중복불가)" - int quantity "수량 (1 이상)" - timestamp created_at "장바구니 담은 일시" - timestamp updated_at "수량 수정일시" - } - %% ======================================== %% 주문 영역 %% ======================================== ORDERS { bigint id PK "자동증가" - varchar(50) order_number UK "주문번호 (중복불가, ORD-yyyyMMdd-xxxxx)" + varchar(50) order_number UK "주문번호 (UUID 기반, ORD-yyyyMMdd-{UUID 앞 8자리})" bigint user_id "주문자 ID (조회 최적화용 인덱스)" int total_amount "총 주문 금액" varchar(20) order_status "주문 상태 (PENDING/CONFIRMED/CANCELLED)" @@ -88,23 +80,26 @@ erDiagram } %% ======================================== - %% 논리적 관계 정의 (물리적 FK 제약조건 없음) + %% 관계 정의 + %% [물리적 FK] ORDERS.user_id → USERS, ORDER_ITEMS.order_id → ORDERS + %% 트랜잭션 정합성 필수 구간 — DDL에 FK 제약조건 명시 + %% [논리적 FK] 나머지 관계는 애플리케이션 레벨에서 검증 %% ======================================== - - %% 브랜드 → 상품 + + %% 브랜드 → 상품 (논리적 FK) BRANDS ||--o{ PRODUCTS : "보유" - - %% 사용자 → 좋아요/장바구니/주문 + + %% 사용자 → 좋아요 (논리적 FK) USERS ||--o{ LIKES : "좋아요" - USERS ||--o{ CART : "장바구니담기" + + %% 사용자 → 주문 (물리적 FK) USERS ||--o{ ORDERS : "주문" - - %% 상품 → 좋아요/장바구니/주문항목 + + %% 상품 → 좋아요/주문항목 (논리적 FK) PRODUCTS ||--o{ LIKES : "좋아요받음" - PRODUCTS ||--o{ CART : "담김" PRODUCTS ||--o{ ORDER_ITEMS : "주문됨" - - %% 주문 → 주문항목 + + %% 주문 → 주문항목 (물리적 FK) ORDERS ||--|{ ORDER_ITEMS : "포함" ``` \ No newline at end of file