diff --git a/.gitignore b/.gitignore index 5a979af6f..dbda3c1be 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ ### Kotlin ### .kotlin +CLAUDE.md diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..fbc77b806 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security (for password encoding) + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") @@ -19,4 +22,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // arch test + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") } 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..460001dd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,43 @@ +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 java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description, String imageUrl) { + Brand brand = brandService.register(name, description, imageUrl); + return BrandInfo.from(brand); + } + + public BrandInfo getBrand(Long id) { + Brand brand = brandService.getBrand(id); + return BrandInfo.from(brand); + } + + public List getAllBrands() { + return brandService.getAllBrands().stream() + .map(BrandInfo::from) + .toList(); + } + + public BrandInfo update(Long id, String name, String description, String imageUrl) { + Brand brand = brandService.update(id, name, description, imageUrl); + return BrandInfo.from(brand); + } + + public void delete(Long id) { + brandService.delete(id); + productService.deleteAllByBrandId(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..c3c997060 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,19 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +public record BrandInfo( + Long brandId, + String name, + String description, + String imageUrl +) { + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getImageUrl() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java new file mode 100644 index 000000000..d77a8cdc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java @@ -0,0 +1,56 @@ +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.ProductLike; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; + +@RequiredArgsConstructor +@Component +public class ProductLikeFacade { + + private final ProductService productService; + private final ProductLikeService productLikeService; + private final BrandService brandService; + + public void like(Long userId, Long productId) { + productService.getProduct(productId); + productLikeService.like(userId, productId); + } + + public void unlike(Long userId, Long productId) { + productLikeService.unlike(userId, productId); + } + + public ProductLikeInfo getLikeInfo(Long userId, Long productId) { + long likeCount = productLikeService.getLikeCount(productId); + boolean liked = productLikeService.isLiked(userId, productId); + return ProductLikeInfo.of(productId, likeCount, liked); + } + + public List getLikedProducts(Long userId) { + List likes = productLikeService.getLikedProducts(userId); + return likes.stream() + .map(like -> { + try { + Product product = productService.getProduct(like.getProductId()); + Brand brand = brandService.getBrand(product.getBrandId()); + long likeCount = productLikeService.getLikeCount(product.getId()); + return ProductInfo.of(product, brand, likeCount, true); + } catch (CoreException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java new file mode 100644 index 000000000..60b82a911 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application.like; + +public record ProductLikeInfo( + Long productId, + long likeCount, + boolean liked +) { + public static ProductLikeInfo of(Long productId, long likeCount, boolean liked) { + return new ProductLikeInfo(productId, likeCount, liked); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateItem.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateItem.java new file mode 100644 index 000000000..94ff3645c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateItem.java @@ -0,0 +1,3 @@ +package com.loopers.application.order; + +public record OrderCreateItem(Long productId, int quantity) {} 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..da1c97a35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,93 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +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.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final ProductService productService; + private final BrandService brandService; + private final OrderService orderService; + + public OrderInfo createOrder(Long userId, List items) { + List orderItems = new ArrayList<>(); + + for (OrderCreateItem createItem : items) { + Product product = productService.getProduct(createItem.productId()); + + if (!product.hasEnoughStock(createItem.quantity())) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + + productService.decreaseStock(createItem.productId(), createItem.quantity()); + + Brand brand = brandService.getBrand(product.getBrandId()); + + OrderItem orderItem = OrderItem.createSnapshot( + product.getId(), + brand.getName(), + product.getName(), + product.getPrice(), + createItem.quantity() + ); + orderItems.add(orderItem); + } + + Order order = orderService.createOrder(userId, orderItems); + List savedItems = orderService.getOrderItems(order.getId()); + List itemInfos = savedItems.stream() + .map(OrderItemInfo::from) + .toList(); + + return OrderInfo.of(order, itemInfos); + } + + public OrderInfo getOrder(Long orderId) { + Order order = orderService.getOrder(orderId); + List items = orderService.getOrderItems(orderId); + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return OrderInfo.of(order, itemInfos); + } + + public List getOrdersByUserId(Long userId) { + List orders = orderService.getOrdersByUserId(userId); + return orders.stream() + .map(order -> { + List items = orderService.getOrderItems(order.getId()); + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return OrderInfo.of(order, itemInfos); + }) + .toList(); + } + + public List getAllOrders() { + List orders = orderService.getAllOrders(); + return orders.stream() + .map(order -> { + List items = orderService.getOrderItems(order.getId()); + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return OrderInfo.of(order, itemInfos); + }) + .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..37c762752 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,24 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + int totalAmount, + List items, + ZonedDateTime createdAt +) { + public static OrderInfo of(Order order, List items) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + items, + order.getCreatedAt() + ); + } +} 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..e1976f4af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long orderItemId, + Long productId, + String brandName, + String productName, + int price, + int quantity +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getBrandName(), + item.getProductName(), + item.getPrice(), + item.getQuantity() + ); + } +} 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..564ea9eeb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,62 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final ProductLikeService productLikeService; + + public ProductInfo getProductDetail(Long productId, Long userId) { + Product product = productService.getProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + long likeCount = productLikeService.getLikeCount(productId); + boolean liked = userId != null && productLikeService.isLiked(userId, productId); + return ProductInfo.of(product, brand, likeCount, liked); + } + + public List getProducts(Long brandId) { + List products; + if (brandId != null) { + products = productService.getProductsByBrandId(brandId); + } else { + products = productService.getAllProducts(); + } + return products.stream() + .map(product -> { + Brand brand = brandService.getBrand(product.getBrandId()); + long likeCount = productLikeService.getLikeCount(product.getId()); + return ProductInfo.of(product, brand, likeCount, false); + }) + .toList(); + } + + public ProductInfo registerProduct(Long brandId, String name, String description, int price, int stock, String imageUrl) { + brandService.getBrand(brandId); + Product product = productService.register(brandId, name, description, price, stock, imageUrl); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.of(product, brand, 0L, false); + } + + public ProductInfo updateProduct(Long productId, String name, String description, int price, int stock, String imageUrl) { + Product product = productService.update(productId, name, description, price, stock, imageUrl); + Brand brand = brandService.getBrand(product.getBrandId()); + long likeCount = productLikeService.getLikeCount(productId); + return ProductInfo.of(product, brand, likeCount, false); + } + + public void deleteProduct(Long productId) { + productService.delete(productId); + } +} 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..205e37087 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long productId, + String productName, + String description, + int price, + int stock, + String imageUrl, + Long brandId, + String brandName, + long likeCount, + boolean liked +) { + public static ProductInfo of(Product product, Brand brand, long likeCount, boolean liked) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStock(), + product.getImageUrl(), + brand.getId(), + brand.getName(), + likeCount, + liked + ); + } +} 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..561b21978 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,30 @@ +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 java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + User user = userService.register(loginId, rawPassword, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMe(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentRawPassword, String newRawPassword) { + User user = userService.authenticate(loginId, currentRawPassword); + userService.changePassword(user.getId(), currentRawPassword, newRawPassword); + } +} 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 new file mode 100644 index 000000000..fc0fdb3f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} 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..e9889c2fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,58 @@ +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; + +@Entity +@Table(name = "brands") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + protected Brand() {} + + public Brand(String name, String description, String imageUrl) { + validateName(name); + + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } + + public void update(String name, String description, String imageUrl) { + validateName(name); + + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } +} 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..b235bcf6d --- /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.List; +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + List findAll(); + Optional findByName(String name); + boolean existsByName(String name); + List findAllByIds(List ids); +} 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..b2f6cb5f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,60 @@ +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.List; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public Brand register(String name, String description, String imageUrl) { + if (brandRepository.existsByName(name)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + } + + Brand brand = new Brand(name, description, imageUrl); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getAllBrands() { + return brandRepository.findAll(); + } + + @Transactional + public Brand update(Long id, String name, String description, String imageUrl) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + brandRepository.findByName(name).ifPresent(existing -> { + if (!existing.getId().equals(id)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + } + }); + + brand.update(name, description, imageUrl); + return brand; + } + + @Transactional + public void delete(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + brand.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLike.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLike.java new file mode 100644 index 000000000..1710a7840 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLike.java @@ -0,0 +1,68 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "product_likes") +public class ProductLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected ProductLike() {} + + public ProductLike(Long userId, Long productId) { + validateUserId(userId); + validateProductId(productId); + this.userId = userId; + this.productId = productId; + this.createdAt = ZonedDateTime.now(); + } + + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 비어있을 수 없습니다."); + } + } + + private void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java new file mode 100644 index 000000000..0a63f4a59 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface ProductLikeRepository { + ProductLike save(ProductLike productLike); + Optional findByUserIdAndProductId(Long userId, Long productId); + void deleteByUserIdAndProductId(Long userId, Long productId); + long countByProductId(Long productId); + List findAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java new file mode 100644 index 000000000..038bcdb24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -0,0 +1,48 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductLikeService { + + private final ProductLikeRepository productLikeRepository; + + @Transactional + public void like(Long userId, Long productId) { + boolean alreadyLiked = productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent(); + if (alreadyLiked) { + return; + } + ProductLike productLike = new ProductLike(userId, productId); + productLikeRepository.save(productLike); + } + + @Transactional + public void unlike(Long userId, Long productId) { + boolean liked = productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent(); + if (!liked) { + return; + } + productLikeRepository.deleteByUserIdAndProductId(userId, productId); + } + + @Transactional(readOnly = true) + public long getLikeCount(Long productId) { + return productLikeRepository.countByProductId(productId); + } + + @Transactional(readOnly = true) + public boolean isLiked(Long userId, Long productId) { + return productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent(); + } + + @Transactional(readOnly = true) + public List getLikedProducts(Long userId) { + return productLikeRepository.findAllByUserId(userId); + } +} 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..ec4ff0155 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,55 @@ +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.Table; + +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_amount", nullable = false) + private int totalAmount; + + protected Order() {} + + private Order(Long userId, int totalAmount) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 비어있을 수 없습니다."); + } + if (totalAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 0 이상이어야 합니다."); + } + this.userId = userId; + this.totalAmount = totalAmount; + } + + public static Order create(Long userId, List items) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 비어있을 수 없습니다."); + } + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 비어있을 수 없습니다."); + } + int totalAmount = items.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + return new Order(userId, totalAmount); + } + + public Long getUserId() { + return userId; + } + + public int getTotalAmount() { + return totalAmount; + } +} 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..cba6bfa88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,113 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "order_items") +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "brand_name", nullable = false, length = 100) + private String brandName; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected OrderItem() {} + + private OrderItem(Long productId, String brandName, String productName, int price, int quantity) { + this.productId = productId; + this.brandName = brandName; + this.productName = productName; + this.price = price; + this.quantity = quantity; + } + + public static OrderItem createSnapshot(Long productId, String brandName, String productName, int price, int quantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + return new OrderItem(productId, brandName, productName, price, quantity); + } + + public void assignOrderId(Long orderId) { + this.orderId = orderId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getOrderId() { + return orderId; + } + + public Long getProductId() { + return productId; + } + + public String getBrandName() { + return brandName; + } + + public String getProductName() { + return productName; + } + + public int getPrice() { + return price; + } + + public int getQuantity() { + return quantity; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} 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..b8c0ee742 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + List saveAll(List items); + List findAllByOrderId(Long orderId); +} 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..743e7e62b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + Optional findById(Long id); + List findAllByUserId(Long userId); + List findAll(); +} 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..299e0e1b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,49 @@ +package com.loopers.domain.order; + +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 OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public Order createOrder(Long userId, List items) { + Order order = Order.create(userId, items); + Order savedOrder = orderRepository.save(order); + + items.forEach(item -> item.assignOrderId(savedOrder.getId())); + orderItemRepository.saveAll(items); + + return savedOrder; + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUserId(Long userId) { + return orderRepository.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public List getAllOrders() { + return orderRepository.findAll(); + } +} 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..9a00a838a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,118 @@ +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; + +@Entity +@Table(name = "products") +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 = "description", length = 1000) + private String description; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "stock", nullable = false) + private int stock; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + protected Product() {} + + public Product(Long brandId, String name, String description, int price, int stock, String imageUrl) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStock(stock); + + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + } + + public void decreaseStock(int quantity) { + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; + } + + public boolean hasEnoughStock(int quantity) { + return this.stock >= quantity; + } + + public void update(String name, String description, int price, int stock, String imageUrl) { + validateName(name); + validatePrice(price); + validateStock(stock); + + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.imageUrl = imageUrl; + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 비어있을 수 없습니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + } + + private void validatePrice(int price) { + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private void validateStock(int stock) { + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public int getPrice() { + return price; + } + + public int getStock() { + return stock; + } + + public String getImageUrl() { + return 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..18d8dc9b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + List findAll(); + List findAllByBrandId(Long brandId); + List findAllByIds(List ids); +} 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..51ce43e0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,68 @@ +package com.loopers.domain.product; + +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 ProductService { + + private final ProductRepository productRepository; + + @Transactional + public Product register(Long brandId, String name, String description, int price, int stock, String imageUrl) { + Product product = new Product(brandId, name, description, price, stock, imageUrl); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public Product getProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getAllProducts() { + return productRepository.findAll(); + } + + @Transactional(readOnly = true) + public List getProductsByBrandId(Long brandId) { + return productRepository.findAllByBrandId(brandId); + } + + @Transactional + public Product update(Long id, String name, String description, int price, int stock, String imageUrl) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.update(name, description, price, stock, imageUrl); + return product; + } + + @Transactional + public void delete(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.delete(); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + for (Product product : products) { + product.delete(); + } + } + + @Transactional + public void decreaseStock(Long productId, int quantity) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.decreaseStock(quantity); + } +} 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 new file mode 100644 index 000000000..a27a0c4d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,143 @@ +package com.loopers.domain.user; + +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 java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"; + private static final String LOGIN_ID_PATTERN = "^[a-zA-Z0-9]+$"; + + @Column(name = "login_id", nullable = false, unique = true, length = 50) + private String loginId; + + @Column(name = "password", nullable = false, length = 255) + private String password; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "email", nullable = false, length = 255) + private String email; + + protected User() {} + + public User(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static void validateRawPassword(String rawPassword, LocalDate birthDate) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (rawPassword.length() < 8 || rawPassword.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!rawPassword.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); + } + if (birthDate != null && containsBirthDate(rawPassword, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + private static boolean containsBirthDate(String password, LocalDate birthDate) { + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); + String yyyy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + String yy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yy-MM-dd")); + + return password.contains(yyyyMMdd) || + password.contains(yyMMdd) || + password.contains(yyyy_MM_dd) || + password.contains(yy_MM_dd); + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (!loginId.matches(LOGIN_ID_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 사용 가능합니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + } + + private void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 없습니다."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if (!email.matches("^[^@]+@[^@]+\\.[^@]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public String getMaskedName() { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} 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 new file mode 100644 index 000000000..9622f1115 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findById(Long id); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} 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 new file mode 100644 index 000000000..5be4604fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,70 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Transactional + public User register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + User.validateRawPassword(rawPassword, birthDate); + String encodedPassword = passwordEncoder.encode(rawPassword); + + User user = new User(loginId, encodedPassword, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { + User user = getUserByLoginId(loginId); + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + } + return user; + } + + @Transactional + public void changePassword(Long userId, String currentRawPassword, String newRawPassword) { + User user = getUser(userId); + + if (!passwordEncoder.matches(currentRawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."); + } + + User.validateRawPassword(newRawPassword, user.getBirthDate()); + String newEncodedPassword = passwordEncoder.encode(newRawPassword); + user.changePassword(newEncodedPassword); + } +} 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..a9c5d4b03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByName(String name); + boolean existsByName(String name); + + @Query("SELECT b FROM Brand b WHERE b.id IN :ids") + List findAllByIds(@Param("ids") List 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..8d4c3ae9f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,46 @@ +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.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public List findAll() { + return brandJpaRepository.findAll(); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByName(name); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByName(name); + } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIds(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java new file mode 100644 index 000000000..ceec16a35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ProductLikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(Long userId, Long productId); + void deleteByUserIdAndProductId(Long userId, Long productId); + long countByProductId(Long productId); + List findAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java new file mode 100644 index 000000000..befd74b6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLike; +import com.loopers.domain.like.ProductLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductLikeRepositoryImpl implements ProductLikeRepository { + + private final ProductLikeJpaRepository productLikeJpaRepository; + + @Override + public ProductLike save(ProductLike productLike) { + return productLikeJpaRepository.save(productLike); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return productLikeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteByUserIdAndProductId(Long userId, Long productId) { + productLikeJpaRepository.deleteByUserIdAndProductId(userId, productId); + } + + @Override + public long countByProductId(Long productId) { + return productLikeJpaRepository.countByProductId(productId); + } + + @Override + public List findAllByUserId(Long userId) { + return productLikeJpaRepository.findAllByUserId(userId); + } +} 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..41565e2cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,10 @@ +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 findAllByOrderId(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..560786eaf --- /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 saveAll(List items) { + return orderItemJpaRepository.saveAll(items); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} 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..5ca8774a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + List findAllByUserId(Long userId); +} 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..e223d115b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByUserId(Long userId) { + return orderJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAll() { + return orderJpaRepository.findAll(); + } +} 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..167a0faa0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + List findAllByBrandId(Long brandId); +} 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..86ca354ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +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 Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAll() { + return productJpaRepository.findAll(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllById(ids); + } +} 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..fb0e51c3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +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); + boolean existsByLoginId(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..d93746775 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,35 @@ +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 User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java new file mode 100644 index 000000000..248bebf4f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand Admin", description = "브랜드 어드민 API") +public interface BrandAdminV1ApiSpec { + + @Operation(summary = "브랜드 목록 조회", description = "모든 브랜드 목록을 조회합니다.") + ApiResponse getAllBrands( + @Parameter(description = "어드민 LDAP", required = true) String ldap + ); + + @Operation(summary = "브랜드 상세 조회", description = "브랜드 ID로 브랜드 정보를 조회합니다.") + ApiResponse getBrand( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "브랜드 ID", required = true) Long brandId + ); + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse registerBrand( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + BrandAdminV1Dto.RegisterRequest request + ); + + @Operation(summary = "브랜드 수정", description = "브랜드 정보를 수정합니다.") + ApiResponse updateBrand( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "브랜드 ID", required = true) Long brandId, + BrandAdminV1Dto.UpdateRequest request + ); + + @Operation(summary = "브랜드 삭제", description = "브랜드를 삭제합니다.") + ApiResponse deleteBrand( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "브랜드 ID", required = true) Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..7928400df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,96 @@ +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 com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PutMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final BrandFacade brandFacade; + + private void validateAdmin(String ldap) { + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "어드민 권한이 필요합니다."); + } + } + + @GetMapping + @Override + public ApiResponse getAllBrands( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap + ) { + validateAdmin(ldap); + List infos = brandFacade.getAllBrands(); + return ApiResponse.success(BrandAdminV1Dto.BrandListResponse.from(infos)); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long brandId + ) { + validateAdmin(ldap); + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse registerBrand( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @Valid @RequestBody BrandAdminV1Dto.RegisterRequest request + ) { + validateAdmin(ldap); + BrandInfo info = brandFacade.register(request.name(), request.description(), request.imageUrl()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse updateBrand( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long brandId, + @Valid @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + validateAdmin(ldap); + BrandInfo info = brandFacade.update(brandId, request.name(), request.description(), request.imageUrl()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public ApiResponse deleteBrand( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long brandId + ) { + validateAdmin(ldap); + brandFacade.delete(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java new file mode 100644 index 000000000..fd3b44352 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +public class BrandAdminV1Dto { + + public record RegisterRequest( + @NotBlank(message = "브랜드명은 필수입니다.") + String name, + + String description, + + String imageUrl + ) {} + + public record UpdateRequest( + @NotBlank(message = "브랜드명은 필수입니다.") + String name, + + String description, + + String imageUrl + ) {} + + public record BrandResponse( + Long brandId, + String name, + String description, + String imageUrl + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.brandId(), + info.name(), + info.description(), + info.imageUrl() + ); + } + } + + public record BrandListResponse( + List brands + ) { + public static BrandListResponse from(List infos) { + return new BrandListResponse( + infos.stream() + .map(BrandResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..260f57cd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand", description = "브랜드 API") +public interface BrandV1ApiSpec { + + @Operation(summary = "브랜드 상세 조회", description = "브랜드 ID로 브랜드 정보를 조회합니다.") + ApiResponse getBrand( + @Parameter(description = "브랜드 ID", required = true) Long brandId + ); +} 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..bb6dd72ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,27 @@ +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand( + @PathVariable Long brandId + ) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} 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..860b16602 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + Long brandId, + String name, + String description, + String imageUrl + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.brandId(), + info.name(), + info.description(), + info.imageUrl() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1ApiSpec.java new file mode 100644 index 000000000..9fdce225b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1ApiSpec.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "ProductLike", description = "상품 좋아요 API") +public interface ProductLikeV1ApiSpec { + + @Operation(summary = "상품 좋아요", description = "상품에 좋아요를 추가합니다.") + ApiResponse like( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "상품 ID", required = true) Long productId + ); + + @Operation(summary = "상품 좋아요 취소", description = "상품에 좋아요를 취소합니다.") + ApiResponse unlike( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "상품 ID", required = true) Long productId + ); + + @Operation(summary = "좋아요한 상품 목록 조회", description = "사용자가 좋아요한 상품 목록을 조회합니다.") + ApiResponse getLikedProducts( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "사용자 ID", required = true) Long userId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Controller.java new file mode 100644 index 000000000..2c477d368 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.ProductLikeFacade; +import com.loopers.application.like.ProductLikeInfo; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class ProductLikeV1Controller implements ProductLikeV1ApiSpec { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserFacade userFacade; + private final ProductLikeFacade productLikeFacade; + + @PostMapping("/products/{productId}/likes") + @Override + public ApiResponse like( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long productId + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + productLikeFacade.like(currentUser.id(), productId); + ProductLikeInfo info = productLikeFacade.getLikeInfo(currentUser.id(), productId); + return ApiResponse.success(ProductLikeV1Dto.LikeResponse.from(info)); + } + + @DeleteMapping("/products/{productId}/likes") + @Override + public ApiResponse unlike( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long productId + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + productLikeFacade.unlike(currentUser.id(), productId); + ProductLikeInfo info = productLikeFacade.getLikeInfo(currentUser.id(), productId); + return ApiResponse.success(ProductLikeV1Dto.LikeResponse.from(info)); + } + + @GetMapping("/users/{userId}/likes") + @Override + public ApiResponse getLikedProducts( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long userId + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + if (!currentUser.id().equals(userId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 좋아요 목록만 조회할 수 있습니다."); + } + List infos = productLikeFacade.getLikedProducts(userId); + return ApiResponse.success(ProductLikeV1Dto.LikedProductListResponse.from(infos)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Dto.java new file mode 100644 index 000000000..2d8e85e82 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/ProductLikeV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.ProductLikeInfo; +import com.loopers.application.product.ProductInfo; + +import java.util.List; + +public class ProductLikeV1Dto { + + public record LikeResponse( + Long productId, + long likeCount, + boolean liked + ) { + public static LikeResponse from(ProductLikeInfo info) { + return new LikeResponse( + info.productId(), + info.likeCount(), + info.liked() + ); + } + } + + public record LikedProductResponse( + Long productId, + String productName, + String description, + int price, + int stock, + String imageUrl, + Long brandId, + String brandName, + long likeCount, + boolean liked + ) { + public static LikedProductResponse from(ProductInfo info) { + return new LikedProductResponse( + info.productId(), + info.productName(), + info.description(), + info.price(), + info.stock(), + info.imageUrl(), + info.brandId(), + info.brandName(), + info.likeCount(), + info.liked() + ); + } + } + + public record LikedProductListResponse( + List products + ) { + public static LikedProductListResponse from(List infos) { + return new LikedProductListResponse( + infos.stream() + .map(LikedProductResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java new file mode 100644 index 000000000..860e2fa98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order Admin", description = "주문 어드민 API") +public interface OrderAdminV1ApiSpec { + + @Operation(summary = "전체 주문 목록 조회", description = "모든 주문 목록을 조회합니다.") + ApiResponse getAllOrders( + @Parameter(description = "어드민 LDAP", required = true) String ldap + ); + + @Operation(summary = "주문 상세 조회", description = "주문 ID로 주문 상세 정보를 조회합니다.") + ApiResponse getOrder( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "주문 ID", required = true) Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..48feb2944 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,53 @@ +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 com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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-admin/v1/orders") +public class OrderAdminV1Controller implements OrderAdminV1ApiSpec { + + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final OrderFacade orderFacade; + + private void validateAdmin(String ldap) { + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "어드민 권한이 필요합니다."); + } + } + + @GetMapping + @Override + public ApiResponse getAllOrders( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap + ) { + validateAdmin(ldap); + List infos = orderFacade.getAllOrders(); + return ApiResponse.success(OrderAdminV1Dto.OrderListResponse.from(infos)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrder( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long orderId + ) { + validateAdmin(ldap); + OrderInfo info = orderFacade.getOrder(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java new file mode 100644 index 000000000..c3cc3cf7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderItemResponse( + Long orderItemId, + Long productId, + String brandName, + String productName, + int price, + int quantity + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.orderItemId(), + info.productId(), + info.brandName(), + info.productName(), + info.price(), + info.quantity() + ); + } + } + + public record OrderResponse( + Long orderId, + Long userId, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.orderId(), + info.userId(), + info.totalAmount(), + info.items().stream() + .map(OrderItemResponse::from) + .toList(), + info.createdAt() + ); + } + } + + public record OrderListResponse( + List orders + ) { + public static OrderListResponse from(List infos) { + return new OrderListResponse( + infos.stream() + .map(OrderResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..e479bb682 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order", description = "주문 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") + ApiResponse createOrder( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + OrderV1Dto.CreateRequest request + ); + + @Operation(summary = "내 주문 목록 조회", description = "로그인한 사용자의 주문 목록을 조회합니다.") + ApiResponse getMyOrders( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password + ); + + @Operation(summary = "주문 상세 조회", description = "주문 ID로 주문 상세 정보를 조회합니다.") + ApiResponse getOrder( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "주문 ID", required = true) Long orderId + ); +} 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..f40f3df01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserFacade userFacade; + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @Valid @RequestBody OrderV1Dto.CreateRequest request + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + OrderInfo info = orderFacade.createOrder(currentUser.id(), request.toOrderCreateItems()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getMyOrders( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + List infos = orderFacade.getOrdersByUserId(currentUser.id()); + return ApiResponse.success(OrderV1Dto.OrderListResponse.from(infos)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrder( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long orderId + ) { + UserInfo currentUser = userFacade.getMe(loginId, password); + OrderInfo info = orderFacade.getOrder(orderId); + if (!info.userId().equals(currentUser.id())) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다."); + } + 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..e7e5ea3b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,87 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderCreateItem; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest( + @NotEmpty(message = "주문 상품 목록은 비어있을 수 없습니다.") + @Valid + List items + ) { + public List toOrderCreateItems() { + return items.stream() + .map(item -> new OrderCreateItem(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + int quantity + ) {} + + public record OrderItemResponse( + Long orderItemId, + Long productId, + String brandName, + String productName, + int price, + int quantity + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.orderItemId(), + info.productId(), + info.brandName(), + info.productName(), + info.price(), + info.quantity() + ); + } + } + + public record OrderResponse( + Long orderId, + Long userId, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.orderId(), + info.userId(), + info.totalAmount(), + info.items().stream() + .map(OrderItemResponse::from) + .toList(), + info.createdAt() + ); + } + } + + public record OrderListResponse( + List orders + ) { + public static OrderListResponse from(List infos) { + return new OrderListResponse( + infos.stream() + .map(OrderResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..ef14de17d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product Admin", description = "상품 어드민 API") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "모든 상품 목록을 조회합니다.") + ApiResponse getProducts( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "브랜드 ID (선택)") Long brandId + ); + + @Operation(summary = "상품 상세 조회", description = "상품 ID로 상품 상세 정보를 조회합니다.") + ApiResponse getProductDetail( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "상품 ID", required = true) Long productId + ); + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다.") + ApiResponse registerProduct( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + ProductAdminV1Dto.RegisterRequest request + ); + + @Operation(summary = "상품 수정", description = "상품 정보를 수정합니다.") + ApiResponse updateProduct( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "상품 ID", required = true) Long productId, + ProductAdminV1Dto.UpdateRequest request + ); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse deleteProduct( + @Parameter(description = "어드민 LDAP", required = true) String ldap, + @Parameter(description = "상품 ID", required = true) Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..edfd0f217 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,112 @@ +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 com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PutMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final ProductFacade productFacade; + + private void validateAdmin(String ldap) { + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "어드민 권한이 필요합니다."); + } + } + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @RequestParam(required = false) Long brandId + ) { + validateAdmin(ldap); + List infos = productFacade.getProducts(brandId); + return ApiResponse.success(ProductAdminV1Dto.ProductListResponse.from(infos)); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProductDetail( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long productId + ) { + validateAdmin(ldap); + ProductInfo info = productFacade.getProductDetail(productId, null); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse registerProduct( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @Valid @RequestBody ProductAdminV1Dto.RegisterRequest request + ) { + validateAdmin(ldap); + ProductInfo info = productFacade.registerProduct( + request.brandId(), + request.name(), + request.description(), + request.price(), + request.stock(), + request.imageUrl() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long productId, + @Valid @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + validateAdmin(ldap); + ProductInfo info = productFacade.updateProduct( + productId, + request.name(), + request.description(), + request.price(), + request.stock(), + request.imageUrl() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } + + @DeleteMapping("/{productId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public ApiResponse deleteProduct( + @RequestHeader(ADMIN_LDAP_HEADER) String ldap, + @PathVariable Long productId + ) { + validateAdmin(ldap); + productFacade.deleteProduct(productId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..8f77f24ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class ProductAdminV1Dto { + + public record RegisterRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다.") + String name, + + String description, + + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + int price, + + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + int stock, + + String imageUrl + ) {} + + public record UpdateRequest( + @NotBlank(message = "상품명은 필수입니다.") + String name, + + String description, + + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + int price, + + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + int stock, + + String imageUrl + ) {} + + public record ProductResponse( + Long productId, + String productName, + String description, + int price, + int stock, + String imageUrl, + Long brandId, + String brandName, + long likeCount, + boolean liked + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.productId(), + info.productName(), + info.description(), + info.price(), + info.stock(), + info.imageUrl(), + info.brandId(), + info.brandName(), + info.likeCount(), + info.liked() + ); + } + } + + public record ProductListResponse( + List products + ) { + public static ProductListResponse from(List infos) { + return new ProductListResponse( + infos.stream() + .map(ProductResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..1297b8e9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product", description = "상품 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 상세 조회", description = "상품 ID로 상품 상세 정보를 조회합니다.") + ApiResponse getProductDetail( + @Parameter(description = "상품 ID", required = true) Long productId + ); + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. brandId로 필터링 가능합니다.") + ApiResponse getProducts( + @Parameter(description = "브랜드 ID (선택)") Long brandId + ); +} 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..9545e6a94 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,39 @@ +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + @Override + public ApiResponse getProductDetail( + @PathVariable Long productId + ) { + ProductInfo info = productFacade.getProductDetail(productId, null); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId + ) { + List infos = productFacade.getProducts(brandId); + return ApiResponse.success(ProductV1Dto.ProductListResponse.from(infos)); + } +} 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..5ab513d76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +import java.util.List; + +public class ProductV1Dto { + + public record ProductResponse( + Long productId, + String productName, + String description, + int price, + int stock, + String imageUrl, + Long brandId, + String brandName, + long likeCount, + boolean liked + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.productId(), + info.productName(), + info.description(), + info.price(), + info.stock(), + info.imageUrl(), + info.brandId(), + info.brandName(), + info.likeCount(), + info.liked() + ); + } + } + + public record ProductListResponse( + List products + ) { + public static ProductListResponse from(List infos) { + return new ProductListResponse( + infos.stream() + .map(ProductResponse::from) + .toList() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..0368cdba0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User", description = "사용자 API") +public interface UserV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation(summary = "내 정보 조회", description = "로그인한 사용자의 정보를 조회합니다. 이름은 마스킹 처리됩니다.") + ApiResponse getMe( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password + ); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + UserV1Dto.ChangePasswordRequest request + ); +} 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 new file mode 100644 index 000000000..942fb5b53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,64 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register( + @Valid @RequestBody UserV1Dto.RegisterRequest request + ) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password + ) { + UserInfo info = userFacade.getMe(loginId, password); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @PutMapping("/password") + @Override + public ApiResponse changePassword( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, password, request.newPassword()); + return ApiResponse.success(null); + } +} 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 new file mode 100644 index 000000000..26017ffc3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record RegisterRequest( + @NotBlank(message = "로그인 ID는 필수입니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 사용 가능합니다.") + String loginId, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @NotBlank(message = "이름은 필수입니다.") + String name, + + @NotNull(message = "생년월일은 필수입니다.") + LocalDate birthDate, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email + ) {} + + public record ChangePasswordRequest( + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + String newPassword + ) {} + + public record UserResponse( + Long id, + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.id(), + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record RegisterResponse( + Long id, + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse( + info.id(), + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..8ef0c74c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java new file mode 100644 index 000000000..6df3fd34e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java @@ -0,0 +1,38 @@ +package com.loopers; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@AnalyzeClasses(packages = "com.loopers", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + // 1. 계층형 아키텍처 의존성 검증 + // Interfaces → Application → Domain ← Infrastructure + // Config, Support 패키지는 모든 레이어에서 참조 가능 + @ArchTest + static final ArchRule layered_architecture_is_respected = layeredArchitecture() + .consideringOnlyDependenciesInAnyPackage("com.loopers..") + .layer("Interfaces").definedBy("..interfaces..") + .layer("Application").definedBy("..application..") + .layer("Domain").definedBy("..domain..") + .layer("Infrastructure").definedBy("..infrastructure..") + .layer("Config").definedBy("..config..") + .layer("Support").definedBy("..support..") + .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Interfaces", "Config") + .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer() + .whereLayer("Config").mayNotBeAccessedByAnyLayer() + .whereLayer("Support").mayOnlyBeAccessedByLayers("Interfaces", "Application", "Domain", "Infrastructure", "Config"); + + // 2. Domain 계층 독립성 (DIP 핵심) + @ArchTest + static final ArchRule domain_should_not_depend_on_infrastructure = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..8c4de69de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,108 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.FakeBrandRepository; +import com.loopers.domain.product.FakeProductRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class BrandFacadeTest { + + private BrandFacade brandFacade; + private FakeBrandRepository fakeBrandRepository; + private FakeProductRepository fakeProductRepository; + private ProductService productService; + + @BeforeEach + void setUp() { + fakeBrandRepository = new FakeBrandRepository(); + fakeProductRepository = new FakeProductRepository(); + + BrandService brandService = new BrandService(fakeBrandRepository); + productService = new ProductService(fakeProductRepository); + + brandFacade = new BrandFacade(brandService, productService); + } + + @DisplayName("브랜드 등록 시,") + @Nested + class Register { + + @DisplayName("브랜드가 정상적으로 등록된다.") + @Test + void registersBrand_successfully() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + String imageUrl = "http://nike.url"; + + // act + BrandInfo result = brandFacade.register(name, description, imageUrl); + + // assert + assertAll( + () -> assertThat(result.brandId()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(result.imageUrl()).isEqualTo("http://nike.url") + ); + } + } + + @DisplayName("브랜드 조회 시,") + @Nested + class GetBrand { + + @DisplayName("존재하는 브랜드 ID로 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + BrandInfo registered = brandFacade.register("아디다스", "스포츠 브랜드", "http://adidas.url"); + + // act + BrandInfo result = brandFacade.getBrand(registered.brandId()); + + // assert + assertAll( + () -> assertThat(result.brandId()).isEqualTo(registered.brandId()), + () -> assertThat(result.name()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("브랜드 삭제 시,") + @Nested + class Delete { + + @DisplayName("브랜드 삭제 시 해당 브랜드의 모든 상품이 소프트 삭제된다.") + @Test + void deleteBrand_cascadesToProducts() { + // arrange + BrandInfo brand = brandFacade.register("뉴발란스", "라이프스타일 브랜드", "http://nb.url"); + Long brandId = brand.brandId(); + + Product product1 = fakeProductRepository.save(new Product(brandId, "550", "클래식 운동화", 120000, 10, "http://550.url")); + Product product2 = fakeProductRepository.save(new Product(brandId, "574", "데일리 운동화", 110000, 5, "http://574.url")); + + // act + brandFacade.delete(brandId); + + // assert + List products = fakeProductRepository.findAllByBrandId(brandId); + assertAll( + () -> assertThat(products).hasSize(2), + () -> assertThat(products.get(0).getDeletedAt()).isNotNull(), + () -> assertThat(products.get(1).getDeletedAt()).isNotNull() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java new file mode 100644 index 000000000..67225b26d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java @@ -0,0 +1,117 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.FakeProductLikeRepository; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.FakeProductRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductLikeFacadeTest { + + private ProductLikeFacade productLikeFacade; + private FakeProductRepository fakeProductRepository; + private FakeProductLikeRepository fakeProductLikeRepository; + + private static final Long USER_ID = 1L; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeProductLikeRepository = new FakeProductLikeRepository(); + ProductService productService = new ProductService(fakeProductRepository); + ProductLikeService productLikeService = new ProductLikeService(fakeProductLikeRepository); + productLikeFacade = new ProductLikeFacade(productService, productLikeService); + } + + @DisplayName("좋아요 등록 시,") + @Nested + class Like { + + @DisplayName("상품이 존재하면 좋아요가 정상적으로 등록된다.") + @Test + void likes_whenProductExists() { + // arrange + Product product = fakeProductRepository.save(new Product(1L, "상품명", "설명", 1000, 10, "http://image.url")); + Long productId = product.getId(); + + // act + productLikeFacade.like(USER_ID, productId); + + // assert + ProductLikeInfo info = productLikeFacade.getLikeInfo(USER_ID, productId); + assertAll( + () -> assertThat(info.liked()).isTrue(), + () -> assertThat(info.likeCount()).isEqualTo(1L) + ); + } + + @DisplayName("존재하지 않는 상품에 좋아요를 누르면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentProductId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productLikeFacade.like(USER_ID, nonExistentProductId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("좋아요 정보 조회 시,") + @Nested + class GetLikeInfo { + + @DisplayName("좋아요 수와 좋아요 여부를 올바르게 반환한다.") + @Test + void returnsCorrectLikeInfo() { + // arrange + Product product = fakeProductRepository.save(new Product(1L, "상품명", "설명", 1000, 10, "http://image.url")); + Long productId = product.getId(); + productLikeFacade.like(USER_ID, productId); + productLikeFacade.like(2L, productId); + + // act + ProductLikeInfo info = productLikeFacade.getLikeInfo(USER_ID, productId); + + // assert + assertAll( + () -> assertThat(info.productId()).isEqualTo(productId), + () -> assertThat(info.likeCount()).isEqualTo(2L), + () -> assertThat(info.liked()).isTrue() + ); + } + + @DisplayName("좋아요하지 않은 사용자에 대해 liked가 false로 반환된다.") + @Test + void returnsLikedFalse_whenUserHasNotLiked() { + // arrange + Product product = fakeProductRepository.save(new Product(1L, "상품명", "설명", 1000, 10, "http://image.url")); + Long productId = product.getId(); + productLikeFacade.like(2L, productId); + + // act + ProductLikeInfo info = productLikeFacade.getLikeInfo(USER_ID, productId); + + // assert + assertAll( + () -> assertThat(info.productId()).isEqualTo(productId), + () -> assertThat(info.likeCount()).isEqualTo(1L), + () -> assertThat(info.liked()).isFalse() + ); + } + } +} 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..67ee129a9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,157 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.FakeBrandRepository; +import com.loopers.domain.order.FakeOrderItemRepository; +import com.loopers.domain.order.FakeOrderRepository; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.FakeProductRepository; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderFacadeTest { + + private OrderFacade orderFacade; + private FakeBrandRepository fakeBrandRepository; + private FakeProductRepository fakeProductRepository; + + private static final Long USER_ID = 1L; + + @BeforeEach + void setUp() { + fakeBrandRepository = new FakeBrandRepository(); + fakeProductRepository = new FakeProductRepository(); + FakeOrderRepository fakeOrderRepository = new FakeOrderRepository(); + FakeOrderItemRepository fakeOrderItemRepository = new FakeOrderItemRepository(); + + BrandService brandService = new BrandService(fakeBrandRepository); + ProductService productService = new ProductService(fakeProductRepository); + OrderService orderService = new OrderService(fakeOrderRepository, fakeOrderItemRepository); + + orderFacade = new OrderFacade(productService, brandService, orderService); + } + + @DisplayName("주문 생성 시,") + @Nested + class CreateOrder { + + @DisplayName("상품과 재고가 충분하면 주문이 정상적으로 생성된다.") + @Test + void createsOrder_whenProductExistsAndStockSufficient() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("브랜드명", "브랜드 설명", "http://brand.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "상품명", "상품 설명", 5000, 10, "http://product.url")); + + List items = List.of(new OrderCreateItem(product.getId(), 2)); + + // act + OrderInfo result = orderFacade.createOrder(USER_ID, items); + + // assert + assertAll( + () -> assertThat(result.orderId()).isNotNull(), + () -> assertThat(result.userId()).isEqualTo(USER_ID), + () -> assertThat(result.totalAmount()).isEqualTo(10000), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productId()).isEqualTo(product.getId()), + () -> assertThat(result.items().get(0).brandName()).isEqualTo("브랜드명"), + () -> assertThat(result.items().get(0).productName()).isEqualTo("상품명"), + () -> assertThat(result.items().get(0).quantity()).isEqualTo(2), + () -> assertThat(product.getStock()).isEqualTo(8) + ); + } + + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockInsufficient() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("브랜드명", "브랜드 설명", "http://brand.url")); + fakeProductRepository.save(new Product(brand.getId(), "상품명", "상품 설명", 5000, 1, "http://product.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "상품명", "상품 설명", 5000, 1, "http://product.url")); + + List items = List.of(new OrderCreateItem(product.getId(), 5)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder(USER_ID, items) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품으로 주문 시 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + List items = List.of(new OrderCreateItem(999L, 1)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder(USER_ID, items) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 조회 시,") + @Nested + class GetOrder { + + @DisplayName("존재하는 주문 ID로 주문 정보를 정상적으로 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("브랜드명", "브랜드 설명", "http://brand.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "상품명", "상품 설명", 3000, 10, "http://product.url")); + List createItems = List.of(new OrderCreateItem(product.getId(), 1)); + OrderInfo created = orderFacade.createOrder(USER_ID, createItems); + + // act + OrderInfo result = orderFacade.getOrder(created.orderId()); + + // assert + assertAll( + () -> assertThat(result.orderId()).isEqualTo(created.orderId()), + () -> assertThat(result.userId()).isEqualTo(USER_ID), + () -> assertThat(result.items()).hasSize(1) + ); + } + } + + @DisplayName("사용자별 주문 목록 조회 시,") + @Nested + class GetOrdersByUserId { + + @DisplayName("해당 사용자의 모든 주문을 반환한다.") + @Test + void returnsAllOrdersForUser() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("브랜드명", "브랜드 설명", "http://brand.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "상품명", "상품 설명", 2000, 10, "http://product.url")); + orderFacade.createOrder(USER_ID, List.of(new OrderCreateItem(product.getId(), 1))); + orderFacade.createOrder(USER_ID, List.of(new OrderCreateItem(product.getId(), 1))); + + // act + List results = orderFacade.getOrdersByUserId(USER_ID); + + // assert + assertThat(results).hasSize(2); + } + } +} 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..5b751aa4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,110 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.FakeBrandRepository; +import com.loopers.domain.like.FakeProductLikeRepository; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.FakeProductRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductFacadeTest { + + private ProductFacade productFacade; + private FakeBrandRepository fakeBrandRepository; + private FakeProductRepository fakeProductRepository; + private FakeProductLikeRepository fakeProductLikeRepository; + private ProductLikeService productLikeService; + + private static final Long USER_ID = 1L; + + @BeforeEach + void setUp() { + fakeBrandRepository = new FakeBrandRepository(); + fakeProductRepository = new FakeProductRepository(); + fakeProductLikeRepository = new FakeProductLikeRepository(); + + BrandService brandService = new BrandService(fakeBrandRepository); + ProductService productService = new ProductService(fakeProductRepository); + productLikeService = new ProductLikeService(fakeProductLikeRepository); + + productFacade = new ProductFacade(productService, brandService, productLikeService); + } + + @DisplayName("상품 상세 조회 시,") + @Nested + class GetProductDetail { + + @DisplayName("상품, 브랜드, 좋아요 정보가 통합되어 반환된다.") + @Test + void returnsProductDetail_withCombinedInfo() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("나이키", "스포츠 브랜드", "http://nike.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "에어맥스", "운동화", 150000, 20, "http://airmax.url")); + + productLikeService.like(USER_ID, product.getId()); + productLikeService.like(2L, product.getId()); + + // act + ProductInfo result = productFacade.getProductDetail(product.getId(), USER_ID); + + // assert + assertAll( + () -> assertThat(result.productId()).isEqualTo(product.getId()), + () -> assertThat(result.productName()).isEqualTo("에어맥스"), + () -> assertThat(result.description()).isEqualTo("운동화"), + () -> assertThat(result.price()).isEqualTo(150000), + () -> assertThat(result.stock()).isEqualTo(20), + () -> assertThat(result.brandId()).isEqualTo(brand.getId()), + () -> assertThat(result.brandName()).isEqualTo("나이키"), + () -> assertThat(result.likeCount()).isEqualTo(2L), + () -> assertThat(result.liked()).isTrue() + ); + } + + @DisplayName("존재하지 않는 상품 조회 시 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentProductId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productFacade.getProductDetail(nonExistentProductId, USER_ID) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("좋아요하지 않은 사용자에 대해 liked가 false로 반환된다.") + @Test + void returnsLikedFalse_whenUserHasNotLiked() { + // arrange + Brand brand = fakeBrandRepository.save(new Brand("아디다스", "스포츠 브랜드", "http://adidas.url")); + Product product = fakeProductRepository.save(new Product(brand.getId(), "슈퍼스타", "클래식 운동화", 120000, 30, "http://superstar.url")); + + productLikeService.like(2L, product.getId()); + + // act + ProductInfo result = productFacade.getProductDetail(product.getId(), USER_ID); + + // assert + assertAll( + () -> assertThat(result.likeCount()).isEqualTo(1L), + () -> assertThat(result.liked()).isFalse() + ); + } + } +} 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..722050374 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,207 @@ +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandServiceTest { + + private BrandRepository brandRepository; + private BrandService brandService; + + private static final String VALID_NAME = "나이키"; + private static final String VALID_DESCRIPTION = "스포츠 브랜드"; + private static final String VALID_IMAGE_URL = "https://example.com/nike.png"; + + @BeforeEach + void setUp() { + brandRepository = new FakeBrandRepository(); + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록 시,") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 브랜드가 생성된다.") + @Test + void createsBrand_whenValidInfoIsProvided() { + // arrange & act + Brand result = brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME), + () -> assertThat(result.getDescription()).isEqualTo(VALID_DESCRIPTION), + () -> assertThat(result.getImageUrl()).isEqualTo(VALID_IMAGE_URL) + ); + } + + @DisplayName("이미 존재하는 이름으로 등록하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenNameAlreadyExists() { + // arrange + brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.register(VALID_NAME, "다른 설명", "https://other.com/image.png") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 단건 조회 시,") + @Nested + class GetBrand { + + @DisplayName("존재하는 ID로 조회하면, 브랜드 정보를 반환한다.") + @Test + void returnsBrand_whenBrandExists() { + // arrange + Brand saved = brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + Brand result = brandService.getBrand(saved.getId()); + + // assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.getBrand(nonExistentId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록 조회 시,") + @Nested + class GetAllBrands { + + @DisplayName("등록된 브랜드가 있으면, 목록을 반환한다.") + @Test + void returnsBrandList_whenBrandsExist() { + // arrange + brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + brandService.register("아디다스", "또 다른 스포츠 브랜드", "https://example.com/adidas.png"); + + // act + List result = brandService.getAllBrands(); + + // assert + assertThat(result).hasSize(2); + } + } + + @DisplayName("브랜드 수정 시,") + @Nested + class UpdateBrand { + + @DisplayName("유효한 정보로 수정하면, 브랜드가 수정된다.") + @Test + void updatesBrand_whenValidInfoIsProvided() { + // arrange + Brand saved = brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + Brand result = brandService.update(saved.getId(), "아디다스", "새 설명", "https://new.com/image.png"); + + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("아디다스"), + () -> assertThat(result.getDescription()).isEqualTo("새 설명"), + () -> assertThat(result.getImageUrl()).isEqualTo("https://new.com/image.png") + ); + } + + @DisplayName("다른 브랜드가 이미 사용 중인 이름으로 수정하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenNameIsUsedByAnotherBrand() { + // arrange + brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + Brand second = brandService.register("아디다스", "또 다른 브랜드", "https://adidas.com/img.png"); + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.update(second.getId(), VALID_NAME, "새 설명", "https://new.com/img.png") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("존재하지 않는 ID로 수정하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.update(nonExistentId, VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시,") + @Nested + class DeleteBrand { + + @DisplayName("존재하는 ID로 삭제하면, 소프트 삭제된다.") + @Test + void softDeletesBrand_whenBrandExists() { + // arrange + Brand saved = brandService.register(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + brandService.delete(saved.getId()); + + // assert + Brand deleted = brandRepository.findById(saved.getId()).orElseThrow(); + assertThat(deleted.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + brandService.delete(nonExistentId) + ); + + // assert + assertThat(result.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..74f8153ef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,121 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + private static final String VALID_NAME = "나이키"; + private static final String VALID_DESCRIPTION = "스포츠 브랜드"; + private static final String VALID_IMAGE_URL = "https://example.com/nike.png"; + + @DisplayName("Brand 생성 시,") + @Nested + class Create { + + @DisplayName("유효한 이름으로 생성하면, 정상적으로 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // arrange & act + Brand brand = new Brand(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo(VALID_NAME), + () -> assertThat(brand.getDescription()).isEqualTo(VALID_DESCRIPTION), + () -> assertThat(brand.getImageUrl()).isEqualTo(VALID_IMAGE_URL) + ); + } + + @DisplayName("설명과 이미지 URL이 null이어도, 정상적으로 생성된다.") + @Test + void createsBrand_whenOptionalFieldsAreNull() { + // arrange & act & assert + assertDoesNotThrow(() -> new Brand(VALID_NAME, null, null)); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(null, VALID_DESCRIPTION, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(" ", VALID_DESCRIPTION, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Brand 수정 시,") + @Nested + class Update { + + @DisplayName("유효한 이름으로 수정하면, 정상적으로 수정된다.") + @Test + void updatesBrand_whenNameIsValid() { + // arrange + Brand brand = new Brand(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + brand.update("아디다스", "또 다른 스포츠 브랜드", "https://example.com/adidas.png"); + + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("아디다스"), + () -> assertThat(brand.getDescription()).isEqualTo("또 다른 스포츠 브랜드"), + () -> assertThat(brand.getImageUrl()).isEqualTo("https://example.com/adidas.png") + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUpdateNameIsNull() { + // arrange + Brand brand = new Brand(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + brand.update(null, VALID_DESCRIPTION, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUpdateNameIsBlank() { + // arrange + Brand brand = new Brand(VALID_NAME, VALID_DESCRIPTION, VALID_IMAGE_URL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + brand.update("", VALID_DESCRIPTION, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java new file mode 100644 index 000000000..e6c7f1c69 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -0,0 +1,54 @@ +package com.loopers.domain.brand; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private Long sequence = 1L; + + @Override + public Brand save(Brand brand) { + if (brand.getId() == null || brand.getId() == 0L) { + ReflectionTestUtils.setField(brand, "id", sequence++); + } + store.put(brand.getId(), brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public Optional findByName(String name) { + return store.values().stream() + .filter(brand -> brand.getName().equals(name)) + .findFirst(); + } + + @Override + public boolean existsByName(String name) { + return store.values().stream() + .anyMatch(brand -> brand.getName().equals(name)); + } + + @Override + public List findAllByIds(List ids) { + return store.values().stream() + .filter(brand -> ids.contains(brand.getId())) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java new file mode 100644 index 000000000..ebfa87461 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -0,0 +1,53 @@ +package com.loopers.domain.like; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeProductLikeRepository implements ProductLikeRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong sequence = new AtomicLong(1); + + @Override + public ProductLike save(ProductLike productLike) { + if (productLike.getId() == null) { + ReflectionTestUtils.setField(productLike, "id", sequence.getAndIncrement()); + } + store.put(productLike.getId(), productLike); + return productLike; + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId) && like.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public void deleteByUserIdAndProductId(Long userId, Long productId) { + store.values().stream() + .filter(like -> like.getUserId().equals(userId) && like.getProductId().equals(productId)) + .findFirst() + .ifPresent(like -> store.remove(like.getId())); + } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(like -> like.getProductId().equals(productId)) + .count(); + } + + @Override + public List findAllByUserId(Long userId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId)) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java new file mode 100644 index 000000000..6394515ab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -0,0 +1,137 @@ +package com.loopers.domain.like; + +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class ProductLikeServiceTest { + + private ProductLikeService productLikeService; + private FakeProductLikeRepository fakeProductLikeRepository; + + private static final Long USER_ID = 1L; + private static final Long PRODUCT_ID = 100L; + + @BeforeEach + void setUp() { + fakeProductLikeRepository = new FakeProductLikeRepository(); + productLikeService = new ProductLikeService(fakeProductLikeRepository); + } + + @DisplayName("좋아요 등록 시,") + @Nested + class Like { + + @DisplayName("좋아요가 정상적으로 등록된다.") + @Test + void likes_successfully() { + // arrange & act + productLikeService.like(USER_ID, PRODUCT_ID); + + // assert + assertThat(productLikeService.isLiked(USER_ID, PRODUCT_ID)).isTrue(); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요를 눌러도 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenAlreadyLiked() { + // arrange + productLikeService.like(USER_ID, PRODUCT_ID); + + // act & assert + assertDoesNotThrow(() -> productLikeService.like(USER_ID, PRODUCT_ID)); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요를 눌러도 좋아요 수는 1이다.") + @Test + void likeCount_remainsOne_whenLikedAgain() { + // arrange + productLikeService.like(USER_ID, PRODUCT_ID); + productLikeService.like(USER_ID, PRODUCT_ID); + + // assert + assertThat(productLikeService.getLikeCount(PRODUCT_ID)).isEqualTo(1L); + } + } + + @DisplayName("좋아요 취소 시,") + @Nested + class Unlike { + + @DisplayName("좋아요가 정상적으로 취소된다.") + @Test + void unlikes_successfully() { + // arrange + productLikeService.like(USER_ID, PRODUCT_ID); + + // act + productLikeService.unlike(USER_ID, PRODUCT_ID); + + // assert + assertThat(productLikeService.isLiked(USER_ID, PRODUCT_ID)).isFalse(); + } + + @DisplayName("좋아요하지 않은 상품에 좋아요 취소를 해도 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenNotLiked() { + // arrange & act & assert + assertDoesNotThrow(() -> productLikeService.unlike(USER_ID, PRODUCT_ID)); + } + } + + @DisplayName("좋아요 수 조회 시,") + @Nested + class GetLikeCount { + + @DisplayName("여러 사용자가 좋아요한 경우 올바른 좋아요 수를 반환한다.") + @Test + void returnsCorrectCount() { + // arrange + productLikeService.like(1L, PRODUCT_ID); + productLikeService.like(2L, PRODUCT_ID); + productLikeService.like(3L, PRODUCT_ID); + + // act + long count = productLikeService.getLikeCount(PRODUCT_ID); + + // assert + assertThat(count).isEqualTo(3L); + } + + @DisplayName("좋아요가 없으면 0을 반환한다.") + @Test + void returnsZero_whenNoLikes() { + // arrange & act + long count = productLikeService.getLikeCount(PRODUCT_ID); + + // assert + assertThat(count).isEqualTo(0L); + } + } + + @DisplayName("좋아요 여부 조회 시,") + @Nested + class IsLiked { + + @DisplayName("좋아요한 경우 true를 반환한다.") + @Test + void returnsTrue_whenLiked() { + // arrange + productLikeService.like(USER_ID, PRODUCT_ID); + + // act & assert + assertThat(productLikeService.isLiked(USER_ID, PRODUCT_ID)).isTrue(); + } + + @DisplayName("좋아요하지 않은 경우 false를 반환한다.") + @Test + void returnsFalse_whenNotLiked() { + // arrange & act & assert + assertThat(productLikeService.isLiked(USER_ID, PRODUCT_ID)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeTest.java new file mode 100644 index 000000000..c77aa0f24 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeTest.java @@ -0,0 +1,60 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductLikeTest { + + private static final Long VALID_USER_ID = 1L; + private static final Long VALID_PRODUCT_ID = 100L; + + @DisplayName("ProductLike 생성 시,") + @Nested + class Create { + + @DisplayName("유효한 userId와 productId로 생성하면, 정상적으로 생성된다.") + @Test + void creates_whenAllFieldsAreValid() { + // arrange & act + ProductLike productLike = new ProductLike(VALID_USER_ID, VALID_PRODUCT_ID); + + // assert + assertAll( + () -> assertThat(productLike.getUserId()).isEqualTo(VALID_USER_ID), + () -> assertThat(productLike.getProductId()).isEqualTo(VALID_PRODUCT_ID), + () -> assertThat(productLike.getCreatedAt()).isNotNull() + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductLike(null, VALID_PRODUCT_ID) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductLike(VALID_USER_ID, null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java new file mode 100644 index 000000000..c7be62520 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java @@ -0,0 +1,33 @@ +package com.loopers.domain.order; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class FakeOrderItemRepository implements OrderItemRepository { + + private final Map store = new HashMap<>(); + private long sequence = 0L; + + @Override + public List saveAll(List items) { + for (OrderItem item : items) { + Long currentId = item.getId(); + if (currentId == null || currentId == 0L) { + ReflectionTestUtils.setField(item, "id", ++sequence); + } + store.put(item.getId(), item); + } + return items; + } + + @Override + public List findAllByOrderId(Long orderId) { + return store.values().stream() + .filter(item -> orderId.equals(item.getOrderId())) + .collect(Collectors.toList()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..e9bb71794 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,42 @@ +package com.loopers.domain.order; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new HashMap<>(); + private long sequence = 0L; + + @Override + public Order save(Order order) { + if (order.getId() == null || order.getId() == 0L) { + ReflectionTestUtils.setField(order, "id", ++sequence); + } + store.put(order.getId(), order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByUserId(Long userId) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..0e6eda08d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,126 @@ +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 org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemTest { + + @DisplayName("OrderItem 스냅샷 생성 시,") + @Nested + class CreateSnapshot { + + @DisplayName("유효한 값이 주어지면, 정상적으로 생성된다.") + @Test + void createsSnapshot_whenAllFieldsAreValid() { + // arrange & act + OrderItem item = OrderItem.createSnapshot(1L, "나이키", "에어맥스", 100000, 2); + + // assert + assertAll( + () -> assertThat(item.getProductId()).isEqualTo(1L), + () -> assertThat(item.getBrandName()).isEqualTo("나이키"), + () -> assertThat(item.getProductName()).isEqualTo("에어맥스"), + () -> assertThat(item.getPrice()).isEqualTo(100000), + () -> assertThat(item.getQuantity()).isEqualTo(2) + ); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(null, "브랜드", "상품", 1000, 1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("brandName이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(1L, " ", "상품", 1000, 1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(1L, "브랜드", " ", 1000, 1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(1L, "브랜드", "상품", -1, 1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity가 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(1L, "브랜드", "상품", 1000, 0) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> + OrderItem.createSnapshot(1L, "브랜드", "상품", 1000, -1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("orderId 할당 시,") + @Nested + class AssignOrderId { + + @DisplayName("orderId가 정상적으로 설정된다.") + @Test + void assignsOrderId_correctly() { + // arrange + OrderItem item = OrderItem.createSnapshot(1L, "브랜드", "상품", 1000, 1); + + // act + item.assignOrderId(42L); + + // assert + assertThat(item.getOrderId()).isEqualTo(42L); + } + } +} 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..154710c0d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,134 @@ +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderServiceTest { + + private FakeOrderRepository orderRepository; + private FakeOrderItemRepository orderItemRepository; + private OrderService orderService; + + @BeforeEach + void setUp() { + orderRepository = new FakeOrderRepository(); + orderItemRepository = new FakeOrderItemRepository(); + orderService = new OrderService(orderRepository, orderItemRepository); + } + + @DisplayName("주문 생성 시,") + @Nested + class CreateOrder { + + @DisplayName("유효한 userId와 항목이 주어지면, 주문이 생성되고 항목에 orderId가 할당된다.") + @Test + void createsOrder_andAssignsOrderIdToItems() { + // arrange + Long userId = 1L; + List items = List.of( + OrderItem.createSnapshot(1L, "브랜드A", "상품A", 5000, 2), + OrderItem.createSnapshot(2L, "브랜드B", "상품B", 3000, 1) + ); + + // act + Order order = orderService.createOrder(userId, items); + + // assert + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getUserId()).isEqualTo(userId), + () -> assertThat(order.getTotalAmount()).isEqualTo(5000 * 2 + 3000 * 1), + () -> items.forEach(item -> assertThat(item.getOrderId()).isEqualTo(order.getId())) + ); + } + } + + @DisplayName("주문 조회 시,") + @Nested + class GetOrder { + + @DisplayName("존재하는 orderId를 주면, 해당 주문을 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + // arrange + List items = List.of(OrderItem.createSnapshot(1L, "브랜드", "상품", 1000, 1)); + Order created = orderService.createOrder(1L, items); + + // act + Order result = orderService.getOrder(created.getId()); + + // assert + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("존재하지 않는 orderId를 주면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> + orderService.getOrder(999L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 항목 조회 시,") + @Nested + class GetOrderItems { + + @DisplayName("orderId에 해당하는 항목 목록을 반환한다.") + @Test + void returnsOrderItems_forGivenOrderId() { + // arrange + List items = List.of( + OrderItem.createSnapshot(1L, "브랜드A", "상품A", 1000, 2), + OrderItem.createSnapshot(2L, "브랜드B", "상품B", 2000, 1) + ); + Order order = orderService.createOrder(1L, items); + + // act + List result = orderService.getOrderItems(order.getId()); + + // assert + assertThat(result).hasSize(2); + } + } + + @DisplayName("사용자별 주문 조회 시,") + @Nested + class GetOrdersByUserId { + + @DisplayName("userId에 해당하는 주문 목록을 반환한다.") + @Test + void returnsOrders_forGivenUserId() { + // arrange + Long userId = 1L; + List items1 = List.of(OrderItem.createSnapshot(1L, "브랜드", "상품A", 1000, 1)); + List items2 = List.of(OrderItem.createSnapshot(2L, "브랜드", "상품B", 2000, 1)); + orderService.createOrder(userId, items1); + orderService.createOrder(userId, items2); + orderService.createOrder(2L, List.of(OrderItem.createSnapshot(3L, "브랜드", "상품C", 500, 1))); + + // act + List result = orderService.getOrdersByUserId(userId); + + // assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> result.forEach(order -> assertThat(order.getUserId()).isEqualTo(userId)) + ); + } + } +} 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..f73bba03e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,87 @@ +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + private static OrderItem validItem(int price, int quantity) { + return OrderItem.createSnapshot(1L, "브랜드", "상품", price, quantity); + } + + @DisplayName("Order 생성 시,") + @Nested + class Create { + + @DisplayName("유효한 userId와 항목 목록이 주어지면, 정상적으로 생성된다.") + @Test + void createsOrder_whenValidUserIdAndItemsAreProvided() { + // arrange + List items = List.of(validItem(1000, 2), validItem(500, 3)); + + // act + Order order = Order.create(1L, items); + + // assert + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalAmount()).isEqualTo(3500) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // arrange + List items = List.of(validItem(1000, 1)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + Order.create(null, items) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("항목 목록이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsAreEmpty() { + // arrange + List items = List.of(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + Order.create(1L, items) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 항목들의 가격 * 수량 합계로 계산된다.") + @Test + void calculatesTotalAmount_fromItemsPriceAndQuantity() { + // arrange + List items = List.of( + validItem(2000, 3), + validItem(1000, 5) + ); + + // act + Order order = Order.create(1L, items); + + // assert + assertThat(order.getTotalAmount()).isEqualTo(2000 * 3 + 1000 * 5); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java new file mode 100644 index 000000000..8a06228da --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -0,0 +1,58 @@ +package com.loopers.domain.product; + +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private long sequence = 0L; + + @Override + public Product save(Product product) { + if (product.getId() == null || product.getId().equals(0L)) { + sequence++; + ReflectionTestUtils.setField(product, "id", sequence); + } + store.put(product.getId(), product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public List findAllByBrandId(Long brandId) { + List result = new ArrayList<>(); + for (Product product : store.values()) { + if (brandId.equals(product.getBrandId())) { + result.add(product); + } + } + return result; + } + + @Override + public List findAllByIds(List ids) { + List result = new ArrayList<>(); + for (Long id : ids) { + Product product = store.get(id); + if (product != null) { + result.add(product); + } + } + return result; + } +} 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..50a5fcebd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,247 @@ +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductServiceTest { + + private ProductService productService; + private FakeProductRepository fakeProductRepository; + + private static final Long VALID_BRAND_ID = 1L; + private static final String VALID_NAME = "테스트 상품"; + private static final String VALID_DESCRIPTION = "상품 설명"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + private static final String VALID_IMAGE_URL = "https://example.com/image.jpg"; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + productService = new ProductService(fakeProductRepository); + } + + @DisplayName("상품 등록 시,") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 상품이 생성된다.") + @Test + void createsProduct_whenValidInfoIsProvided() { + // arrange & act + Product result = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getBrandId()).isEqualTo(VALID_BRAND_ID), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME) + ); + } + } + + @DisplayName("상품 조회 시,") + @Nested + class GetProduct { + + @DisplayName("존재하는 ID로 조회하면, 상품 정보를 반환한다.") + @Test + void returnsProduct_whenProductExists() { + // arrange + Product saved = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + + // act + Product result = productService.getProduct(saved.getId()); + + // assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.getProduct(nonExistentId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 수정 시,") + @Nested + class Update { + + @DisplayName("존재하는 ID로 수정하면, 상품 정보가 변경된다.") + @Test + void updatesProduct_whenProductExists() { + // arrange + Product saved = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + String newName = "수정된 상품명"; + + // act + Product result = productService.update(saved.getId(), newName, "새 설명", 20000, 50, "https://example.com/new.jpg"); + + // assert + assertThat(result.getName()).isEqualTo(newName); + } + + @DisplayName("존재하지 않는 ID로 수정하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.update(nonExistentId, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 삭제 시,") + @Nested + class Delete { + + @DisplayName("존재하는 ID로 삭제하면, 소프트 삭제된다.") + @Test + void deletesProduct_whenProductExists() { + // arrange + Product saved = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + + // act + productService.delete(saved.getId()); + + // assert + Product result = fakeProductRepository.findById(saved.getId()).orElseThrow(); + assertThat(result.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.delete(nonExistentId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("재고 감소 시,") + @Nested + class DecreaseStock { + + @DisplayName("재고가 충분하면, 재고가 감소한다.") + @Test + void decreasesStock_whenStockIsSufficient() { + // arrange + Product saved = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 10, VALID_IMAGE_URL); + + // act + productService.decreaseStock(saved.getId(), 5); + + // assert + Product result = productService.getProduct(saved.getId()); + assertThat(result.getStock()).isEqualTo(5); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + // arrange + Product saved = productService.register(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 3, VALID_IMAGE_URL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.decreaseStock(saved.getId(), 5) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 ID로 재고 감소하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.decreaseStock(nonExistentId, 1) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드별 상품 일괄 삭제 시,") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품이 소프트 삭제된다.") + @Test + void deletesAllProducts_whenBrandIdIsGiven() { + // arrange + productService.register(VALID_BRAND_ID, "상품1", null, 1000, 10, null); + productService.register(VALID_BRAND_ID, "상품2", null, 2000, 20, null); + productService.register(2L, "다른브랜드상품", null, 3000, 30, null); + + // act + productService.deleteAllByBrandId(VALID_BRAND_ID); + + // assert + List brandProducts = fakeProductRepository.findAllByBrandId(VALID_BRAND_ID); + assertThat(brandProducts).allMatch(p -> p.getDeletedAt() != null); + } + } + + @DisplayName("브랜드별 상품 조회 시,") + @Nested + class GetProductsByBrandId { + + @DisplayName("해당 브랜드의 상품 목록을 반환한다.") + @Test + void returnsProducts_whenBrandIdIsGiven() { + // arrange + productService.register(VALID_BRAND_ID, "상품1", null, 1000, 10, null); + productService.register(VALID_BRAND_ID, "상품2", null, 2000, 20, null); + productService.register(2L, "다른브랜드상품", null, 3000, 30, null); + + // act + List result = productService.getProductsByBrandId(VALID_BRAND_ID); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allMatch(p -> VALID_BRAND_ID.equals(p.getBrandId())); + } + } +} 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..b280eac4f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,197 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + private static final Long VALID_BRAND_ID = 1L; + private static final String VALID_NAME = "테스트 상품"; + private static final String VALID_DESCRIPTION = "상품 설명"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + private static final String VALID_IMAGE_URL = "https://example.com/image.jpg"; + + @DisplayName("Product 생성 시,") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsProduct_whenAllFieldsAreValid() { + // arrange & act + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + + // assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(VALID_BRAND_ID), + () -> assertThat(product.getName()).isEqualTo(VALID_NAME), + () -> assertThat(product.getDescription()).isEqualTo(VALID_DESCRIPTION), + () -> assertThat(product.getPrice()).isEqualTo(VALID_PRICE), + () -> assertThat(product.getStock()).isEqualTo(VALID_STOCK), + () -> assertThat(product.getImageUrl()).isEqualTo(VALID_IMAGE_URL) + ); + } + + @DisplayName("brandId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(null, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(VALID_BRAND_ID, null, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(VALID_BRAND_ID, " ", VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, -1, VALID_STOCK, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("stock이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, -1, VALID_IMAGE_URL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 감소 시,") + @Nested + class DecreaseStock { + + @DisplayName("재고가 충분하면, 재고가 감소한다.") + @Test + void decreasesStock_whenStockIsSufficient() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 10, VALID_IMAGE_URL); + + // act + assertDoesNotThrow(() -> product.decreaseStock(5)); + + // assert + assertThat(product.getStock()).isEqualTo(5); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 3, VALID_IMAGE_URL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + product.decreaseStock(5) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 확인 시,") + @Nested + class HasEnoughStock { + + @DisplayName("재고가 충분하면, true를 반환한다.") + @Test + void returnsTrue_whenStockIsSufficient() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 10, VALID_IMAGE_URL); + + // act + boolean result = product.hasEnoughStock(10); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("재고가 부족하면, false를 반환한다.") + @Test + void returnsFalse_whenStockIsInsufficient() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, 3, VALID_IMAGE_URL); + + // act + boolean result = product.hasEnoughStock(5); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("상품 수정 시,") + @Nested + class Update { + + @DisplayName("유효한 값으로 수정하면, 상품 정보가 변경된다.") + @Test + void updatesProduct_whenValidValuesAreProvided() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_DESCRIPTION, VALID_PRICE, VALID_STOCK, VALID_IMAGE_URL); + String newName = "수정된 상품명"; + String newDescription = "수정된 설명"; + int newPrice = 20000; + int newStock = 50; + String newImageUrl = "https://example.com/new-image.jpg"; + + // act + product.update(newName, newDescription, newPrice, newStock, newImageUrl); + + // assert + assertAll( + () -> assertThat(product.getName()).isEqualTo(newName), + () -> assertThat(product.getDescription()).isEqualTo(newDescription), + () -> assertThat(product.getPrice()).isEqualTo(newPrice), + () -> assertThat(product.getStock()).isEqualTo(newStock), + () -> assertThat(product.getImageUrl()).isEqualTo(newImageUrl) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..d44297a90 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입 시,") + @Nested + class Register { + + @DisplayName("유효한 정보로 가입하면, 사용자가 생성된다.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange & act + User result = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("비밀번호가 BCrypt로 암호화되어 저장된다.") + @Test + void encryptsPassword_whenUserIsRegistered() { + // arrange & act + User result = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(result.getPassword()).isNotEqualTo(VALID_PASSWORD), + () -> assertThat(result.getPassword()).startsWith("$2a$") + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenLoginIdAlreadyExists() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, "다른이름", VALID_BIRTH_DATE, "other@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("회원 조회 시,") + @Nested + class GetUser { + + @DisplayName("존재하는 ID로 조회하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenUserExists() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + User result = userService.getUser(savedUser.getId()); + + // assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(savedUser.getId()), + () -> assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenUserDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.getUser(nonExistentId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("인증 시,") + @Nested + class Authenticate { + + @DisplayName("로그인 ID와 비밀번호가 일치하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenCredentialsMatch() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + User result = userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + + // assert + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordDoesNotMatch() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.authenticate(VALID_LOGIN_ID, "WrongPassword1!") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @DisplayName("현재 비밀번호가 일치하고 새 비밀번호가 유효하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenCurrentPasswordMatchesAndNewPasswordIsValid() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + String newPassword = "NewPass123!"; + + // act + userService.changePassword(savedUser.getId(), VALID_PASSWORD, newPassword); + + // assert + User updatedUser = userService.getUser(savedUser.getId()); + assertThat(updatedUser.getPassword()).startsWith("$2a$"); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.changePassword(savedUser.getId(), "WrongPassword1!", "NewPass123!") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.changePassword(savedUser.getId(), VALID_PASSWORD, VALID_PASSWORD) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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 new file mode 100644 index 000000000..a52987605 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,234 @@ +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_ENCODED_PASSWORD = "$2a$10$encodedPassword"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @DisplayName("User 생성 시,") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsUser_whenAllFieldsAreValid() { + // arrange & act + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(user.getPassword()).isEqualTo(VALID_ENCODED_PASSWORD), + () -> assertThat(user.getName()).isEqualTo(VALID_NAME), + () -> assertThat(user.getBirthDate()).isEqualTo(VALID_BIRTH_DATE), + () -> assertThat(user.getEmail()).isEqualTo(VALID_EMAIL) + ); + } + + @DisplayName("로그인 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(null, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("로그인 ID에 특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User("test@user!", VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, null, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 미래면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthDateIsFuture() { + // arrange + LocalDate futureBirthDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, futureBirthDate, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 올바르지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, "invalid-email") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호 검증 시,") + @Nested + class ValidateRawPassword { + + @DisplayName("유효한 비밀번호면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenPasswordIsValid() { + // arrange & act & assert + assertDoesNotThrow(() -> + User.validateRawPassword("Password1!", VALID_BIRTH_DATE) + ); + } + + @DisplayName("비밀번호가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass1!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호가 16자 초과면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // arrange + String longPassword = "Password1!" + "a".repeat(7); + + // act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword(longPassword, VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsKorean() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass한글1!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate_yyyyMMdd() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass19900515!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate_yyMMdd() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass900515!!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름 마스킹 시,") + @Nested + class GetMaskedName { + + @DisplayName("이름이 2자 이상이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasMultipleCharacters() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, "홍길동", VALID_BIRTH_DATE, VALID_EMAIL); + + // act + String maskedName = user.getMaskedName(); + + // assert + assertThat(maskedName).isEqualTo("홍길*"); + } + + @DisplayName("이름이 1자이면, *로 반환된다.") + @Test + void returnsStar_whenNameHasSingleCharacter() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, "홍", VALID_BIRTH_DATE, VALID_EMAIL); + + // act + String maskedName = user.getMaskedName(); + + // assert + assertThat(maskedName).isEqualTo("*"); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @DisplayName("새로운 인코딩된 비밀번호로 변경된다.") + @Test + void changesPassword_whenNewEncodedPasswordIsProvided() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + String newEncodedPassword = "$2a$10$newEncodedPassword"; + + // act + user.changePassword(newEncodedPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newEncodedPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java new file mode 100644 index 000000000..33277bb0f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -0,0 +1,248 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createTestUser() { + String encodedPassword = passwordEncoder.encode(VALID_PASSWORD); + User user = new User(VALID_LOGIN_ID, encodedPassword, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + return userJpaRepository.save(user); + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + @DisplayName("POST /api/v1/users (회원가입)") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 201 CREATED를 반환한다.") + @Test + void returnsCreated_whenValidRequestIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, 409 CONFLICT를 반환한다.") + @Test + void returnsConflict_whenLoginIdAlreadyExists() { + // arrange + createTestUser(); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, VALID_PASSWORD, "다른이름", VALID_BIRTH_DATE, "other@example.com" + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("비밀번호가 8자 미만이면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenPasswordIsTooShort() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, "Pass1!", VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me (내 정보 조회)") + @Nested + class GetMe { + + @DisplayName("유효한 인증 정보로 조회하면, 마스킹된 이름이 반환된다.") + @Test + void returnsMaskedName_whenValidCredentials() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, VALID_PASSWORD); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*") + ); + } + + @DisplayName("비밀번호가 틀리면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenPasswordIsWrong() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, "WrongPassword1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PUT /api/v1/users/password (비밀번호 변경)") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, VALID_PASSWORD); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + VALID_PASSWORD, "NewPassword1!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("현재 비밀번호가 틀리면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenCurrentPasswordIsWrong() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, "WrongPassword1!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "WrongPassword1!", "NewPassword1!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..68c0e2cb8 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,260 @@ +# 01. 요구사항 명세 + +## 1. 이 서비스가 풀려는 문제 + +감성 이커머스라는 컨셉 아래, 유저가 브랜드 상품을 탐색하고 좋아요를 누르고, 여러 상품을 한 번에 주문하는 흐름을 만든다. +회원 도메인은 1주차에 완성됐으므로 제외하고, 이번 설계 범위는 **브랜드, 상품, 좋아요, 주문**이다. + +각 도메인이 풀려는 문제를 관점별로 정리하면: + +**사용자 관점** +- 여러 브랜드의 상품을 한 곳에서 탐색하고, 마음에 드는 상품에 관심을 표시하고 싶다. +- 여러 상품을 한 번에 골라서 주문하고, 내 주문 이력을 언제든 확인하고 싶다. +- 과거 주문에서 "그때 얼마였지?"를 확인할 수 있어야 한다 → 주문 시점 가격이 보존되어야 하는 이유. + +**비즈니스 관점** +- 어드민이 브랜드와 상품을 등록/관리할 수 있어야 한다. +- 좋아요 데이터는 단순 기능이 아니라, 향후 랭킹/추천의 기반 데이터가 된다. +- 상품 가격이 변경되더라도 이미 완료된 주문 금액에 영향이 가면 안 된다. + +**시스템 관점** +- 주문 시 재고 차감이 정합성 있게 이루어져야 한다. 재고가 음수가 되거나, 동시 주문으로 초과 차감되면 안 된다. +- 브랜드 삭제 시 소속 상품이 고아 상태로 남으면 안 된다. 하지만 그 상품에 걸린 주문/좋아요 이력은 보존되어야 한다. +- 결제 시스템은 아직 없다. 선결 조건만 충족하면 주문은 즉시 완료된다. 하지만 나중에 결제/쿠폰이 추가될 수 있으므로, 확장 여지를 닫아버리면 안 된다. + +### 액터 + +| 액터 | 설명 | 인증 방식 | +|------|------|-----------| +| 고객 (User) | 상품 탐색, 좋아요, 주문을 수행하는 일반 사용자 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` | +| 어드민 (Admin) | 브랜드/상품/주문을 관리하는 사내 운영자 | `X-Loopers-Ldap: loopers.admin` | + +### 핵심 도메인 + +| 도메인 | 핵심 책임 | +|--------|-----------| +| Brand | 상품을 묶는 브랜드 단위. 어드민이 관리한다. | +| Product | 가격, 재고를 가진 판매 단위. 브랜드에 종속된다. | +| ProductLike | 고객의 상품 관심 표시. 향후 랭킹/추천 데이터 기반이 된다. | +| Order / OrderItem | 고객의 구매 행위. 주문 시점의 상품 정보를 스냅샷으로 보존한다. | + +--- + +## 2. 설계 판단 + +요구사항만으로는 결정할 수 없는 부분들이 많았다. 아래는 고민 과정과 결정 근거를 함께 정리한 것이다. + +### 삭제 전략: Soft Delete + +BaseEntity에 이미 `deletedAt`이 내장되어 있고, 주문 이력과 좋아요 데이터를 보존해야 하므로 soft delete를 사용한다. + +다만 soft delete를 쓰면서 **브랜드 이름에 DB UNIQUE 제약을 거는 건 충돌이 생긴다.** "Nike"를 삭제(soft)한 뒤 새로운 "Nike"를 등록하면 UNIQUE 위반이 난다. 처음엔 `UNIQUE(name, deleted_at)` 복합 유니크를 고민했는데, MySQL에서 NULL이 포함된 유니크 인덱스 동작이 까다롭다. 결국 **DB UNIQUE 제약은 걸지 않고, Application 레벨에서 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름을 검사**하는 방식으로 결정했다. + +### 주문 완료 조건: 선결 조건 충족 = 즉시 완료 + +결제 시스템이 없으므로 주문 상태(CREATED, CONFIRMED, CANCELLED 같은) 관리가 불필요하다. 선결 조건(상품 존재, 재고 충분)만 통과하면 주문은 바로 완료다. 그래서 Order 엔티티에 `status` 필드를 넣지 않았다. + +다만 나중에 결제가 추가되면 status 컬럼을 새로 넣어야 하는 마이그레이션이 발생한다. 이 부분은 인지하고 있지만, 지금 쓰지도 않을 필드를 미리 넣어두는 건 과설계라고 판단했다. 결제가 도입될 때 `ALTER TABLE ADD COLUMN`으로 충분히 대응 가능하다. + +### 좋아요 멱등성: 에러 대신 무시 + +이미 좋아요한 상태에서 다시 POST가 오면 409를 줄지, 그냥 200으로 무시할지 고민했다. 실제 쇼핑몰 앱에서 유저가 좋아요 버튼을 연타하거나, 네트워크 재시도로 같은 요청이 두 번 오는 건 흔한 일이다. 그때마다 에러를 던지면 클라이언트 처리가 번거로워진다. **멱등하게 200 OK를 반환**하는 게 클라이언트 구현도 편하고 안전하다. 취소(DELETE)도 마찬가지 — 이미 취소된 상태에서 다시 요청이 와도 200 OK. + +### 좋아요 목록 조회: 본인만 + +`/api/v1/users/{userId}/likes`에서 userId가 본인이 아닌 경우를 403으로 막는다. 현재 요구사항에 소셜 기능(다른 사람의 좋아요 목록 보기)이 없고, "내가 좋아요 한 상품 목록 조회"라고 명시되어 있다. 사실 URI를 `/api/v1/users/me/likes`로 바꾸는 게 더 자연스러운데, 원본 요구사항의 URI를 그대로 따랐다. + +### 주문 스냅샷 범위 + +주문 시점에 OrderItem에 저장할 정보를 **상품명, 가격, 브랜드명**으로 결정했다. 이미지 URL까지 넣을지 고민했는데, 주문 이력 화면에서 가장 중요한 건 "무슨 상품을, 얼마에, 몇 개 샀는지"다. 이미지는 상품이 살아있으면 원본에서 가져올 수 있고, 삭제됐으면 기본 이미지를 보여주면 된다. 필요해지면 나중에 컬럼을 추가하면 되므로 최소한으로 가져간다. + +### 주문 항목 중복 처리 + +같은 주문 요청에 동일 상품이 여러 번 들어오는 경우를 어떻게 처리할지 — 예를 들어 `[{productId:1, qty:2}, {productId:1, qty:3}]`. 합산해서 qty:5로 처리하는 방법도 있지만, 클라이언트가 보낸 데이터를 서버가 자의적으로 변형하는 건 오히려 혼란을 줄 수 있다. **400으로 거부하고 클라이언트가 정리해서 보내도록 강제**한다. 입력 정합성은 입구에서 막는 게 깔끔하다. + +### 주문 수량 제한 + +한 상품에 수량 제한이 없으면 qty:999999 같은 요청이 들어올 수 있다. 일반적인 쇼핑몰에서 한 번에 99개 이상 주문하는 경우는 거의 없으므로, **단일 항목 최대 수량을 99개로 제한**한다. 이건 비즈니스 정책이므로 나중에 바뀔 수 있지만, 아무 제한이 없는 것보단 낫다. + +### 재고 0 상품 노출 + +재고가 0인 상품을 목록에서 숨길지 말지 — **노출하되 주문만 불가**로 결정했다. 재고가 없다고 숨겨버리면 유저 입장에서 "어? 아까 본 상품이 사라졌네?" 하는 혼란이 생긴다. 품절 상태를 보여주는 게 더 자연스러운 UX다. + +### 어드민 목록에서 삭제된 데이터 + +어드민은 운영자니까 삭제된 브랜드/상품도 확인할 수 있어야 한다. 어드민 목록 조회 API에 **`deleted` 파라미터를 추가**해서, `deleted=true`면 삭제된 것만, `deleted=false`(기본값)면 활성 데이터만 보여주는 방식으로 처리한다. + +### 상품 조회 시 브랜드 정보 조회 + +상품 응답에 브랜드 정보(id, 이름)를 포함해야 하는데, Product 엔티티에는 brandId만 있다. JPA 연관관계를 안 쓰기로 했으므로 두 가지 방법이 있다: +- A) 쿼리 레벨에서 join → 성능은 좋지만 ID 참조 원칙과 다소 충돌 +- B) 상품 조회 후 brandId 목록으로 Brand를 별도 조회 → N+1은 아니고 2번 쿼리 + +**목록 조회는 쿼리 join, 상세 조회는 별도 조회**로 갔다. 목록은 성능이 중요하고, 상세는 한 건이라 브랜드 한 건 더 조회해도 문제없다. + +### 좋아요 수 집계 + +상품 목록에서 좋아요 수를 보여줘야 하고, `likes_desc` 정렬도 지원한다. Product에 `likeCount` 같은 비정규화 필드를 넣을지 고민했는데, 현재 트래픽 규모에서는 **COUNT 서브쿼리로 충분**하다. 비정규화는 카운트 정합성 관리(좋아요 등록/취소 시 동기화) 비용이 생기므로, 성능 문제가 실제로 발생할 때 도입하는 게 맞다. + +--- + +## 3. 기능 요구사항 + +### 3.1 브랜드 (Brand) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 브랜드 조회 | GET | `/api/v1/brands/{brandId}` | X | 단일 브랜드 정보를 조회한다. 삭제된 브랜드는 404. | + +**고객에게 제공하는 브랜드 정보:** id, 이름, 설명, 이미지 URL + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 브랜드 목록 조회 | GET | `/api-admin/v1/brands?page=0&size=20&deleted=false` | LDAP | 등록된 브랜드 목록. `deleted=true`로 삭제된 브랜드도 조회 가능. | +| 브랜드 상세 조회 | GET | `/api-admin/v1/brands/{brandId}` | LDAP | 단일 브랜드 상세 정보 (삭제된 것도 조회 가능) | +| 브랜드 등록 | POST | `/api-admin/v1/brands` | LDAP | 새 브랜드 등록 | +| 브랜드 수정 | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | 브랜드 정보 수정 | +| 브랜드 삭제 | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | 브랜드 soft delete + 해당 상품 일괄 soft delete | + +**어드민에게 추가 제공하는 정보:** createdAt, updatedAt, deletedAt, 소속 상품 수 + +**브랜드 등록/수정 시 검증:** +- 이름: 필수, 빈 값 불가 +- 이름 중복: 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름 등록 불가 (CONFLICT) + +--- + +### 3.2 상품 (Product) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api/v1/products` | X | 상품 목록 (필터 + 정렬 + 페이지네이션). 삭제된 상품 제외, 재고 0 포함. | +| 상품 상세 조회 | GET | `/api/v1/products/{productId}` | X | 단일 상품 정보. 삭제된 상품은 404. | + +**상품 목록 쿼리 파라미터:** + +| 파라미터 | 타입 | 기본값 | 설명 | +|----------|------|--------|------| +| `brandId` | Long | - | 특정 브랜드 필터링 (선택) | +| `sort` | String | `latest` | 정렬 기준: `latest`, `price_asc`, `likes_desc` | +| `page` | int | 0 | 페이지 번호 | +| `size` | int | 20 | 페이지당 상품 수 | + +**고객에게 제공하는 상품 정보:** id, 상품명, 설명, 가격, 재고, 이미지 URL, 브랜드 정보(id, 이름), 좋아요 수 + +> 좋아요 수는 `product_likes` 테이블의 COUNT 서브쿼리로 집계한다. +> 목록 조회 시 브랜드 정보는 쿼리 레벨 join으로 가져온다. + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}&deleted=false` | LDAP | 등록된 상품 목록. `deleted=true`로 삭제된 상품도 조회 가능. | +| 상품 상세 조회 | GET | `/api-admin/v1/products/{productId}` | LDAP | 상품 상세 정보 (삭제된 것도 조회 가능) | +| 상품 등록 | POST | `/api-admin/v1/products` | LDAP | 새 상품 등록 | +| 상품 수정 | PUT | `/api-admin/v1/products/{productId}` | LDAP | 상품 정보 수정 | +| 상품 삭제 | DELETE | `/api-admin/v1/products/{productId}` | LDAP | 상품 soft delete | + +**상품 등록 시 제약:** +- 브랜드: 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 +- 상품명: 필수 +- 가격: 0 이상 +- 재고: 0 이상 + +**상품 수정 시 제약:** +- 브랜드는 변경 불가 +- 나머지 필드만 수정 가능 + +--- + +### 3.3 좋아요 (ProductLike) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 좋아요 등록 | POST | `/api/v1/products/{productId}/likes` | O | 상품에 좋아요. 멱등 처리 (이미 있으면 무시). | +| 좋아요 취소 | DELETE | `/api/v1/products/{productId}/likes` | O | 좋아요 해제. 멱등 처리 (없으면 무시). | +| 내 좋아요 목록 | GET | `/api/v1/users/{userId}/likes` | O | 본인이 좋아요한 상품 목록. userId ≠ 본인이면 403. | + +**좋아요 등록 제약:** +- 삭제된 상품에는 좋아요 불가 (404) +- 이미 좋아요한 상태에서 재요청 시 200 OK (멱등) + +**좋아요 취소:** +- 삭제된 상품이라도 기존 좋아요는 취소 가능 (상품 검증 생략) +- 이미 취소된 상태에서 재요청 시 200 OK (멱등) + +**좋아요 목록 응답:** +- 좋아요한 상품 정보 (id, 상품명, 가격, 브랜드명, 이미지 URL) +- 삭제된 상품은 DB 쿼리 레벨에서 `products.deleted_at IS NULL` join으로 제외 + +--- + +### 3.4 주문 (Order) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 주문 생성 | POST | `/api/v1/orders` | O | 다건 상품 주문. 재고 확인 및 차감. 스냅샷 저장. | +| 주문 목록 조회 | GET | `/api/v1/orders?startAt=..&endAt=..&page=0&size=20` | O | 기간별 주문 목록 (페이지네이션) | +| 주문 상세 조회 | GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 (주문 항목 포함) | + +**주문 생성 요청:** +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +**주문 생성 시 처리 흐름:** +1. 주문 항목이 비어있지 않은지 확인 +2. 동일 상품 중복 여부 확인 (같은 productId가 두 번 오면 400 거부) +3. 각 항목의 수량이 1~99 범위인지 확인 +4. 요청된 상품들이 모두 존재하고 삭제되지 않았는지 확인 +5. 각 상품의 재고가 요청 수량 이상인지 확인 +6. 재고 차감 +7. 주문 시점의 상품 정보를 OrderItem에 스냅샷으로 저장 (상품명, 가격, 브랜드명) +8. 총 주문 금액 계산 (각 항목의 가격 x 수량의 합) +9. Order 생성 (선결 조건 통과 = 주문 완료) + +**주문 생성 실패 조건:** +- 주문 항목이 비어있음 → 400 +- 동일 상품이 중복으로 포함됨 → 400 +- 수량이 1~99 범위 밖 → 400 +- 상품이 존재하지 않거나 삭제됨 → 404 +- 재고 부족 → 400 (어떤 상품이 부족한지 메시지에 포함) + +**주문 상세 조회:** +- 본인의 주문만 조회 가능 (타인 주문 접근 시 403) +- 주문 정보: id, 총 금액, 주문 일시 +- 주문 항목: 상품 스냅샷(상품명, 가격, 브랜드명), 수량, 소계 + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 주문 목록 조회 | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | 전체 주문 목록 (페이지네이션) | +| 주문 상세 조회 | GET | `/api-admin/v1/orders/{orderId}` | LDAP | 단일 주문 상세 (주문자 정보 포함) | + +--- + +## 4. 향후 확장 시 영향 범위 + +지금은 스코프 밖이지만, 나중에 추가될 때 현재 설계에 미치는 영향을 미리 정리해둔다. + +| 기능 | 현재 | 추가 시 영향 | +|------|------|-------------| +| 결제 | 없음. 주문 = 완료. | Order에 `status` 컬럼 추가 (`ALTER TABLE`). 주문 생성 시퀀스에서 재고 차감 후 결제 단계 삽입. 실패 시 재고 복원(보상 트랜잭션) 필요. | +| 주문 취소 | 없음 | 취소 API + 재고 복원 로직. status 필드 필요. | +| 쿠폰 | 없음 | 주문 생성 흐름에서 재고 확인과 주문 생성 사이에 쿠폰 검증/적용 단계 삽입. Order에 할인 금액 필드 추가. | +| 동시성 제어 | 기본 구현 (Application 레벨 검증) | 비관적 락(`SELECT FOR UPDATE`) 또는 낙관적 락(`@Version`), DB `CHECK (stock >= 0)` | +| 좋아요 수 비정규화 | COUNT 쿼리 | Product에 `like_count` 컬럼 추가. 좋아요 등록/취소 시 동기화 필요. 정합성 관리 비용 발생. | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..7c560d43f --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,208 @@ +# 02. 시퀀스 다이어그램 + +## 1. 주문 생성 흐름 + +### 왜 이 다이어그램이 필요한가 + +주문 생성은 이 시스템에서 가장 복잡한 흐름이다. +입력 검증 → 상품 존재/삭제 확인 → 재고 확인 → 차감 → 브랜드 조회 → 스냅샷 저장 → 주문 생성이 **하나의 트랜잭션** 안에서 일어나야 하고, 어느 단계에서 실패하든 전체가 롤백되어야 한다. + +특히 검증해야 할 건: +- 요청 레벨 검증(중복 상품, 수량 범위)과 도메인 레벨 검증(상품 존재, 재고)의 **순서와 책임 분리** +- 재고 차감과 주문 저장이 묶이는 **트랜잭션 경계** +- 스냅샷에 필요한 브랜드 정보를 **어디서 가져오는지** + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant ProductService as ProductService + participant BrandService as BrandService + participant Product as Product + participant Order as Order + participant OrderRepo as OrderRepository + + Client->>Controller: POST /api/v1/orders
{items: [{productId, quantity}]} + + Note over Controller: 요청 검증
- 항목 비어있는지
- 동일 상품 중복 여부
- 수량 1~99 범위 + + Controller->>Facade: createOrder(userId, command) + + Note over Facade: 트랜잭션 시작 + + Facade->>ProductService: getActiveProducts(productIds) + Note over ProductService: deleted_at IS NULL 필터링 + alt 상품 없음 or 삭제됨 + ProductService-->>Facade: NOT_FOUND + Facade--xClient: 404 + end + ProductService-->>Facade: List + + loop 각 주문 항목에 대해 + Facade->>Product: hasEnoughStock(quantity) + alt 재고 부족 + Product-->>Facade: false + Facade--xClient: 400 Bad Request (재고 부족) + end + Facade->>Product: decreaseStock(quantity) + end + + Facade->>BrandService: getBrands(brandIds) + BrandService-->>Facade: List + + Facade->>Order: create(userId, products, brands, quantities) + Note over Order: 스냅샷 저장
상품명, 가격, 브랜드명 → OrderItem + Note over Order: 총 금액 계산
sum(price × quantity) + + Facade->>OrderRepo: save(order) + + Note over Facade: 트랜잭션 커밋 + + Facade-->>Controller: OrderInfo + Controller-->>Client: 200 OK + 주문 정보 +``` + +### 이 구조에서 봐야 할 포인트 + +1. **검증이 두 단계로 나뉜다.** 요청 형식 검증(중복 상품, 수량 범위)은 Controller에서, 도메인 검증(상품 존재, 재고 충분)은 트랜잭션 안에서 한다. 형식이 잘못된 요청은 트랜잭션을 열기도 전에 걸러낸다. +2. **ProductService.getActiveProducts()가 삭제된 상품을 필터링**한다. 메서드 이름에 "Active"를 넣어서 soft delete 필터링이 적용된다는 걸 명시적으로 드러낸다. 요청한 productId 중 하나라도 조회되지 않으면 404. +3. **BrandService 호출이 재고 차감 이후에 있다.** 재고가 부족하면 브랜드를 조회할 필요 자체가 없으므로, 불필요한 쿼리를 아끼기 위해 이 순서로 배치했다. +4. **트랜잭션 하나에 모든 게 묶인다.** 상품이 10개, 20개여도 현재는 하나의 트랜잭션이다. 상품 수가 극단적으로 많아지면 락 시간이 길어지는 리스크가 있지만, 현재 규모에서는 정합성이 더 중요하다. + +--- + +## 2. 좋아요 등록/취소 흐름 + +### 왜 이 다이어그램이 필요한가 + +좋아요의 핵심은 **멱등성**이다. 같은 요청이 여러 번 와도 결과가 동일해야 하며, 등록과 취소에서 상품 검증 범위가 다르다는 점이 설계 의도를 명확히 드러내야 할 부분이다. + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller as ProductLikeV1Controller + participant Facade as ProductLikeFacade + participant ProductService as ProductService + participant LikeService as ProductLikeService + participant LikeRepo as ProductLikeRepository + + rect rgb(230, 245, 230) + Note right of Client: 좋아요 등록 + Client->>Controller: POST /api/v1/products/{productId}/likes + Controller->>Facade: like(userId, productId) + Facade->>ProductService: getActiveProduct(productId) + alt 상품 없음 or 삭제됨 + ProductService-->>Facade: NOT_FOUND + Facade--xClient: 404 + end + Facade->>LikeService: like(userId, productId) + LikeService->>LikeRepo: findByUserIdAndProductId() + alt 이미 좋아요 상태 + LikeRepo-->>LikeService: 존재함 + LikeService-->>Facade: (무시, 멱등 처리) + else 좋아요 없음 + LikeRepo-->>LikeService: 없음 + LikeService->>LikeRepo: save(new ProductLike) + end + Facade-->>Controller: OK + Controller-->>Client: 200 OK + end + + rect rgb(245, 230, 230) + Note right of Client: 좋아요 취소 + Client->>Controller: DELETE /api/v1/products/{productId}/likes + Controller->>Facade: unlike(userId, productId) + Note over Facade: 상품 존재 검증 생략 + Facade->>LikeService: unlike(userId, productId) + LikeService->>LikeRepo: findByUserIdAndProductId() + alt 좋아요 존재 + LikeRepo-->>LikeService: 존재함 + LikeService->>LikeRepo: delete(productLike) + else 좋아요 없음 + LikeRepo-->>LikeService: 없음 + LikeService-->>Facade: (무시, 멱등 처리) + end + Facade-->>Controller: OK + Controller-->>Client: 200 OK + end +``` + +### 이 구조에서 봐야 할 포인트 + +1. **등록 시에만 상품 존재 여부를 확인하고, 취소 시에는 생략한다.** 이유는 명확하다 — 상품이 삭제된 후에도 유저가 기존 좋아요를 해제할 수 있어야 한다. 삭제된 상품의 좋아요를 취소하려는데 "상품이 없습니다"라고 하면 유저 입장에서 답답하다. +2. **멱등 처리는 LikeService에서 판단한다.** 이미 좋아요가 있으면 등록을 무시하고, 없으면 삭제를 무시한다. 에러를 던지지 않으므로 클라이언트는 현재 상태를 몰라도 안전하게 요청할 수 있다. +3. **좋아요는 물리 삭제(hard delete)를 사용한다.** 좋아요 이력을 보존할 필요가 없고, 토글 성격이므로 soft delete는 과하다. `(user_id, product_id)` 유니크 제약이 있으므로 soft delete를 쓰면 재등록 시 충돌 문제도 생긴다. + +--- + +## 3. 브랜드 삭제 (어드민) 흐름 + +### 왜 이 다이어그램이 필요한가 + +브랜드 삭제는 **cascade soft delete**가 발생하는 유일한 흐름이다. 브랜드 하나를 삭제하면 소속 상품 전체가 함께 soft delete 되므로 영향 범위가 크다. 기존 주문이나 좋아요 데이터에 영향이 없는지를 검증해야 한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Admin + participant Controller as BrandAdminV1Controller + participant Facade as BrandFacade + participant BrandService as BrandService + participant ProductService as ProductService + participant Brand as Brand + participant Product as Product + + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId} + Controller->>Facade: deleteBrand(brandId) + + Note over Facade: 트랜잭션 시작 + + Facade->>BrandService: getActiveBrand(brandId) + alt 브랜드 없음 or 이미 삭제됨 + BrandService-->>Facade: NOT_FOUND + Facade--xAdmin: 404 + end + + Facade->>Brand: delete() + Note over Brand: deletedAt = now() + + Facade->>ProductService: getActiveProductsByBrandId(brandId) + loop 해당 브랜드의 각 활성 상품 + Facade->>Product: delete() + Note over Product: deletedAt = now() + end + + Note over Facade: 트랜잭션 커밋 + + Facade-->>Controller: OK + Controller-->>Admin: 200 OK + + Note over Admin: 기존 주문의 OrderItem 스냅샷은
영향 없음 (이미 복사된 데이터) + Note over Admin: 기존 좋아요 레코드는 유지됨
고객 목록 조회 시 쿼리 레벨에서 필터링 +``` + +### 이 구조에서 봐야 할 포인트 + +1. **브랜드와 소속 상품이 하나의 트랜잭션으로 처리된다.** 상품 삭제 도중 실패하면 브랜드 삭제도 롤백된다. 원자성이 보장된다. +2. **주문 데이터는 안전하다.** OrderItem에 상품명, 가격, 브랜드명이 스냅샷으로 복사되어 있으므로, 원본이 삭제되어도 주문 이력 조회에 문제가 없다. 이게 스냅샷을 도입한 핵심 이유다. +3. **좋아요 레코드 자체는 삭제하지 않는다.** cascade 범위를 좋아요까지 넓히면 트랜잭션이 더 비대해진다. 대신 고객이 좋아요 목록을 조회할 때 `products.deleted_at IS NULL` join으로 삭제된 상품을 걸러낸다. + +--- + +## 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| 주문 생성 트랜잭션이 비대해질 수 있음 | 상품 수가 많으면 재고 차감마다 row lock, 트랜잭션 시간 증가 | 향후 비관적 락 도입 시 락 순서를 productId 순으로 고정하여 데드락 방지 | +| 브랜드 삭제 시 상품이 수천 개면 느릴 수 있음 | 트랜잭션 시간 증가, 타임아웃 가능 | 배치 처리 또는 `UPDATE products SET deleted_at = now() WHERE brand_id = ?` 벌크 쿼리로 전환 | +| 좋아요 COUNT 쿼리가 상품 목록 정렬에 사용됨 | `likes_desc` 정렬 시 매번 서브쿼리 집계 필요 | 트래픽 증가 시 Product에 `like_count` 비정규화 필드 도입 | +| 좋아요 목록에서 삭제된 상품 필터링 | join 조건으로 처리하므로 쿼리 복잡도 약간 증가 | 현재 규모에서는 문제 없음. 데이터 증가 시 인덱스 튜닝으로 대응 | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..18eaefc4b --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,309 @@ +# 03. 클래스 다이어그램 + +## 1. JPA 엔티티 모델 + +### 왜 이 다이어그램이 필요한가 + +DB에 영속화되는 JPA 엔티티들의 **필드, 행위, 관계**를 보여준다. +ERD가 "테이블에 뭐가 들어가는가"라면, 이 다이어그램은 "객체가 어떤 비즈니스 규칙을 캡슐화하는가"를 검증한다. + +특히 봐야 할 건: +- 어떤 엔티티가 BaseEntity를 상속하고, 어떤 엔티티가 상속하지 않는지 — 그 이유 +- 엔티티 간 관계가 JPA 연관관계인지, ID 참조인지 +- 비즈니스 로직이 Service가 아닌 엔티티에 있는 경우 그 근거 + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() void + +restore() void + } + + class Brand { + -String name + -String description + -String imageUrl + +update(name, description, imageUrl) void + } + + class Product { + -Long brandId + -String name + -String description + -int price + -int stock + -String imageUrl + +decreaseStock(quantity) void + +hasEnoughStock(quantity) boolean + +update(name, description, price, stock, imageUrl) void + } + + class ProductLike { + -Long id + -Long userId + -Long productId + -ZonedDateTime createdAt + } + + class Order { + -Long userId + -int totalAmount + -List~OrderItem~ items + +static create(userId, items) Order + -calculateTotalAmount() int + } + + class OrderItem { + -Long productId + -String productName + -int productPrice + -String brandName + -int quantity + +static createSnapshot(product, brand, quantity) OrderItem + +getSubtotal() int + } + + BaseEntity <|-- Brand + BaseEntity <|-- Product + BaseEntity <|-- Order + + Brand "1" --> "*" Product : brandId + Product "1" --> "*" ProductLike : productId + Order "1" *-- "*" OrderItem : items +``` + +### 이 구조에서 봐야 할 포인트 + +1. **BaseEntity를 상속하는 엔티티(Brand, Product, Order)와 상속하지 않는 엔티티(ProductLike, OrderItem)가 나뉜다.** + - ProductLike는 물리 삭제(hard delete)를 사용한다. soft delete를 쓰면 `(user_id, product_id)` 유니크 제약과 충돌하고, updatedAt도 필요 없다. 그래서 BaseEntity를 상속하지 않고 자체적으로 id, createdAt만 관리한다. + - OrderItem은 Order에 종속된 Composition 관계다. Order가 생성될 때 함께 생성되고, 독립적으로 삭제/수정되지 않는다. + +2. **Product와 Brand는 ID 참조 관계다.** `brandId` 필드(Long 타입)로 연결하며, JPA `@ManyToOne`은 사용하지 않는다. 도메인 간 결합도를 낮추기 위해서다. 단, DB 레벨에서는 FK 제약을 걸어 참조 무결성은 보장한다. "JPA 연관관계 없음 ≠ DB FK 없음"이라는 점이 중요하다. + +3. **재고 관련 로직은 Product 엔티티에 있다.** `decreaseStock()`은 재고 부족 시 예외를 던지고, `hasEnoughStock()`은 충분 여부를 반환한다. 이 로직을 Service에 두면 "재고 검증 없이 차감"하는 실수가 가능해진다. 엔티티 자체가 자기 불변 조건을 지키게 하는 게 안전하다. + +--- + +## 2. 서비스 / 애플리케이션 레이어 + +### 왜 이 다이어그램이 필요한가 + +엔티티가 "무엇을 저장하고, 어떤 규칙을 갖는가"를 정의한다면, Service/Facade는 "누가 엔티티를 조회/조합하고, 트랜잭션을 어떻게 관리하는가"를 정의한다. 이 두 관심사가 섞이면 코드가 금방 복잡해진다. + +특히 봐야 할 건: +- 어떤 Controller가 Service를 직접 호출하고, 어떤 Controller가 Facade를 경유하는지 +- Facade가 존재하는 이유 (여러 도메인 조합이 필요한 경우) +- Service 간 직접 의존이 없는지 + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + namespace interfaces { + class BrandV1Controller { + +getBrand(brandId) ApiResponse + } + class BrandAdminV1Controller { + +listBrands(page, size, deleted) ApiResponse + +getBrand(brandId) ApiResponse + +createBrand(request) ApiResponse + +updateBrand(brandId, request) ApiResponse + +deleteBrand(brandId) ApiResponse + } + class ProductV1Controller { + +listProducts(brandId, sort, page, size) ApiResponse + +getProduct(productId) ApiResponse + } + class ProductAdminV1Controller { + +listProducts(page, size, brandId, deleted) ApiResponse + +getProduct(productId) ApiResponse + +createProduct(request) ApiResponse + +updateProduct(productId, request) ApiResponse + +deleteProduct(productId) ApiResponse + } + class ProductLikeV1Controller { + +like(userId, productId) ApiResponse + +unlike(userId, productId) ApiResponse + +getMyLikes(userId) ApiResponse + } + class OrderV1Controller { + +createOrder(userId, request) ApiResponse + +listOrders(userId, startAt, endAt, page, size) ApiResponse + +getOrder(userId, orderId) ApiResponse + } + class OrderAdminV1Controller { + +listOrders(page, size) ApiResponse + +getOrder(orderId) ApiResponse + } + } + + namespace application { + class BrandFacade { + +createBrand(command) BrandInfo + +updateBrand(command) BrandInfo + +deleteBrand(brandId) void + } + class ProductFacade { + +createProduct(command) ProductInfo + +updateProduct(command) ProductInfo + +deleteProduct(productId) void + } + class OrderFacade { + +createOrder(userId, command) OrderInfo + } + class ProductLikeFacade { + +like(userId, productId) void + +unlike(userId, productId) void + } + } + + namespace domain { + class BrandService { + +register(name, description, imageUrl) Brand + +getActiveBrand(brandId) Brand + +getBrands(brandIds) List~Brand~ + } + class ProductService { + +register(brandId, name, desc, price, stock, imageUrl) Product + +getActiveProduct(productId) Product + +getActiveProducts(productIds) List~Product~ + +getActiveProductsByBrandId(brandId) List~Product~ + } + class ProductLikeService { + +like(userId, productId) void + +unlike(userId, productId) void + +getLikedProducts(userId) List~ProductLike~ + } + class OrderService { + +createOrder(userId, totalAmount, items) Order + +getOrder(orderId) Order + +getOrdersByUserId(userId, startAt, endAt, page, size) Page~Order~ + } + } + + %% 단순 조회: Controller → Service 직접 + BrandV1Controller --> BrandService + ProductV1Controller --> ProductService + OrderAdminV1Controller --> OrderService + + %% 도메인 조합이 필요한 경우: Controller → Facade + BrandAdminV1Controller --> BrandFacade + ProductAdminV1Controller --> ProductFacade + ProductLikeV1Controller --> ProductLikeFacade + OrderV1Controller --> OrderFacade + + %% Facade → Service 의존 + BrandFacade --> BrandService + BrandFacade --> ProductService + ProductFacade --> ProductService + ProductFacade --> BrandService + OrderFacade --> ProductService + OrderFacade --> OrderService + OrderFacade --> BrandService + ProductLikeFacade --> ProductService + ProductLikeFacade --> ProductLikeService +``` + +### 이 구조에서 봐야 할 포인트 + +1. **단순 조회는 Controller → Service 직접 호출한다.** Facade를 거칠 이유가 없는 경우에 불필요한 레이어를 추가하지 않는다. + - `BrandV1Controller → BrandService`: 고객 브랜드 조회 + - `ProductV1Controller → ProductService`: 고객 상품 목록/상세 조회 + - `OrderAdminV1Controller → OrderService`: 어드민 주문 조회 + +2. **여러 도메인을 조합해야 하면 반드시 Facade를 경유한다.** Facade가 트랜잭션 경계이자 도메인 간 조율자 역할을 한다. + - `OrderFacade`: ProductService(상품 조회 + 재고 차감) + BrandService(브랜드명 조회) + OrderService(주문 저장) + - `BrandFacade`: BrandService(브랜드 삭제) + ProductService(소속 상품 일괄 삭제) + - `ProductFacade`: ProductService(상품 등록/수정) + BrandService(브랜드 존재 여부 검증) + - `ProductLikeFacade`: ProductService(상품 존재 확인) + ProductLikeService(좋아요 처리) + +3. **Service 간에는 직접 의존하지 않는다.** 처음에 ProductService에서 BrandService를 직접 호출하는 방식도 고민했는데, 그러면 Service 간 순환 의존이나 책임 경계 모호함이 생긴다. 여러 Service를 조합해야 하면 항상 Facade에서 한다. + +4. **Service 메서드 이름에 soft delete 필터링 여부를 드러낸다.** `getActiveProduct()`는 삭제된 상품을 제외하고 조회한다. `getProduct()`처럼 모호한 이름 대신, 의도를 명시해서 필터링 누락 실수를 방지한다. + +--- + +## 3. 엔티티 상세 설계 + +### Brand + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| name | String | not null, max 100 | 브랜드명. DB UNIQUE 없음, Application 레벨에서 활성 브랜드 중 중복 검증. | +| description | String | nullable, max 500 | 브랜드 설명 | +| imageUrl | String | nullable, max 500 | 브랜드 이미지 URL | + +### Product + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| brandId | Long | not null | 소속 브랜드 ID. JPA 연관관계 없음, DB FK 있음. | +| name | String | not null, max 200 | 상품명 | +| description | String | nullable, max 1000 | 상품 설명 | +| price | int | not null, >= 0 | 판매 가격 | +| stock | int | not null, >= 0 | 재고 수량 | +| imageUrl | String | nullable, max 500 | 상품 이미지 URL | + +**도메인 메서드:** +- `decreaseStock(int quantity)`: 재고 차감. `stock < quantity`이면 `CoreException(BAD_REQUEST)` 발생. +- `hasEnoughStock(int quantity)`: 재고 충분 여부 boolean 반환. +- `update(...)`: brandId를 제외한 필드 수정. 브랜드 변경 불가 정책을 엔티티 레벨에서 강제. + +### ProductLike + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | Long | PK, auto increment | - | +| userId | Long | not null | 좋아요한 유저 ID | +| productId | Long | not null | 좋아요 대상 상품 ID | +| createdAt | ZonedDateTime | not null | 좋아요 시점 | + +**BaseEntity를 상속하지 않는다.** soft delete 불필요(물리 삭제), updatedAt 불필요. 자체 id/createdAt만 관리. +**제약:** `(userId, productId)` 유니크 제약. 멱등성의 DB 레벨 안전장치. + +### Order + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| userId | Long | not null | 주문자 ID | +| totalAmount | int | not null, >= 0 | 총 주문 금액 | + +**도메인 메서드:** +- `static create(userId, List)`: 팩토리 메서드. OrderItem 목록을 받아 총 금액을 자동 계산한다. + +### OrderItem + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| productId | Long | not null | 원본 상품 ID (참조용, FK 아님) | +| productName | String | not null | 스냅샷: 주문 시점 상품명 | +| productPrice | int | not null | 스냅샷: 주문 시점 단가 | +| brandName | String | not null | 스냅샷: 주문 시점 브랜드명 | +| quantity | int | not null, 1~99 | 주문 수량 | + +**도메인 메서드:** +- `static createSnapshot(product, brand, quantity)`: Product와 Brand에서 필요한 정보만 복사하여 스냅샷을 생성한다. +- `getSubtotal()`: `productPrice * quantity` 반환. + +**Order와 Composition 관계.** `@OneToMany`로 관리되며 독립 생명주기 없음. +**productId에 FK를 걸지 않는 이유:** 원본 상품이 삭제(soft delete)되어도 주문 항목은 보존되어야 한다. FK가 있으면 참조 대상이 "삭제됨"인 상태에서 정합성 문제가 복잡해진다. 조회 시에는 스냅샷 데이터만 사용하므로 원본을 참조할 일이 없다. + +--- + +## 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|--------|------|--------| +| Product-Brand ID 참조 시 정합성 | JPA 연관관계 없이 brandId만 저장하면 코드 레벨에서 존재하지 않는 브랜드 참조 가능 | DB FK 제약으로 기본 보장 + ProductFacade에서 Application 레벨 추가 검증 | +| OrderItem 증가에 따른 Order 조회 성능 | 주문 항목이 많아지면 Order 조회 시 N+1 가능 | fetch join 또는 별도 조회 쿼리 | +| 좋아요 수 실시간 집계 | `likes_desc` 정렬마다 COUNT 서브쿼리 발생 | 현재는 COUNT로 충분. 트래픽 증가 시 Product에 `likeCount` 비정규화 필드 추가 | +| getActiveXxx() 네이밍 규칙 강제 어려움 | 실수로 deleted 포함 조회 메서드를 호출할 가능성 | Repository 레벨에서 기본 조회 시 deleted_at IS NULL 조건 강제 (Custom Repository 또는 `@Where`) | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..c650ca2ef --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,247 @@ +# 04. ERD (Entity-Relationship Diagram) + +## 1. 전체 ERD + +### 왜 이 다이어그램이 필요한가 + +클래스 다이어그램에서 "객체가 어떤 규칙을 갖는가"를 정의했다면, ERD는 "실제 DB에 어떤 테이블이 생기고, 어떤 컬럼과 제약이 걸리는가"를 정의한다. + +이 두 개가 따로 있는 이유가 있다. JPA 엔티티 모델과 DB 스키마가 1:1로 매칭되지 않는 부분이 있기 때문이다. 예를 들어 클래스 다이어그램에서 Product는 `brandId`를 Long으로 들고 있지만, DB에서는 `brand_id`에 FK 제약이 걸린다. "JPA 연관관계 없음 ≠ DB FK 없음"이라는 원칙이 ERD에서 비로소 구체화된다. + +특히 봐야 할 건: +- FK가 걸리는 곳과 걸리지 않는 곳의 차이 — 그 이유 +- 스냅샷 데이터가 원본 테이블과 완전히 분리되어 있는지 +- soft delete 컬럼(`deleted_at`)이 있는 테이블과 없는 테이블의 구분 + +### 다이어그램 + +```mermaid +erDiagram + users ||--o{ product_likes : "좋아요" + users ||--o{ orders : "주문" + brands ||--o{ products : "소속 상품" + products ||--o{ product_likes : "좋아요 대상" + orders ||--o{ order_items : "주문 항목" + + users { + bigint id PK "AUTO_INCREMENT" + varchar(50) login_id UK "NOT NULL" + varchar(255) password "NOT NULL" + varchar(100) name "NOT NULL" + date birth_date "NOT NULL" + varchar(255) email "NOT NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar(100) name "NOT NULL" + varchar(500) description "NULL" + varchar(500) image_url "NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL → brands.id" + varchar(200) name "NOT NULL" + varchar(1000) description "NULL" + int price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar(500) image_url "NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL → users.id" + bigint product_id FK "NOT NULL → products.id" + datetime created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL → users.id" + int total_amount "NOT NULL, >= 0" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL → orders.id" + bigint product_id "NOT NULL (참조용, FK 아님)" + varchar(200) product_name "NOT NULL (스냅샷)" + int product_price "NOT NULL (스냅샷)" + varchar(100) brand_name "NOT NULL (스냅샷)" + int quantity "NOT NULL, >= 1" + datetime created_at "NOT NULL" + } +``` + +### 이 구조에서 봐야 할 포인트 + +1. **order_items.product_id에는 FK를 걸지 않았다.** 처음에는 FK를 거는 게 정합성에 좋을 것 같았는데, 문제가 있다. 원본 상품이 soft delete 되면 `deleted_at`에 값이 들어가는 거지 row가 사라지는 건 아니니까 FK 자체는 깨지지 않는다. 하지만 나중에 물리 삭제 정책으로 바뀌거나, hard delete로 데이터를 정리해야 하는 상황이 오면 FK가 걸린 order_items 때문에 삭제가 막힌다. 애초에 주문 항목에서 원본 상품을 참조할 일이 없다 — 스냅샷 데이터(product_name, product_price, brand_name)만 쓰니까. 그래서 FK 없이 product_id를 참조용으로만 저장한다. + +2. **brands.name에 UNIQUE 제약이 없다.** 처음에 당연히 UNIQUE를 걸어야 한다고 생각했는데, soft delete와 충돌이 생긴다. "Nike"를 soft delete한 뒤 새로운 "Nike"를 등록하면 DB 레벨에서 UNIQUE 위반이 난다. `UNIQUE(name, deleted_at)` 복합 유니크도 고민했지만 MySQL에서 NULL이 포함된 유니크 인덱스 동작이 직관적이지 않다. 결국 DB UNIQUE는 포기하고, **Application 레벨에서 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름을 검사**하는 방식으로 갔다. 대신 `idx_brands_name` 일반 인덱스는 걸어서 이름 검색 성능은 확보했다. + +3. **soft delete가 있는 테이블과 없는 테이블이 나뉜다.** `deleted_at` 컬럼이 있는 건 brands, products, orders — 이력 보존이 필요한 엔티티들이다. product_likes에는 없다. 좋아요는 토글 성격이라 이력을 남길 이유가 없고, 물리 삭제(hard delete)를 쓴다. order_items에도 없다. Order에 종속된 Composition이라 독립적으로 삭제될 일이 없다. + +4. **product_likes에 `(user_id, product_id)` 유니크 제약이 걸린다.** 같은 유저가 같은 상품에 두 번 좋아요를 남기면 안 되므로, Application에서 체크하는 것과 별개로 DB 레벨 안전장치를 둔다. 멱등 처리를 Application에서만 하면, 동시 요청이 들어왔을 때 둘 다 "없음"으로 판단하고 둘 다 INSERT하는 race condition이 가능하다. UNIQUE 제약이 이걸 막아준다. + +--- + +## 2. 인덱스 설계 + +인덱스는 "어떤 쿼리가 자주 실행될 것인가"를 기준으로 설계했다. API 스펙에서 역산하면 어떤 WHERE/ORDER BY 조건이 나오는지 알 수 있다. + +| 테이블 | 인덱스 | 컬럼 | 왜 필요한가 | +|--------|--------|------|-------------| +| `brands` | `idx_brands_name` | `name` | 브랜드 등록 시 이름 중복 검사. UNIQUE 대신 일반 인덱스 — soft delete 때문. | +| `products` | `idx_products_brand_id` | `brand_id` | 브랜드별 상품 목록 필터링 (`?brandId=...`). FK에 자동 인덱스가 생기는 경우도 있지만 명시적으로 건다. | +| `products` | `idx_products_created_at` | `created_at DESC` | `sort=latest` 정렬. 최신순이 기본 정렬이라 가장 빈번하게 사용된다. | +| `products` | `idx_products_price` | `price ASC` | `sort=price_asc` 정렬. | +| `product_likes` | `uk_product_likes_user_product` | `user_id, product_id` (UNIQUE) | 중복 좋아요 방지 + `findByUserIdAndProductId()` 조회 성능. 유니크 제약이 곧 인덱스 역할. | +| `product_likes` | `idx_product_likes_product_id` | `product_id` | 상품별 좋아요 수 집계. `sort=likes_desc` 정렬 시 COUNT 서브쿼리에서 사용. | +| `product_likes` | `idx_product_likes_user_id` | `user_id` | 유저별 좋아요 목록 조회 (`/api/v1/users/{userId}/likes`). | +| `orders` | `idx_orders_user_id_created_at` | `user_id, created_at` | 유저별 기간 주문 조회. `user_id`로 필터링 후 `created_at` 범위 조건. 복합 인덱스 순서가 중요하다 — 등호 조건(`user_id =`)이 앞, 범위 조건(`created_at BETWEEN`)이 뒤에 와야 인덱스를 제대로 탄다. | +| `order_items` | `idx_order_items_order_id` | `order_id` | 주문 상세 조회 시 해당 주문의 항목들을 가져온다. FK에 의한 자동 인덱스가 생길 수 있지만 명시. | + +`sort=likes_desc` 정렬은 현재 COUNT 서브쿼리로 처리한다. `idx_product_likes_product_id` 인덱스가 있으면 product_id 기준 COUNT가 인덱스 스캔으로 가능하지만, 결국 모든 상품에 대해 COUNT를 해야 하므로 데이터가 많아지면 느려질 수 있다. 트래픽이 증가하면 Product에 `like_count` 비정규화 컬럼을 추가하는 게 맞다. + +--- + +## 3. 테이블별 DDL + +### brands + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + image_url VARCHAR(500), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + INDEX idx_brands_name (name) +); +``` + +`name`에 UNIQUE가 아닌 일반 INDEX를 건 이유는 위에서 설명했다. soft delete 환경에서 DB UNIQUE는 재등록 시 충돌이 생긴다. + +### products + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(1000), + price INT NOT NULL, + stock INT NOT NULL, + image_url VARCHAR(500), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) REFERENCES brands (id), + INDEX idx_products_brand_id (brand_id), + INDEX idx_products_created_at (created_at DESC) +); +``` + +`brand_id`에 FK를 건다. JPA에서 `@ManyToOne`을 안 쓴다고 DB FK도 안 거는 건 아니다. JPA 연관관계는 객체 그래프 탐색 편의를 위한 것이고, DB FK는 참조 무결성을 위한 것이다. 존재하지 않는 brand_id가 들어오는 걸 DB 레벨에서 막아야 한다. + +`price`와 `stock`에 `CHECK (price >= 0)`, `CHECK (stock >= 0)` 제약을 걸 수도 있었는데, 현재는 Application 레벨 검증으로 처리한다. 향후 동시성 이슈가 실제로 발생하면 DB CHECK 제약을 추가하는 게 이중 안전장치가 된다. + +### product_likes + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) REFERENCES products (id), + UNIQUE KEY uk_product_likes_user_product (user_id, product_id), + INDEX idx_product_likes_product_id (product_id), + INDEX idx_product_likes_user_id (user_id) +); +``` + +`updated_at`과 `deleted_at`이 없다. 좋아요는 있거나 없거나 둘 중 하나다. 수정할 일이 없으니 `updated_at`이 필요 없고, 물리 삭제를 쓰니 `deleted_at`도 필요 없다. + +`user_id`와 `product_id` 각각에 단독 인덱스를 건 이유: 유니크 인덱스 `(user_id, product_id)`는 `user_id`가 선행 컬럼이라 `user_id` 단독 조회에는 활용 가능하다. 하지만 `product_id` 단독 조회(좋아요 수 집계)에는 이 유니크 인덱스를 쓸 수 없다. 그래서 `idx_product_likes_product_id`를 별도로 건다. + +### orders + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_amount INT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users (id), + INDEX idx_orders_user_id_created_at (user_id, created_at) +); +``` + +`status` 컬럼이 없다. 현재는 선결 조건(상품 존재, 재고 충분) 통과 = 주문 완료이므로, 상태를 추적할 필요가 없다. 결제가 도입되면 `ALTER TABLE ADD COLUMN status` 마이그레이션이 필요하겠지만, 지금 쓰지 않는 필드를 미리 넣는 건 과설계다. + +### order_items + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_price INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + quantity INT NOT NULL, + created_at DATETIME(6) NOT NULL, + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) REFERENCES orders (id), + INDEX idx_order_items_order_id (order_id) +); +``` + +`product_id`에 FK를 걸지 않는다. 스냅샷 철학과 일관된 결정이다. 이 테이블의 `product_name`, `product_price`, `brand_name`은 주문 시점에 복사된 데이터이고, 원본이 바뀌거나 삭제되어도 여기 저장된 값은 독립적이다. `product_id`는 "어떤 상품이었는지" 참조할 수 있게 남겨두는 것이지, 원본 데이터를 다시 조회하겠다는 뜻이 아니다. + +`updated_at`과 `deleted_at`이 없다. Order에 종속된 Composition 관계여서 독립적으로 수정/삭제되지 않는다. + +--- + +## 4. 데이터 정합성 전략 + +각 항목에 대해 "왜 이 전략을 선택했는가"를 함께 정리한다. + +| 항목 | 전략 | 왜 이렇게 했는가 | +|------|------|-------------------| +| 좋아요 중복 방지 | `(user_id, product_id)` UNIQUE 제약 | Application 멱등 처리만으로는 동시 요청 시 race condition 가능. DB 레벨 UNIQUE가 최종 안전장치. | +| 재고 음수 방지 | Application 레벨 검증 | `Product.decreaseStock()`에서 재고 부족 시 예외 발생. 현재는 이걸로 충분하지만, 동시 주문이 실제로 문제가 되면 비관적 락 또는 DB `CHECK (stock >= 0)` 추가. | +| 주문-상품 정합성 | 스냅샷 분리 | order_items에 상품 정보를 복사. 원본이 변경/삭제되어도 주문 이력에 영향 없음. FK 없이 product_id만 참조용 저장. | +| 브랜드명 중복 방지 | Application 레벨 검증 | 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름 검사. DB UNIQUE는 soft delete와 충돌하므로 사용 안 함. | +| 브랜드-상품 종속성 | Application Cascade Soft Delete | 브랜드 삭제 시 소속 상품 일괄 soft delete. DB CASCADE DELETE는 물리 삭제이므로 적합하지 않음. | +| Soft Delete 필터링 | `WHERE deleted_at IS NULL` | 고객 API 조회 시 반드시 이 조건을 포함. Service 메서드를 `getActiveXxx()`로 네이밍하여 필터링 적용을 명시적으로 드러낸다. | + +--- + +## 잠재 리스크 + +| 리스크 | 왜 문제가 되는가 | 현재 대응 / 향후 선택지 | +|--------|------------------|------------------------| +| soft delete 필터링 누락 | 삭제된 상품/브랜드가 고객에게 노출된다. `getProduct()`처럼 모호한 메서드를 만들면 실수할 확률이 높다. | 현재: Service 메서드를 `getActiveXxx()`로 네이밍하여 의도를 명시. 향후: `@Where(clause = "deleted_at IS NULL")` 어노테이션으로 강제하는 것도 가능하지만, 어드민 API에서 삭제된 데이터를 조회해야 하는 경우와 충돌할 수 있어서 신중하게 적용해야 한다. | +| 동시 주문 시 재고 초과 차감 | 두 유저가 동시에 마지막 재고 1개를 주문하면, 둘 다 `hasEnoughStock()`을 통과하고 둘 다 `decreaseStock()` 하는 시나리오가 가능하다. 재고가 음수로 내려간다. | 현재: Application 레벨 검증으로 시작. 향후: A) 비관적 락(`SELECT FOR UPDATE`) — 확실하지만 성능 저하. B) 낙관적 락(`@Version`) — 충돌 시 재시도. C) DB 레벨 `CHECK (stock >= 0)` — 음수 진입 자체를 차단. | +| 좋아요 수 집계 성능 | `sort=likes_desc` 정렬마다 모든 상품에 대해 COUNT 서브쿼리를 날린다. 상품과 좋아요 데이터가 많아지면 느려진다. | 현재: `idx_product_likes_product_id` 인덱스로 COUNT 성능 확보. 향후: Product에 `like_count` 비정규화 컬럼 추가. 대신 좋아요 등록/취소 시 카운트 동기화 로직 필요. | +| order_items에 FK 없는 product_id | 이론상 존재하지 않는 product_id가 저장될 수 있다. | Application 레벨에서 주문 생성 시 `getActiveProducts()`로 검증하므로 실질적 문제 없음. 조회 시에는 스냅샷 데이터만 사용하므로 원본 참조 불필요. | +| 브랜드명 Application 레벨 중복 검사 | DB UNIQUE가 없으므로, 동시에 같은 이름의 브랜드가 등록되면 둘 다 통과할 수 있다. | 어드민만 브랜드를 등록하므로 동시 등록 가능성이 매우 낮다. 문제가 되면 `SELECT FOR UPDATE`로 직렬화하거나, 부분 유니크 인덱스(`WHERE deleted_at IS NULL`)를 지원하는 DB로 전환. |