From 90134e1161205f3a3f957432e9b6056a985fa04b Mon Sep 17 00:00:00 2001 From: popeye Date: Tue, 28 Apr 2026 15:39:40 +0900 Subject: [PATCH] feat(product): add bundle flag to product catalog --- docs/payment-order-api-spec.md | 540 ++++++++++++++++++ .../product/dto/ProductCreateRequest.java | 3 + .../domain/product/dto/ProductResponse.java | 4 + .../item/domain/product/entity/Product.java | 6 +- .../product/repository/ProductRepository.java | 6 +- .../product/service/AdminProductService.java | 2 +- .../service/AdminProductServiceImpl.java | 5 +- .../domain/product/service/ShopService.java | 2 +- .../product/service/ShopServiceImpl.java | 4 +- .../item/global/init/ShopDataInitializer.java | 4 + .../controller/AdminProductController.java | 10 +- .../item/infra/controller/ShopController.java | 9 +- .../product/dto/ProductCreateRequestTest.java | 3 +- .../repository/ProductRepositoryTest.java | 63 +- .../service/AdminProductServiceImplTest.java | 36 +- .../product/service/ShopServiceImplTest.java | 26 +- 16 files changed, 689 insertions(+), 34 deletions(-) create mode 100644 docs/payment-order-api-spec.md diff --git a/docs/payment-order-api-spec.md b/docs/payment-order-api-spec.md new file mode 100644 index 0000000..a0e5019 --- /dev/null +++ b/docs/payment-order-api-spec.md @@ -0,0 +1,540 @@ +# 결제/주문 API 최종 명세 (Gateway 8080 기준) + +## 0. 공통 +### 0.1 Base URL +- 게이트웨이: `http://localhost:8080` + +### 0.2 인증 방식 +- 보호 API는 `accessToken` 쿠키가 필요합니다. +- 게이트웨이는 `accessToken` 쿠키를 읽어 내부적으로 `X-Member-*` 헤더를 주입합니다. +- 쿠키가 없거나 유효하지 않으면 게이트웨이에서 차단됩니다. + +게이트웨이 인증 에러 예시: +```json +{ + "code": "GATEWAY-001", + "status": 401, + "message": "토큰이 누락되었습니다." +} +``` + +### 0.3 공통 응답 포맷 +성공: +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": {} +} +``` + +에러: +```json +{ + "code": "ITEM-002", + "status": 400, + "message": "상품을 찾을 수 없습니다.", + "data": null +} +``` + +### 0.4 주문 상태 +- `PENDING`: 승인 대기 +- `APPROVED`: 승인 완료 +- `REJECTED`: 거절 완료 +- `CANCELED`: 취소 +- `EXPIRED`: 만료 (요청 후 10분 경과) + +--- + +## 1. (선행) 로그인 - 관리자/사용자 +### 1.1 프론트 요청 +- `POST /api/auth/login` + +요청 body: +```json +{ + "email": "admin@test.com", + "password": "1234" +} +``` + +### 1.2 서버 응답 +- 성공 시 `Set-Cookie`로 `accessToken`, `refreshToken` 발급 +- 응답 코드는 일반적으로 `302` (리다이렉트: `/main` 또는 `/onboarding`) + +### 1.3 주요 에러코드 +- `AUTH-008` 로그인 실패 +- `AUTH-004` 계정 정지 +- `AUTH-005` 계정 비활성 +- `GEN-010` 인증되지 않은 사용자 + +--- + +## 2. 사용자 실명 수정 +### 2.1 프론트 요청 +- `PATCH /api/members/real-name` +- 쿠키: `accessToken` (ROLE_USER) + +요청 body: +```json +{ + "realName": "홍길동" +} +``` + +### 2.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": null +} +``` + +### 2.3 주요 에러코드 +- `MEM-011` 실명 공백 +- `MEM-001` 사용자 없음 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 3. [관리자] 상품 등록 +### 3.1 프론트 요청 +- `POST /api/v1/admin/shop/products` +- 쿠키: `accessToken` (ROLE_ADMIN) + +요청 body: +```json +{ + "name": "매칭권 10개 (+옵션권 5개)", + "description": "매칭권과 옵션권을 함께 충전해요.", + "price": 9000, + "displayOrder": 3, + "isActive": true, + "isBundle": true, + "rewards": [ + { + "itemType": "MATCHING_TICKET", + "quantity": 10 + }, + { + "itemType": "OPTION_TICKET", + "quantity": 10 + } + ], + "bonusRewards": [ + { + "itemType": "OPTION_TICKET", + "quantity": 5 + } + ] +} +``` + +필드 규칙: +- `description`: 필수, 공백 불가, 50자 이하 +- `price`: 1 이상 +- `displayOrder`: 0 이상, 낮을수록 먼저 노출 +- `isBundle`: 번들 상품 여부, true이면 번들 조회 필터에 포함 +- `rewards`: 실제 지급 구성품, 최소 1개 이상 +- `bonusRewards`: 프론트 표시용 보너스 구성품, 실제 지급은 `rewards` 기준 +- `bonusRewards.itemType`: 같은 요청의 `rewards`에 존재해야 함 +- `bonusRewards.quantity`: 1 이상, 동일 `itemType`의 실제 지급 수량 이하 +- `rewards`, `bonusRewards` 각각 동일 `itemType` 중복 불가 + +### 3.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": { + "id": 10, + "name": "매칭권 10개 (+옵션권 5개)", + "description": "매칭권과 옵션권을 함께 충전해요.", + "price": 9000, + "displayOrder": 3, + "isActive": true, + "isBundle": true, + "rewards": [ + { + "itemType": "MATCHING_TICKET", + "itemName": "매칭권", + "quantity": 10 + }, + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 10 + } + ], + "bonusRewards": [ + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 5 + } + ] + } +} +``` + +### 3.3 주요 에러코드 +- `GEN-011` 권한 없음 +- `GEN-003` 유효성 검증 실패 +- `GEN-002` 입력값 오류 (설명/순서/중복 itemType, 가격/수량 오류, 보너스 구성 오류) +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 4. [관리자] 상품 목록 조회 +### 4.1 프론트 요청 +- `GET /api/v1/admin/shop/products` +- `GET /api/v1/admin/shop/products?isBundle=true` +- `GET /api/v1/admin/shop/products?isBundle=false` +- 쿠키: `accessToken` (ROLE_ADMIN) + +Query: +- `isBundle` (optional): `true`이면 번들 상품만, `false`이면 비번들 상품만 반환. 생략하면 전체 반환 + +### 4.2 서버 성공 응답 +활성/비활성 전체 상품을 `displayOrder ASC, id ASC` 순서로 반환합니다. `isBundle`이 있으면 해당 여부로 필터링합니다. +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": [ + { + "id": 10, + "name": "매칭권 10개 (+옵션권 5개)", + "description": "매칭권과 옵션권을 함께 충전해요.", + "price": 9000, + "displayOrder": 3, + "isActive": true, + "isBundle": true, + "rewards": [ + { + "itemType": "MATCHING_TICKET", + "itemName": "매칭권", + "quantity": 10 + }, + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 10 + } + ], + "bonusRewards": [ + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 5 + } + ] + }, + { + "id": 11, + "name": "판매 중지 상품", + "description": "관리자에게만 보이는 비활성 상품입니다.", + "price": 1000, + "displayOrder": 99, + "isActive": false, + "isBundle": false, + "rewards": [ + { + "itemType": "MATCHING_TICKET", + "itemName": "매칭권", + "quantity": 1 + } + ], + "bonusRewards": [] + } + ] +} +``` + +### 4.3 주요 에러코드 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 5. [관리자] 상품 삭제(판매 중지) +### 5.1 프론트 요청 +- `DELETE /api/v1/admin/shop/products/{productId}` +- 쿠키: `accessToken` (ROLE_ADMIN) +- body: 없음 + +Path: +- `productId`: 판매 중지 처리할 상품 ID + +### 5.2 서버 성공 응답 +상품을 DB에서 실제 삭제하지 않고 `isActive=false`로 변경합니다. +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": null +} +``` + +### 5.3 주요 에러코드 +- `GEN-007` 상품 없음 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 6. 상품 목록 조회 +### 6.1 프론트 요청 +- `GET /api/v1/shop/products` +- `GET /api/v1/shop/products?isBundle=true` +- `GET /api/v1/shop/products?isBundle=false` + +Query: +- `isBundle` (optional): `true`이면 번들 상품만, `false`이면 비번들 상품만 반환. 생략하면 전체 활성 상품 반환 + +### 6.2 서버 성공 응답 +활성 상품만 `displayOrder ASC, id ASC` 순서로 반환합니다. `isBundle`이 있으면 해당 여부로 필터링합니다. +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": [ + { + "id": 10, + "name": "매칭권 10개 (+옵션권 5개)", + "description": "매칭권과 옵션권을 함께 충전해요.", + "price": 9000, + "displayOrder": 3, + "isActive": true, + "isBundle": true, + "rewards": [ + { + "itemType": "MATCHING_TICKET", + "itemName": "매칭권", + "quantity": 10 + }, + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 10 + } + ], + "bonusRewards": [ + { + "itemType": "OPTION_TICKET", + "itemName": "옵션권", + "quantity": 5 + } + ] + } + ] +} +``` + +### 6.3 주요 에러코드 +- `GEN-099` 서버 내부 오류 + +--- + +## 7. 결제 주문 생성 (상품 ID 기반) +### 7.1 프론트 요청 +- `POST /api/v1/shop/purchase/{productId}` +- 쿠키: `accessToken` (ROLE_USER) +- body: 없음 + +### 7.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": null +} +``` + +### 7.3 주요 에러코드 +- `ITEM-002` 상품 없음 +- `ITEM-003` 비활성 상품 +- `PAY-003` 이미 대기 주문 존재 +- `PAY-004` 실명 없음 +- `PAY-010` username(닉네임) 없음 +- `GEN-005` path variable 타입 오류 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 8. 내 대기 주문 상태 조회 +### 8.1 프론트 요청 +- `GET /api/v1/shop/purchase/status` +- 쿠키: `accessToken` + +### 8.2 서버 성공 응답 +PENDING: +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": { + "status": "PENDING" + } +} +``` + +NONE: +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": { + "status": "NONE" + } +} +``` + +### 8.3 주요 에러코드 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 9. [관리자] 대기 주문 목록 조회 +### 9.1 프론트 요청 +- `GET /api/v1/admin/payment/requests` +- 쿠키: `accessToken` (ROLE_ADMIN) + +### 9.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": [ + { + "requestId": 101, + "memberId": 25, + "requestedItemName": "매칭권 10개 (+옵션권 5개)", + "requesterRealName": "홍길동", + "requesterUsername": "길동이", + "optionTicketQty": 10, + "matchingTicketQty": 10, + "requestedPrice": 9000, + "expectedPrice": 9000, + "status": "PENDING", + "requestedAt": "2026-04-23T14:10:00", + "expiresAt": "2026-04-23T14:20:00" + } + ] +} +``` + +### 9.3 주요 에러코드 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 10. [관리자] 승인 +### 10.1 프론트 요청 +- `POST /api/v1/admin/payment/approve/{requestId}` +- 쿠키: `accessToken` (ROLE_ADMIN) +- body: 없음 + +### 10.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": null +} +``` + +### 10.3 주요 에러코드 +- `PAY-001` 요청 없음 +- `PAY-002` 이미 처리됨 (동시 승인 포함) +- `PAY-006` 요청 만료 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 11. [관리자] 거절 +### 11.1 프론트 요청 +- `POST /api/v1/admin/payment/reject/{requestId}` +- 쿠키: `accessToken` (ROLE_ADMIN) +- body: 없음 + +### 11.2 서버 성공 응답 +```json +{ + "code": "GEN-000", + "status": 200, + "message": "요청이 성공적으로 처리되었습니다.", + "data": null +} +``` + +### 11.3 주요 에러코드 +- `PAY-001` 요청 없음 +- `PAY-002` 이미 처리됨 +- `PAY-006` 요청 만료 +- `GEN-011` 권한 없음 +- `GATEWAY-001`, `GATEWAY-002` + +--- + +## 12. 관리자 실시간 모니터링 (STOMP) +### 12.1 연결 정보 +- 모니터 페이지: `GET /admin-payment-monitor.html` +- SockJS endpoint: `/ws/payment` +- STOMP subscribe topic: `/topic/admin/orders` + +### 12.2 서버 푸시 공통 포맷 +```json +{ + "eventId": 1234, + "eventType": "ORDER_CREATED", + "occurredAt": "2026-04-23T14:10:00", + "payload": {} +} +``` + +### 12.3 `ORDER_CREATED.payload` +```json +{ + "orderId": 101, + "memberId": 25, + "requestedItemName": "매칭권 10개 (+옵션권 5개)", + "requesterRealName": "홍길동", + "requesterUsername": "길동이", + "optionTicketQty": 10, + "matchingTicketQty": 10, + "requestedPrice": 9000, + "expectedPrice": 9000, + "status": "PENDING", + "requestedAt": "2026-04-23T14:10:00", + "expiresAt": "2026-04-23T14:20:00" +} +``` + +### 12.4 `ORDER_STATUS_CHANGED.payload` +```json +{ + "orderId": 101, + "fromStatus": "PENDING", + "toStatus": "APPROVED", + "decidedAt": "2026-04-23T14:12:00", + "decidedByAdminId": 1, + "reason": null +} +``` diff --git a/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductCreateRequest.java b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductCreateRequest.java index 50b448e..2db8ad2 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductCreateRequest.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductCreateRequest.java @@ -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, diff --git a/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java index d99c424..aeffb76 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/dto/ProductResponse.java @@ -29,6 +29,9 @@ public record ProductResponse( @Schema(description = "판매 활성 여부. 사용자 상품 목록은 true 상품만 반환합니다.", example = "true") boolean isActive, + @Schema(description = "번들 상품 여부. isBundle 필터 조회에 사용됩니다.", example = "true") + boolean isBundle, + @Schema(description = "실제 지급 구성품 목록. 구매 승인 시 이 수량이 지급됩니다.") List rewards, @@ -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(), diff --git a/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java b/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java index 59ec31c..2cee83e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/entity/Product.java @@ -32,6 +32,9 @@ 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 rewards = new ArrayList<>(); @@ -39,12 +42,13 @@ public class Product { private List 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; } // 연관관계 편의 메서드 (상품 생성 시 구성품을 쉽게 추가하기 위함) diff --git a/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java b/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java index aef6ff9..7a6fe20 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/repository/ProductRepository.java @@ -13,17 +13,19 @@ public interface ProductRepository extends JpaRepository { 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 findActiveProductsWithRewards(); + List 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 findAllProductsWithRewards(); + List findAllProductsWithRewards(@Param("isBundle") Boolean isBundle); @Query(""" SELECT DISTINCT p diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductService.java index 593242d..84c531a 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductService.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductService.java @@ -9,7 +9,7 @@ public interface AdminProductService { ProductResponse createProduct(ProductCreateRequest request); - List getProducts(); + List getProducts(Boolean isBundle); void deleteProduct(Long productId); } diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductServiceImpl.java index 6c0fbec..bf51b61 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminProductServiceImpl.java @@ -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 -> { @@ -64,8 +65,8 @@ public ProductResponse createProduct(ProductCreateRequest request) { @Override @Transactional(readOnly = true) - public List getProducts() { - List products = productRepository.findAllProductsWithRewards(); + public List getProducts(Boolean isBundle) { + List products = productRepository.findAllProductsWithRewards(isBundle); fetchBonusRewards(products); return products.stream() .map(ProductResponse::from) diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java index 36c63e2..ef1651e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java @@ -7,7 +7,7 @@ public interface ShopService { - List getActiveProducts(); + List getActiveProducts(Boolean isBundle); void requestPurchase(Long memberId, Long productId); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java index e0f293d..f0327dd 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java @@ -38,8 +38,8 @@ public class ShopServiceImpl implements ShopService { @Override @Transactional(readOnly = true) - public List getActiveProducts() { - List products = productRepository.findActiveProductsWithRewards(); + public List getActiveProducts(Boolean isBundle) { + List products = productRepository.findActiveProductsWithRewards(isBundle); fetchBonusRewards(products); return products.stream() .map(ProductResponse::from) diff --git a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java index 4c63ae1..c288558 100644 --- a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java +++ b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java index 47be0cf..3f3a173 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java @@ -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; @@ -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> createProduct( @@ -50,13 +51,14 @@ public ResponseEntity> createProduct( @RequireRole(MemberRole.ROLE_ADMIN) @Operation( summary = "관리자 상품 목록 조회", - description = "활성/비활성 전체 상품 목록을 displayOrder 오름차순, id 오름차순으로 조회합니다." + description = "활성/비활성 전체 상품 목록을 displayOrder 오름차순, id 오름차순으로 조회합니다. isBundle query parameter로 번들/비번들 상품을 필터링할 수 있습니다." ) @GetMapping("/products") public ResponseEntity>> 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) diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java index 0cd8a1f..7b38aba 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java @@ -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; @@ -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>> getActiveProducts() { - return ResponseEntity.ok(ApiResponse.ok(shopService.getActiveProducts())); + public ResponseEntity>> getActiveProducts( + @RequestParam(required = false) Boolean isBundle + ) { + return ResponseEntity.ok(ApiResponse.ok(shopService.getActiveProducts(isBundle))); } @Operation(summary = "아이템 구매 요청", description = "상품 ID 기반으로 구매 요청(입금 대기)을 생성합니다.") diff --git a/item-service/src/test/java/com/comatching/item/domain/product/dto/ProductCreateRequestTest.java b/item-service/src/test/java/com/comatching/item/domain/product/dto/ProductCreateRequestTest.java index 0678a17..8e2af33 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/dto/ProductCreateRequestTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/dto/ProductCreateRequestTest.java @@ -45,6 +45,7 @@ void shouldAllowNullBonusRewards() { 1000, 1, true, + true, List.of(reward(ItemType.MATCHING_TICKET, 1)), null ); @@ -140,7 +141,7 @@ private static ProductCreateRequest request( List rewards, List 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) { diff --git a/item-service/src/test/java/com/comatching/item/domain/product/repository/ProductRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/product/repository/ProductRepositoryTest.java index 82df7c2..e0208c8 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/repository/ProductRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/repository/ProductRepositoryTest.java @@ -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); @@ -55,7 +55,7 @@ void shouldFetchActiveProductsWithRewardsAndBonusRewards() { entityManager.clear(); // when - List products = productRepository.findActiveProductsWithRewards(); + List products = productRepository.findActiveProductsWithRewards(null); productRepository.fetchBonusRewardsByProductIds(products.stream().map(Product::getId).toList()); // then @@ -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); @@ -84,7 +84,7 @@ void shouldFetchAllProductsOrdered() { entityManager.clear(); // when - List products = productRepository.findAllProductsWithRewards(); + List products = productRepository.findAllProductsWithRewards(null); productRepository.fetchBonusRewardsByProductIds(products.stream().map(Product::getId).toList()); // then @@ -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 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 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(); } diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/AdminProductServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/AdminProductServiceImplTest.java index d32963a..23766d6 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/AdminProductServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/AdminProductServiceImplTest.java @@ -48,6 +48,7 @@ void shouldCreateProduct() { 3300, 7, true, + true, List.of( reward(ItemType.MATCHING_TICKET, 3), reward(ItemType.OPTION_TICKET, 1) @@ -70,6 +71,7 @@ void shouldCreateProduct() { assertThat(saved.getPrice()).isEqualTo(3300); assertThat(saved.getDisplayOrder()).isEqualTo(7); assertThat(saved.isActive()).isTrue(); + assertThat(saved.isBundle()).isTrue(); assertThat(saved.getRewards()).hasSize(2); assertThat(saved.getBonusRewards()).hasSize(1); assertThat(response.name()).isEqualTo("신규 번들"); @@ -77,6 +79,7 @@ void shouldCreateProduct() { assertThat(response.price()).isEqualTo(3300); assertThat(response.displayOrder()).isEqualTo(7); assertThat(response.isActive()).isTrue(); + assertThat(response.isBundle()).isTrue(); assertThat(response.rewards()).hasSize(2); assertThat(response.bonusRewards()).hasSize(1); assertThat(response.bonusRewards().get(0).itemType()).isEqualTo(ItemType.OPTION_TICKET); @@ -87,27 +90,45 @@ void shouldCreateProduct() { @DisplayName("관리자 상품 목록은 비활성 상품도 포함한다") void shouldGetAllProductsForAdmin() { // given - Product active = product("활성 상품", "활성 설명", 1000, 1, true); - Product inactive = product("비활성 상품", "비활성 설명", 2000, 2, false); + Product active = product("활성 상품", "활성 설명", 1000, 1, true, true); + Product inactive = product("비활성 상품", "비활성 설명", 2000, 2, false, false); ReflectionTestUtils.setField(active, "id", 1L); ReflectionTestUtils.setField(inactive, "id", 2L); - given(productRepository.findAllProductsWithRewards()).willReturn(List.of(active, inactive)); + given(productRepository.findAllProductsWithRewards(null)).willReturn(List.of(active, inactive)); // when - List responses = adminProductService.getProducts(); + List responses = adminProductService.getProducts(null); // then assertThat(responses).extracting(ProductResponse::id).containsExactly(1L, 2L); assertThat(responses).extracting(ProductResponse::isActive).containsExactly(true, false); + assertThat(responses).extracting(ProductResponse::isBundle).containsExactly(true, false); then(productRepository).should().fetchBonusRewardsByProductIds(List.of(1L, 2L)); } + @Test + @DisplayName("관리자 상품 목록은 번들 여부로 필터링할 수 있다") + void shouldGetProductsFilteredByBundleFlagForAdmin() { + // given + Product bundle = product("번들 상품", "번들 설명", 1000, 1, true, true); + ReflectionTestUtils.setField(bundle, "id", 1L); + given(productRepository.findAllProductsWithRewards(true)).willReturn(List.of(bundle)); + + // when + List responses = adminProductService.getProducts(true); + + // then + assertThat(responses).extracting(ProductResponse::isBundle).containsExactly(true); + then(productRepository).should().findAllProductsWithRewards(true); + then(productRepository).should().fetchBonusRewardsByProductIds(List.of(1L)); + } + @Test @DisplayName("상품 삭제는 실제 삭제가 아니라 비활성화한다") void shouldDeactivateProductWhenDeleted() { // given - Product product = product("판매 상품", "판매 설명", 1000, 1, true); + Product product = product("판매 상품", "판매 설명", 1000, 1, true, false); given(productRepository.findById(1L)).willReturn(Optional.of(product)); // when @@ -193,20 +214,21 @@ private ProductCreateRequest request( List rewards, List bonusRewards ) { - return new ProductCreateRequest("신규 번들", "상품 설명", 1000, 1, true, rewards, bonusRewards); + return new ProductCreateRequest("신규 번들", "상품 설명", 1000, 1, true, true, rewards, bonusRewards); } private ProductCreateRequest.ProductRewardCreateRequest reward(ItemType itemType, int quantity) { return new ProductCreateRequest.ProductRewardCreateRequest(itemType, quantity); } - private Product product(String name, String description, int price, int displayOrder, boolean isActive) { + private Product product(String name, String description, int price, int displayOrder, boolean isActive, boolean isBundle) { Product product = Product.builder() .name(name) .description(description) .price(price) .displayOrder(displayOrder) .isActive(isActive) + .isBundle(isBundle) .build(); product.addReward(ProductReward.builder().itemType(ItemType.MATCHING_TICKET).quantity(1).build()); product.addBonusReward(ProductBonusReward.builder().itemType(ItemType.MATCHING_TICKET).quantity(1).build()); diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java index 27b9951..2edff61 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java @@ -103,10 +103,10 @@ void shouldGetActiveProductsOrderedByDisplayOrderAndId() { Product second = product("두 번째 상품", 2000, true); ReflectionTestUtils.setField(first, "id", 1L); ReflectionTestUtils.setField(second, "id", 2L); - given(productRepository.findActiveProductsWithRewards()).willReturn(List.of(first, second)); + given(productRepository.findActiveProductsWithRewards(null)).willReturn(List.of(first, second)); // when - List responses = shopService.getActiveProducts(); + List responses = shopService.getActiveProducts(null); // then assertThat(responses).extracting(ProductResponse::id).containsExactly(1L, 2L); @@ -114,6 +114,23 @@ void shouldGetActiveProductsOrderedByDisplayOrderAndId() { then(productRepository).should().fetchBonusRewardsByProductIds(List.of(1L, 2L)); } + @Test + @DisplayName("활성 상품 목록은 번들 여부로 필터링할 수 있다") + void shouldGetActiveProductsFilteredByBundleFlag() { + // given + Product bundle = product("번들 상품", 2000, true, true); + ReflectionTestUtils.setField(bundle, "id", 1L); + given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(bundle)); + + // when + List responses = shopService.getActiveProducts(true); + + // then + assertThat(responses).extracting(ProductResponse::isBundle).containsExactly(true); + then(productRepository).should().findActiveProductsWithRewards(true); + then(productRepository).should().fetchBonusRewardsByProductIds(List.of(1L)); + } + @Test @DisplayName("존재하지 않는 상품 ID면 예외가 발생한다") void shouldThrowWhenProductNotFound() { @@ -216,12 +233,17 @@ void shouldReturnNoneStatusWhenNoPendingRequest() { } private Product product(String name, int price, boolean isActive) { + return product(name, price, isActive, false); + } + + private Product product(String name, int price, boolean isActive, boolean isBundle) { return Product.builder() .name(name) .description("상품 설명") .price(price) .displayOrder(1) .isActive(isActive) + .isBundle(isBundle) .build(); } }