Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
540 changes: 540 additions & 0 deletions docs/payment-order-api-spec.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public record ProductCreateRequest(
@Schema(description = "판매 활성 여부. false이면 사용자 상품 목록과 구매 대상에서 제외됩니다.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
boolean isActive,

@Schema(description = "번들 상품 여부. true이면 번들 상품 필터에 포함됩니다.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
boolean isBundle,

@Schema(description = "실제 지급 구성품 목록. 구매 승인 시 이 수량이 그대로 지급됩니다.", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "구성품은 최소 1개 이상이어야 합니다.")
List<@Valid ProductRewardCreateRequest> rewards,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public record ProductResponse(
@Schema(description = "판매 활성 여부. 사용자 상품 목록은 true 상품만 반환합니다.", example = "true")
boolean isActive,

@Schema(description = "번들 상품 여부. isBundle 필터 조회에 사용됩니다.", example = "true")
boolean isBundle,

@Schema(description = "실제 지급 구성품 목록. 구매 승인 시 이 수량이 지급됩니다.")
List<ProductRewardDto> rewards,

Expand All @@ -43,6 +46,7 @@ public static ProductResponse from(Product product) {
product.getPrice(),
product.getDisplayOrder(),
product.isActive(),
product.isBundle(),
product.getRewards().stream()
.map(ProductRewardDto::from)
.toList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,23 @@ public class Product {
@Column(nullable = false)
private boolean isActive;

@Column(nullable = false)
private boolean isBundle;

@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductReward> rewards = new ArrayList<>();

@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductBonusReward> bonusRewards = new ArrayList<>();

@Builder
public Product(String name, String description, int price, int displayOrder, boolean isActive) {
public Product(String name, String description, int price, int displayOrder, boolean isActive, boolean isBundle) {
this.name = name;
this.description = description;
this.price = price;
this.displayOrder = displayOrder;
this.isActive = isActive;
this.isBundle = isBundle;
}

// 연관관계 편의 메서드 (상품 생성 시 구성품을 쉽게 추가하기 위함)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
FROM Product p
LEFT JOIN FETCH p.rewards
WHERE p.isActive = true
AND (:isBundle IS NULL OR p.isBundle = :isBundle)
ORDER BY p.displayOrder ASC, p.id ASC
""")
List<Product> findActiveProductsWithRewards();
List<Product> findActiveProductsWithRewards(@Param("isBundle") Boolean isBundle);

@Query("""
SELECT DISTINCT p
FROM Product p
LEFT JOIN FETCH p.rewards
WHERE (:isBundle IS NULL OR p.isBundle = :isBundle)
ORDER BY p.displayOrder ASC, p.id ASC
""")
List<Product> findAllProductsWithRewards();
List<Product> findAllProductsWithRewards(@Param("isBundle") Boolean isBundle);

@Query("""
SELECT DISTINCT p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface AdminProductService {

ProductResponse createProduct(ProductCreateRequest request);

List<ProductResponse> getProducts();
List<ProductResponse> getProducts(Boolean isBundle);

void deleteProduct(Long productId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public ProductResponse createProduct(ProductCreateRequest request) {
.price(request.price())
.displayOrder(request.displayOrder())
.isActive(request.isActive())
.isBundle(request.isBundle())
.build();

request.rewards().forEach(rewardRequest -> {
Expand Down Expand Up @@ -64,8 +65,8 @@ public ProductResponse createProduct(ProductCreateRequest request) {

@Override
@Transactional(readOnly = true)
public List<ProductResponse> getProducts() {
List<Product> products = productRepository.findAllProductsWithRewards();
public List<ProductResponse> getProducts(Boolean isBundle) {
List<Product> products = productRepository.findAllProductsWithRewards(isBundle);
fetchBonusRewards(products);
return products.stream()
.map(ProductResponse::from)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

public interface ShopService {

List<ProductResponse> getActiveProducts();
List<ProductResponse> getActiveProducts(Boolean isBundle);

void requestPurchase(Long memberId, Long productId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ public class ShopServiceImpl implements ShopService {

@Override
@Transactional(readOnly = true)
public List<ProductResponse> getActiveProducts() {
List<Product> products = productRepository.findActiveProductsWithRewards();
public List<ProductResponse> getActiveProducts(Boolean isBundle) {
List<Product> products = productRepository.findActiveProductsWithRewards(isBundle);
fetchBonusRewards(products);
return products.stream()
.map(ProductResponse::from)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public void run(String... args) throws Exception {
.price(1000)
.displayOrder(1)
.isActive(true)
.isBundle(false)
.build();
p1.addReward(ProductReward.builder()
.itemType(ItemType.MATCHING_TICKET)
Expand All @@ -52,6 +53,7 @@ public void run(String... args) throws Exception {
.price(5000)
.displayOrder(2)
.isActive(true)
.isBundle(true)
.build();
p2.addReward(ProductReward.builder()
.itemType(ItemType.MATCHING_TICKET)
Expand All @@ -73,6 +75,7 @@ public void run(String... args) throws Exception {
.price(9000)
.displayOrder(3)
.isActive(true)
.isBundle(true)
.build();
p3.addReward(ProductReward.builder()
.itemType(ItemType.MATCHING_TICKET)
Expand All @@ -86,6 +89,7 @@ public void run(String... args) throws Exception {
.price(300)
.displayOrder(4)
.isActive(true)
.isBundle(false)
.build();
p4.addReward(ProductReward.builder()
.itemType(ItemType.OPTION_TICKET)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.comatching.common.annotation.CurrentMember;
Expand Down Expand Up @@ -36,7 +37,7 @@ public class AdminProductController {
@RequireRole(MemberRole.ROLE_ADMIN)
@Operation(
summary = "상품 등록",
description = "상품명, 50자 이하 설명, 가격, 노출 순서, 활성 여부, 실제 지급 구성품, 프론트 표시용 보너스 구성품을 입력받아 신규 상품을 등록합니다. 실제 지급은 rewards 기준이며 bonusRewards는 표시용입니다."
description = "상품명, 50자 이하 설명, 가격, 노출 순서, 활성 여부, 번들 여부, 실제 지급 구성품, 프론트 표시용 보너스 구성품을 입력받아 신규 상품을 등록합니다. 실제 지급은 rewards 기준이며 bonusRewards는 표시용입니다."
)
@PostMapping("/products")
public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
Expand All @@ -50,13 +51,14 @@ public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
@RequireRole(MemberRole.ROLE_ADMIN)
@Operation(
summary = "관리자 상품 목록 조회",
description = "활성/비활성 전체 상품 목록을 displayOrder 오름차순, id 오름차순으로 조회합니다."
description = "활성/비활성 전체 상품 목록을 displayOrder 오름차순, id 오름차순으로 조회합니다. isBundle query parameter로 번들/비번들 상품을 필터링할 수 있습니다."
)
@GetMapping("/products")
public ResponseEntity<ApiResponse<List<ProductResponse>>> getProducts(
@CurrentMember MemberInfo memberInfo
@CurrentMember MemberInfo memberInfo,
@RequestParam(required = false) Boolean isBundle
) {
return ResponseEntity.ok(ApiResponse.ok(adminProductService.getProducts()));
return ResponseEntity.ok(ApiResponse.ok(adminProductService.getProducts(isBundle)));
}

@RequireRole(MemberRole.ROLE_ADMIN)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.comatching.common.annotation.CurrentMember;
Expand All @@ -30,11 +31,13 @@ public class ShopController {

@Operation(
summary = "상품 목록 조회",
description = "현재 판매 중인 활성 상품만 displayOrder 오름차순, id 오름차순으로 조회합니다. 응답에는 실제 지급 rewards와 프론트 표시용 bonusRewards가 함께 포함됩니다."
description = "현재 판매 중인 활성 상품만 displayOrder 오름차순, id 오름차순으로 조회합니다. isBundle query parameter로 번들/비번들 상품을 필터링할 수 있습니다. 응답에는 실제 지급 rewards와 프론트 표시용 bonusRewards가 함께 포함됩니다."
)
@GetMapping("/products")
public ResponseEntity<ApiResponse<List<ProductResponse>>> getActiveProducts() {
return ResponseEntity.ok(ApiResponse.ok(shopService.getActiveProducts()));
public ResponseEntity<ApiResponse<List<ProductResponse>>> getActiveProducts(
@RequestParam(required = false) Boolean isBundle
) {
return ResponseEntity.ok(ApiResponse.ok(shopService.getActiveProducts(isBundle)));
}

@Operation(summary = "아이템 구매 요청", description = "상품 ID 기반으로 구매 요청(입금 대기)을 생성합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ void shouldAllowNullBonusRewards() {
1000,
1,
true,
true,
List.of(reward(ItemType.MATCHING_TICKET, 1)),
null
);
Expand Down Expand Up @@ -140,7 +141,7 @@ private static ProductCreateRequest request(
List<ProductCreateRequest.ProductRewardCreateRequest> rewards,
List<ProductCreateRequest.ProductRewardCreateRequest> bonusRewards
) {
return new ProductCreateRequest(name, description, price, displayOrder, true, rewards, bonusRewards);
return new ProductCreateRequest(name, description, price, displayOrder, true, true, rewards, bonusRewards);
}

private static ProductCreateRequest.ProductRewardCreateRequest reward(ItemType itemType, int quantity) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ class ProductRepositoryTest {
@DisplayName("활성 상품은 rewards와 bonusRewards를 N+1 없이 단계적으로 조회한다")
void shouldFetchActiveProductsWithRewardsAndBonusRewards() {
// given
Product second = product("두 번째 상품", 2, true);
Product second = product("두 번째 상품", 2, true, false);
second.addReward(reward(ItemType.MATCHING_TICKET, 1));
second.addBonusReward(bonusReward(ItemType.MATCHING_TICKET, 1));

Product first = product("첫 번째 상품", 1, true);
Product first = product("첫 번째 상품", 1, true, true);
first.addReward(reward(ItemType.MATCHING_TICKET, 2));
first.addReward(reward(ItemType.OPTION_TICKET, 1));
first.addBonusReward(bonusReward(ItemType.OPTION_TICKET, 1));

Product inactive = product("비활성 상품", 3, false);
Product inactive = product("비활성 상품", 3, false, true);
inactive.addReward(reward(ItemType.MATCHING_TICKET, 1));

entityManager.persist(second);
Expand All @@ -55,7 +55,7 @@ void shouldFetchActiveProductsWithRewardsAndBonusRewards() {
entityManager.clear();

// when
List<Product> products = productRepository.findActiveProductsWithRewards();
List<Product> products = productRepository.findActiveProductsWithRewards(null);
productRepository.fetchBonusRewardsByProductIds(products.stream().map(Product::getId).toList());

// then
Expand All @@ -72,10 +72,10 @@ void shouldFetchActiveProductsWithRewardsAndBonusRewards() {
@DisplayName("관리자 상품 목록은 비활성 상품까지 정렬하여 조회한다")
void shouldFetchAllProductsOrdered() {
// given
Product inactive = product("비활성 상품", 2, false);
Product inactive = product("비활성 상품", 2, false, false);
inactive.addReward(reward(ItemType.MATCHING_TICKET, 1));

Product active = product("활성 상품", 1, true);
Product active = product("활성 상품", 1, true, true);
active.addReward(reward(ItemType.MATCHING_TICKET, 1));

entityManager.persist(inactive);
Expand All @@ -84,7 +84,7 @@ void shouldFetchAllProductsOrdered() {
entityManager.clear();

// when
List<Product> products = productRepository.findAllProductsWithRewards();
List<Product> products = productRepository.findAllProductsWithRewards(null);
productRepository.fetchBonusRewardsByProductIds(products.stream().map(Product::getId).toList());

// then
Expand All @@ -95,13 +95,60 @@ void shouldFetchAllProductsOrdered() {
});
}

private Product product(String name, int displayOrder, boolean isActive) {
@Test
@DisplayName("활성 상품 목록은 번들 여부로 필터링한다")
void shouldFetchActiveProductsFilteredByBundleFlag() {
// given
Product bundle = product("번들 상품", 1, true, true);
bundle.addReward(reward(ItemType.MATCHING_TICKET, 1));

Product single = product("단품 상품", 2, true, false);
single.addReward(reward(ItemType.MATCHING_TICKET, 1));

entityManager.persist(bundle);
entityManager.persist(single);
entityManager.flush();
entityManager.clear();

// when
List<Product> products = productRepository.findActiveProductsWithRewards(true);

// then
assertThat(products).extracting(Product::getName).containsExactly("번들 상품");
assertThat(products).allSatisfy(product -> assertThat(product.isBundle()).isTrue());
}

@Test
@DisplayName("관리자 상품 목록은 번들 여부로 필터링한다")
void shouldFetchAllProductsFilteredByBundleFlag() {
// given
Product bundle = product("번들 상품", 1, true, true);
bundle.addReward(reward(ItemType.MATCHING_TICKET, 1));

Product single = product("비번들 상품", 2, false, false);
single.addReward(reward(ItemType.MATCHING_TICKET, 1));

entityManager.persist(bundle);
entityManager.persist(single);
entityManager.flush();
entityManager.clear();

// when
List<Product> products = productRepository.findAllProductsWithRewards(false);

// then
assertThat(products).extracting(Product::getName).containsExactly("비번들 상품");
assertThat(products).allSatisfy(product -> assertThat(product.isBundle()).isFalse());
}

private Product product(String name, int displayOrder, boolean isActive, boolean isBundle) {
return Product.builder()
.name(name)
.description("상품 설명")
.price(1000)
.displayOrder(displayOrder)
.isActive(isActive)
.isBundle(isBundle)
.build();
}

Expand Down
Loading