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 0000000..4a82983 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + private final ProductService productService; + + public ProductInfo createProduct(String name, String description, BigDecimal price, Integer stock) { + ProductModel product = productService.createProduct(name, description, price, stock); + return ProductInfo.from(product); + } + + public ProductInfo getProduct(Long id) { + ProductModel product = productService.getProduct(id); + return ProductInfo.from(product); + } + + public Page getProducts(Pageable pageable) { + Page products = productService.getProducts(pageable); + return products.map(ProductInfo::from); + } + + public ProductInfo updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) { + ProductModel product = productService.updateProduct(id, name, description, price, stock); + return ProductInfo.from(product); + } + + public void deleteProduct(Long id) { + productService.deleteProduct(id); + } +} 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 0000000..bb20224 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public record ProductInfo(Long id, String name, String description, BigDecimal price, Integer stock, ZonedDateTime createdAt, ZonedDateTime updatedAt) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getName(), + model.getDescription(), + model.getPrice(), + model.getStock(), + model.getCreatedAt(), + model.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 0000000..4d9dca1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,82 @@ +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.Entity; +import jakarta.persistence.Table; +import java.math.BigDecimal; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + private String name; + private String description; + private BigDecimal price; + private Integer stock; + + protected ProductModel() {} + + public ProductModel(String name, String description, BigDecimal price, Integer stock) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public BigDecimal getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public void update(String name, String description, BigDecimal price, Integer stock) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + } +} 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 0000000..79357d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + Optional find(Long id); + List findAll(); + Page findAll(Pageable pageable); + ProductModel save(ProductModel product); + void delete(ProductModel product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 0000000..953a535 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,48 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductModel createProduct(String name, String description, BigDecimal price, Integer stock) { + ProductModel product = new ProductModel(name, description, price, stock); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductModel getProduct(Long id) { + return productRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + return productRepository.findAll(pageable); + } + + @Transactional + public ProductModel updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) { + ProductModel product = getProduct(id); + product.update(name, description, price, stock); + return product; + } + + @Transactional + public void deleteProduct(Long id) { + ProductModel product = getProduct(id); + productRepository.delete(product); + } +} 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 0000000..a8e661f --- /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.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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 0000000..d2ad4fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional find(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAll() { + return productJpaRepository.findAll().stream() + .filter(product -> product.getDeletedAt() == null) + .collect(Collectors.toList()); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public ProductModel save(ProductModel productModel) { + return productJpaRepository.save(productModel); + } + + @Override + public void delete(ProductModel productModel) { + productModel.delete(); + productJpaRepository.save(productModel); + } +} 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 0000000..a31a03b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,57 @@ +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.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 관리 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 생성", + description = "새로운 상품을 생성합니다." + ) + ApiResponse createProduct( + ProductV1Dto.CreateRequest request + ); + + @Operation( + summary = "상품 조회", + description = "ID로 상품을 조회합니다." + ) + ApiResponse getProduct( + @Schema(description = "상품 ID") + Long productId + ); + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이징하여 조회합니다." + ) + ApiResponse getProducts( + @Schema(description = "페이지 번호") + int page, + @Schema(description = "페이지 크기") + int size + ); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse updateProduct( + @Schema(description = "상품 ID") + Long productId, + ProductV1Dto.UpdateRequest request + ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다." + ) + ApiResponse deleteProduct( + @Schema(description = "상품 ID") + Long productId + ); +} 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 0000000..55b3c65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,81 @@ +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse createProduct( + @Valid @RequestBody ProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.name(), + request.description(), + request.price(), + request.stock() + ); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct( + @PathVariable Long productId + ) { + ProductInfo info = productFacade.getProduct(productId); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageRequest pageable = PageRequest.of(page, size); + Page productPage = productFacade.getProducts(pageable); + ProductV1Dto.ProductListResponse response = ProductV1Dto.ProductListResponse.from(productPage); + return ApiResponse.success(response); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductV1Dto.UpdateRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, + request.name(), + request.description(), + request.price(), + request.stock() + ); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteProduct( + @PathVariable Long productId + ) { + productFacade.deleteProduct(productId); + return ApiResponse.success(); + } +} 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 0000000..25d629e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,70 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +public class ProductV1Dto { + public record CreateRequest( + @NotBlank String name, + String description, + @NotNull @DecimalMin("0") BigDecimal price, + @NotNull @Min(0) Integer stock + ) {} + + public record UpdateRequest( + @NotBlank String name, + String description, + @NotNull @DecimalMin("0") BigDecimal price, + @NotNull @Min(0) Integer stock + ) {} + + public record ProductResponse( + Long id, + String name, + String description, + BigDecimal price, + Integer stock, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.description(), + info.price(), + info.stock(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record ProductListResponse( + List products, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page page) { + return new ProductListResponse( + page.getContent().stream() + .map(ProductResponse::from) + .toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +}