diff --git a/src/main/java/com/bbangle/bbangle/board/repository/ProductImgRepository.java b/src/main/java/com/bbangle/bbangle/board/repository/ProductImgRepository.java index 2984b3c3a..66505045c 100644 --- a/src/main/java/com/bbangle/bbangle/board/repository/ProductImgRepository.java +++ b/src/main/java/com/bbangle/bbangle/board/repository/ProductImgRepository.java @@ -10,6 +10,14 @@ public interface ProductImgRepository extends JpaRepository { List findAllByIdInOrderByIdAsc(List imageIds); + @Query(""" + SELECT pi FROM ProductImg pi + WHERE pi.board.id IN :boardIds + AND pi.imgOrder = 0 + AND pi.isDeleted = false + """) + List findThumbnailsByBoardIdIn(@Param("boardIds") List boardIds); + @Modifying(clearAutomatically = true) @Query(""" UPDATE ProductImg pi diff --git a/src/main/java/com/bbangle/bbangle/board/repository/ProductRepository.java b/src/main/java/com/bbangle/bbangle/board/repository/ProductRepository.java index de06bf338..410c2946a 100644 --- a/src/main/java/com/bbangle/bbangle/board/repository/ProductRepository.java +++ b/src/main/java/com/bbangle/bbangle/board/repository/ProductRepository.java @@ -41,4 +41,13 @@ public interface ProductRepository extends JpaRepository, Product WHERE p.id IN :productIds """) void softDeleteByProductIds(@Param("productIds") List productIds); + + @Query(""" + SELECT p FROM Product p + JOIN FETCH p.board b + JOIN FETCH b.store s + WHERE p.id IN :ids + AND p.isDeleted = false + """) + List findAllWithBoardAndStoreByIdIn(@Param("ids") List ids); } diff --git a/src/main/java/com/bbangle/bbangle/config/security/CustomerApiPath.java b/src/main/java/com/bbangle/bbangle/config/security/CustomerApiPath.java index 34c39be6d..7de85f510 100644 --- a/src/main/java/com/bbangle/bbangle/config/security/CustomerApiPath.java +++ b/src/main/java/com/bbangle/bbangle/config/security/CustomerApiPath.java @@ -5,7 +5,10 @@ public class CustomerApiPath { public static final String PREFIX = "/api/v1/customer"; public static final String[] ANY_METHOD = { - "/api/v1/boards/folders/**" + "/api/v1/boards/folders/**", + "/api/v1/customer/orders/**", + "/api/v1/customer/delivery-addresses/**", + "/api/v1/customer/payments/**" }; } diff --git a/src/main/java/com/bbangle/bbangle/delivery/domain/Shipping.java b/src/main/java/com/bbangle/bbangle/delivery/domain/Shipping.java index 17cbe8a1a..a4e03d556 100644 --- a/src/main/java/com/bbangle/bbangle/delivery/domain/Shipping.java +++ b/src/main/java/com/bbangle/bbangle/delivery/domain/Shipping.java @@ -44,6 +44,12 @@ public static Shipping empty() { return new Shipping(null, null, null); } + public static Shipping withMemo(String deliveryMemo) { + Shipping shipping = new Shipping(null, null, null); + shipping.deliveryMemo = deliveryMemo; + return shipping; + } + public void updateShippingInfo(String courierName, String trackingNumber) { this.courierName = courierName; this.trackingNumber = trackingNumber; diff --git a/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java b/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java index 716a8a55b..4b7492e25 100644 --- a/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java +++ b/src/main/java/com/bbangle/bbangle/exception/BbangleErrorCode.java @@ -207,7 +207,18 @@ public enum BbangleErrorCode { INVALID_VAT_DATE_RANGE(-852, "조회 기간이 올바르지 않습니다.", BAD_REQUEST), EXCEEDED_MAX_VAT_DATE_RANGE(-853, "조회 기간은 최대 1년까지 가능합니다.", BAD_REQUEST), INVALID_EXCEL_TYPE(-854, "엑셀 다운로드 유형이 올바르지 않습니다.", BAD_REQUEST), - EXCEL_CREATE_FAILED(-855, "엑셀 파일 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + EXCEL_CREATE_FAILED(-855, "엑셀 파일 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + // Order Customer Error(856 ~ 870) + DELIVERY_ADDRESS_NOT_FOUND(-856, "기본 배송지를 찾을 수 없습니다.", NOT_FOUND), + ORDER_PRODUCT_EMPTY(-857, "주문 상품이 비어 있습니다.", BAD_REQUEST), + + // Payment Customer Error(871 ~ 880) + PAYMENT_NOT_FOUND(-871, "결제 정보를 찾을 수 없습니다.", NOT_FOUND), + PAYMENT_AMOUNT_MISMATCH(-872, "결제 금액이 일치하지 않습니다.", BAD_REQUEST), + PAYMENT_CONFIRM_FAILED(-873, "결제 승인에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + PAYMENT_ALREADY_COMPLETED(-874, "이미 완료된 결제입니다.", CONFLICT), + INVALID_ORDER_STATUS_TRANSITION(-875, "해당 주문 상태에서는 상태를 변경할 수 없습니다.", BAD_REQUEST); private final int code; private final String message; diff --git a/src/main/java/com/bbangle/bbangle/member/customer/controller/DeliveryAddressController.java b/src/main/java/com/bbangle/bbangle/member/customer/controller/DeliveryAddressController.java new file mode 100644 index 000000000..aa0f2a56e --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/customer/controller/DeliveryAddressController.java @@ -0,0 +1,84 @@ +package com.bbangle.bbangle.member.customer.controller; + +import com.bbangle.bbangle.common.dto.CommonResult; +import com.bbangle.bbangle.common.dto.ListResult; +import com.bbangle.bbangle.common.dto.SingleResult; +import com.bbangle.bbangle.common.service.ResponseService; +import com.bbangle.bbangle.member.customer.controller.dto.request.DeliveryAddressSaveRequest; +import com.bbangle.bbangle.member.customer.controller.dto.response.DeliveryAddressResponse; +import com.bbangle.bbangle.member.customer.service.DeliveryAddressService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/customer/delivery-addresses") +public class DeliveryAddressController { + + private final DeliveryAddressService deliveryAddressService; + private final ResponseService responseService; + + @GetMapping + @Operation(summary = "배송지 목록 조회") + public ListResult getDeliveryAddresses( + @AuthenticationPrincipal Long memberId + ) { + return responseService.getListResult( + deliveryAddressService.getDeliveryAddresses(memberId) + ); + } + + @PostMapping + @Operation(summary = "배송지 추가") + public SingleResult addDeliveryAddress( + @Valid @RequestBody DeliveryAddressSaveRequest request, + @AuthenticationPrincipal Long memberId + ) { + return responseService.getSingleResult( + deliveryAddressService.addDeliveryAddress(memberId, request) + ); + } + + @PutMapping("/{id}") + @Operation(summary = "배송지 수정") + public SingleResult updateDeliveryAddress( + @PathVariable Long id, + @Valid @RequestBody DeliveryAddressSaveRequest request, + @AuthenticationPrincipal Long memberId + ) { + return responseService.getSingleResult( + deliveryAddressService.updateDeliveryAddress(memberId, id, request) + ); + } + + @DeleteMapping("/{id}") + @Operation(summary = "배송지 삭제") + public CommonResult deleteDeliveryAddress( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId + ) { + deliveryAddressService.deleteDeliveryAddress(memberId, id); + return responseService.getSuccessResult(); + } + + @PatchMapping("/{id}/default") + @Operation(summary = "기본 배송지 설정") + public CommonResult setDefaultDeliveryAddress( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId + ) { + deliveryAddressService.setDefaultDeliveryAddress(memberId, id); + return responseService.getSuccessResult(); + } +} diff --git a/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/request/DeliveryAddressSaveRequest.java b/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/request/DeliveryAddressSaveRequest.java new file mode 100644 index 000000000..543b8d9be --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/request/DeliveryAddressSaveRequest.java @@ -0,0 +1,15 @@ +package com.bbangle.bbangle.member.customer.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record DeliveryAddressSaveRequest( + @NotBlank @Size(max = 50) String addressName, + @NotBlank @Size(max = 100) String recipientName, + @NotBlank @Size(max = 20) String phone, + @NotBlank @Size(max = 255) String address, + @Size(max = 255) String addressDetail, + @Size(max = 10) String zipCode, + Boolean isDefault +) { +} diff --git a/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/response/DeliveryAddressResponse.java b/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/response/DeliveryAddressResponse.java new file mode 100644 index 000000000..6ebb2b953 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/customer/controller/dto/response/DeliveryAddressResponse.java @@ -0,0 +1,27 @@ +package com.bbangle.bbangle.member.customer.controller.dto.response; + +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; + +public record DeliveryAddressResponse( + Long id, + String addressName, + boolean isDefault, + String recipientName, + String phone, + String address, + String addressDetail, + String zipCode +) { + public static DeliveryAddressResponse from(MemberDeliveryAddress entity) { + return new DeliveryAddressResponse( + entity.getId(), + entity.getAddressName(), + entity.isDefault(), + entity.getRecipientName(), + entity.getPhone(), + entity.getAddress(), + entity.getAddressDetail(), + entity.getZipCode() + ); + } +} diff --git a/src/main/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressService.java b/src/main/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressService.java new file mode 100644 index 000000000..05bd1fd25 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressService.java @@ -0,0 +1,114 @@ +package com.bbangle.bbangle.member.customer.service; + +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.member.customer.controller.dto.request.DeliveryAddressSaveRequest; +import com.bbangle.bbangle.member.customer.controller.dto.response.DeliveryAddressResponse; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import com.bbangle.bbangle.member.repository.MemberDeliveryAddressRepository; +import com.bbangle.bbangle.member.repository.MemberRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class DeliveryAddressService { + + private final MemberDeliveryAddressRepository deliveryAddressRepository; + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public List getDeliveryAddresses(Long memberId) { + return deliveryAddressRepository.findAllByMemberIdAndIsDeletedFalse(memberId) + .stream() + .map(DeliveryAddressResponse::from) + .toList(); + } + + public DeliveryAddressResponse addDeliveryAddress(Long memberId, DeliveryAddressSaveRequest request) { + boolean makeDefault = Boolean.TRUE.equals(request.isDefault()); + + // isDefault=true일 때 Member 행 잠금을 직렬화 포인트로 사용해 동시 insert 경합 방지 + Member member = makeDefault + ? memberRepository.findByIdWithLock(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER)) + : memberRepository.findById(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER)); + + if (makeDefault) { + deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(memberId) + .ifPresent(MemberDeliveryAddress::unsetDefault); + } + + MemberDeliveryAddress address = MemberDeliveryAddress.builder() + .member(member) + .addressName(request.addressName()) + .recipientName(request.recipientName()) + .phone(request.phone()) + .address(request.address()) + .addressDetail(request.addressDetail()) + .zipCode(request.zipCode()) + .isDefault(makeDefault) + .build(); + + return DeliveryAddressResponse.from(deliveryAddressRepository.save(address)); + } + + public DeliveryAddressResponse updateDeliveryAddress(Long memberId, Long addressId, + DeliveryAddressSaveRequest request) { + MemberDeliveryAddress address = findOwnedAddress(memberId, addressId); + + address.update( + request.addressName(), + request.recipientName(), + request.phone(), + request.address(), + request.addressDetail(), + request.zipCode() + ); + + // isDefault가 null이면 기존 기본 배송지 상태 유지 + if (request.isDefault() != null) { + boolean makeDefault = request.isDefault(); + if (makeDefault && !address.isDefault()) { + // Member 행 잠금으로 직렬화 후 기존 기본 배송지 해제 + memberRepository.findByIdWithLock(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER)); + deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(memberId) + .ifPresent(MemberDeliveryAddress::unsetDefault); + address.setDefault(); + } else if (!makeDefault) { + address.unsetDefault(); + } + } + + return DeliveryAddressResponse.from(address); + } + + public void deleteDeliveryAddress(Long memberId, Long addressId) { + MemberDeliveryAddress address = findOwnedAddress(memberId, addressId); + address.delete(); + } + + public void setDefaultDeliveryAddress(Long memberId, Long addressId) { + // Member 행 잠금을 먼저 획득해 동시 기본 배송지 변경 직렬화 + memberRepository.findByIdWithLock(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER)); + + MemberDeliveryAddress address = findOwnedAddress(memberId, addressId); + deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(memberId) + .ifPresent(MemberDeliveryAddress::unsetDefault); + address.setDefault(); + } + + // 소유권을 조회 조건에 포함해 타인 주소 접근 시에도 동일한 404 반환 (ID 열거 방지) + private MemberDeliveryAddress findOwnedAddress(Long memberId, Long addressId) { + return deliveryAddressRepository + .findByIdAndMemberIdAndIsDeletedFalse(addressId, memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bbangle/bbangle/member/domain/MemberDeliveryAddress.java b/src/main/java/com/bbangle/bbangle/member/domain/MemberDeliveryAddress.java new file mode 100644 index 000000000..d27cddeab --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/domain/MemberDeliveryAddress.java @@ -0,0 +1,91 @@ +package com.bbangle.bbangle.member.domain; + +import com.bbangle.bbangle.common.domain.SoftDeleteBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "member_delivery_address") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberDeliveryAddress extends SoftDeleteBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(name = "address_name", nullable = false, length = 50) + private String addressName; + + @Column(name = "is_default", nullable = false, columnDefinition = "tinyint") + private boolean isDefault; + + @Column(name = "recipient_name", nullable = false, length = 100) + private String recipientName; + + @Column(name = "phone", nullable = false, length = 20) + private String phone; + + @Column(name = "address", nullable = false, length = 255) + private String address; + + @Column(name = "address_detail", length = 255) + private String addressDetail; + + @Column(name = "zip_code", length = 10) + private String zipCode; + + @Builder + private MemberDeliveryAddress( + Member member, + String addressName, + boolean isDefault, + String recipientName, + String phone, + String address, + String addressDetail, + String zipCode + ) { + this.member = member; + this.addressName = addressName; + this.isDefault = isDefault; + this.recipientName = recipientName; + this.phone = phone; + this.address = address; + this.addressDetail = addressDetail; + this.zipCode = zipCode; + } + + public void update(String addressName, String recipientName, String phone, + String address, String addressDetail, String zipCode) { + this.addressName = addressName; + this.recipientName = recipientName; + this.phone = phone; + this.address = address; + this.addressDetail = addressDetail; + this.zipCode = zipCode; + } + + public void setDefault() { + this.isDefault = true; + } + + public void unsetDefault() { + this.isDefault = false; + } +} diff --git a/src/main/java/com/bbangle/bbangle/member/repository/MemberDeliveryAddressRepository.java b/src/main/java/com/bbangle/bbangle/member/repository/MemberDeliveryAddressRepository.java new file mode 100644 index 000000000..674bae2d6 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/member/repository/MemberDeliveryAddressRepository.java @@ -0,0 +1,17 @@ +package com.bbangle.bbangle.member.repository; + +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberDeliveryAddressRepository extends JpaRepository { + + // 기본 배송지 단순 조회 (읽기 전용) + Optional findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(Long memberId); + + // 소유권을 조회 조건에 포함해 ID 열거 방지 (없으면 무조건 404) + Optional findByIdAndMemberIdAndIsDeletedFalse(Long id, Long memberId); + + List findAllByMemberIdAndIsDeletedFalse(Long memberId); +} diff --git a/src/main/java/com/bbangle/bbangle/member/repository/MemberRepository.java b/src/main/java/com/bbangle/bbangle/member/repository/MemberRepository.java index 9a3cc1b3f..7fa640cb3 100644 --- a/src/main/java/com/bbangle/bbangle/member/repository/MemberRepository.java +++ b/src/main/java/com/bbangle/bbangle/member/repository/MemberRepository.java @@ -1,10 +1,19 @@ package com.bbangle.bbangle.member.repository; import com.bbangle.bbangle.member.domain.Member; +import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface MemberRepository extends JpaRepository, MemberQueryDSLRepository { + // 기본 배송지 변경 시 동시 insert 방지를 위한 직렬화 포인트 (Member 행 잠금) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT m FROM Member m WHERE m.id = :memberId") + Optional findByIdWithLock(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/bbangle/bbangle/order/customer/controller/CustomerOrderController.java b/src/main/java/com/bbangle/bbangle/order/customer/controller/CustomerOrderController.java new file mode 100644 index 000000000..d71a2048d --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/controller/CustomerOrderController.java @@ -0,0 +1,46 @@ +package com.bbangle.bbangle.order.customer.controller; + +import com.bbangle.bbangle.common.dto.SingleResult; +import com.bbangle.bbangle.common.service.ResponseService; +import com.bbangle.bbangle.order.customer.dto.request.OrderCreateRequest; +import com.bbangle.bbangle.order.customer.dto.request.OrderPreviewRequest; +import com.bbangle.bbangle.order.customer.dto.response.OrderCreateResponse; +import com.bbangle.bbangle.order.customer.dto.response.OrderPreviewResponse; +import com.bbangle.bbangle.order.customer.service.CustomerOrderService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/customer/orders") +@RequiredArgsConstructor +public class CustomerOrderController { + + private final CustomerOrderService customerOrderService; + private final ResponseService responseService; + + @PostMapping("/preview") + @Operation(summary = "주문서 미리보기") + public SingleResult getOrderPreview( + @RequestBody @Valid OrderPreviewRequest request, + @AuthenticationPrincipal Long memberId + ) { + OrderPreviewResponse response = customerOrderService.getOrderPreview(memberId, request); + return responseService.getSingleResult(response); + } + + @PostMapping + @Operation(summary = "주문 생성") + public SingleResult createOrders( + @RequestBody @Valid OrderCreateRequest request, + @AuthenticationPrincipal Long memberId + ) { + OrderCreateResponse response = customerOrderService.createOrders(memberId, request); + return responseService.getSingleResult(response); + } +} diff --git a/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderCreateRequest.java b/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderCreateRequest.java new file mode 100644 index 000000000..940a81150 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderCreateRequest.java @@ -0,0 +1,22 @@ +package com.bbangle.bbangle.order.customer.dto.request; + +import com.bbangle.bbangle.payment.domain.PaymentMethod; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record OrderCreateRequest( + @NotEmpty @Valid List items, + Long deliveryAddressId, + String deliveryRequest, + @NotNull PaymentMethod paymentMethod +) { + + public record OrderProductItem( + @NotNull Long productId, + @NotNull @Min(1) Integer quantity + ) {} + +} diff --git a/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderPreviewRequest.java b/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderPreviewRequest.java new file mode 100644 index 000000000..61c5b28f0 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/dto/request/OrderPreviewRequest.java @@ -0,0 +1,18 @@ +package com.bbangle.bbangle.order.customer.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record OrderPreviewRequest( + @NotEmpty @Valid List items +) { + + public record OrderProductItem( + @NotNull Long productId, + @NotNull @Min(1) Integer quantity + ) {} + +} diff --git a/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderCreateResponse.java b/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderCreateResponse.java new file mode 100644 index 000000000..519e7911a --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderCreateResponse.java @@ -0,0 +1,8 @@ +package com.bbangle.bbangle.order.customer.dto.response; + +import java.util.List; + +public record OrderCreateResponse( + List orderIds, + String representativeOrderNumber +) {} diff --git a/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderPreviewResponse.java b/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderPreviewResponse.java new file mode 100644 index 000000000..391dbe338 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/dto/response/OrderPreviewResponse.java @@ -0,0 +1,41 @@ +package com.bbangle.bbangle.order.customer.dto.response; + +import java.util.List; + +public record OrderPreviewResponse( + List stores, + DeliveryAddressInfo deliveryAddress, + int totalProductAmount, + int totalDeliveryFee, + int totalPaymentAmount +) { + + public record StoreOrderGroup( + Long storeId, + String storeName, + List items, + int storeSubtotal, + int deliveryFee + ) {} + + public record OrderItemInfo( + Long productId, + String productName, + String thumbnailUrl, + int quantity, + int unitPrice, + int totalPrice + ) {} + + public record DeliveryAddressInfo( + Long id, + String addressName, + String recipientName, + String phone, + String address, + String addressDetail, + String zipCode, + boolean isDefault + ) {} + +} diff --git a/src/main/java/com/bbangle/bbangle/order/customer/service/CustomerOrderService.java b/src/main/java/com/bbangle/bbangle/order/customer/service/CustomerOrderService.java new file mode 100644 index 000000000..56661e127 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/order/customer/service/CustomerOrderService.java @@ -0,0 +1,407 @@ +package com.bbangle.bbangle.order.customer.service; + +import com.bbangle.bbangle.board.domain.Board; +import com.bbangle.bbangle.board.domain.DiscountType; +import com.bbangle.bbangle.board.domain.Product; +import com.bbangle.bbangle.board.domain.ProductImg; +import com.bbangle.bbangle.board.repository.ProductImgRepository; +import com.bbangle.bbangle.board.repository.ProductRepository; +import com.bbangle.bbangle.delivery.domain.Receiver; +import com.bbangle.bbangle.delivery.domain.Sender; +import com.bbangle.bbangle.delivery.domain.Shipping; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import com.bbangle.bbangle.member.repository.MemberDeliveryAddressRepository; +import com.bbangle.bbangle.member.repository.MemberRepository; +import com.bbangle.bbangle.order.customer.dto.request.OrderCreateRequest; +import com.bbangle.bbangle.order.customer.dto.request.OrderPreviewRequest; +import com.bbangle.bbangle.order.customer.dto.response.OrderCreateResponse; +import com.bbangle.bbangle.order.customer.dto.response.OrderPreviewResponse; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.domain.OrderDelivery; +import com.bbangle.bbangle.order.domain.OrderItem; +import com.bbangle.bbangle.order.domain.model.OrderDeliveryStatus; +import com.bbangle.bbangle.order.domain.model.OrderStatus; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.payment.domain.Payment; +import com.bbangle.bbangle.payment.domain.PaymentStatus; +import com.bbangle.bbangle.payment.repository.PaymentRepository; +import com.bbangle.bbangle.seller.domain.Seller; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import com.bbangle.bbangle.store.domain.Store; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomerOrderService { + + private static final DateTimeFormatter ORDER_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final MemberRepository memberRepository; + private final MemberDeliveryAddressRepository memberDeliveryAddressRepository; + private final ProductRepository productRepository; + private final ProductImgRepository productImgRepository; + private final SellerRepository sellerRepository; + private final OrderRepository orderRepository; + private final PaymentRepository paymentRepository; + + /** + * 주문서 미리보기 — 스토어별 상품 그룹, 배송비, 합계를 계산해 반환합니다. + */ + public OrderPreviewResponse getOrderPreview(Long memberId, OrderPreviewRequest request) { + // 요청 상품 ID → 수량 맵 + Map quantityByProductId = buildQuantityMap(request.items() + .stream() + .map(item -> new ProductQuantityEntry(item.productId(), item.quantity())) + .toList()); + + List products = fetchAndValidateProducts(new ArrayList<>(quantityByProductId.keySet())); + + // 썸네일 조회 (Board ID별 첫 번째 이미지) + List boardIds = products.stream() + .map(p -> p.getBoard().getId()) + .distinct() + .toList(); + Map thumbnailByBoardId = productImgRepository.findThumbnailsByBoardIdIn(boardIds) + .stream() + .collect(Collectors.toMap(pi -> pi.getBoard().getId(), ProductImg::getUrl, (a, b) -> a)); + + // 스토어별로 상품 그룹핑 + Map> productsByStoreId = products.stream() + .collect(Collectors.groupingBy(p -> p.getBoard().getStore().getId())); + + // 기본 배송지 조회 + MemberDeliveryAddress defaultAddress = memberDeliveryAddressRepository + .findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(memberId) + .orElse(null); + + List storeGroups = new ArrayList<>(); + int totalProductAmount = 0; + int totalDeliveryFee = 0; + + for (Map.Entry> entry : productsByStoreId.entrySet()) { + List storeProducts = entry.getValue(); + Store store = storeProducts.get(0).getBoard().getStore(); + + // Board별로 재그룹핑하여 배송비 계산 + Map> productsByBoardId = storeProducts.stream() + .collect(Collectors.groupingBy(p -> p.getBoard().getId())); + + List itemInfos = new ArrayList<>(); + int storeSubtotal = 0; + int storeDeliveryFee = 0; + + for (Map.Entry> boardEntry : productsByBoardId.entrySet()) { + List boardProducts = boardEntry.getValue(); + Board board = boardProducts.get(0).getBoard(); + int boardSubtotal = 0; + + for (Product product : boardProducts) { + int quantity = quantityByProductId.getOrDefault(product.getId(), 0); + int unitPrice = calcUnitPrice(product); + int totalPrice = unitPrice * quantity; + boardSubtotal += totalPrice; + + String thumbnail = thumbnailByBoardId.get(board.getId()); + itemInfos.add(new OrderPreviewResponse.OrderItemInfo( + product.getId(), + product.getTitle(), + thumbnail, + quantity, + unitPrice, + totalPrice + )); + } + + storeDeliveryFee += calcBoardDeliveryFee(board, boardSubtotal); + storeSubtotal += boardSubtotal; + } + + totalProductAmount += storeSubtotal; + totalDeliveryFee += storeDeliveryFee; + + storeGroups.add(new OrderPreviewResponse.StoreOrderGroup( + store.getId(), + store.getName(), + itemInfos, + storeSubtotal, + storeDeliveryFee + )); + } + + // 스토어 ID 기준으로 정렬하여 일관된 응답 순서 보장 + storeGroups.sort(Comparator.comparing(OrderPreviewResponse.StoreOrderGroup::storeId)); + + OrderPreviewResponse.DeliveryAddressInfo addressInfo = toDeliveryAddressInfo(defaultAddress); + + int totalPaymentAmount = totalProductAmount + totalDeliveryFee; + return new OrderPreviewResponse(storeGroups, addressInfo, totalProductAmount, totalDeliveryFee, totalPaymentAmount); + } + + /** + * 주문 생성 — 스토어별로 Order 엔티티를 각각 생성합니다. + */ + @Transactional + public OrderCreateResponse createOrders(Long memberId, OrderCreateRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER)); + + MemberDeliveryAddress address = resolveDeliveryAddress(memberId, request.deliveryAddressId()); + + Map quantityByProductId = buildQuantityMap(request.items() + .stream() + .map(item -> new ProductQuantityEntry(item.productId(), item.quantity())) + .toList()); + + List products = fetchAndValidateProducts(new ArrayList<>(quantityByProductId.keySet())); + + // 스토어별 상품 그룹핑 + Map> productsByStoreId = products.stream() + .collect(Collectors.groupingBy(p -> p.getBoard().getStore().getId())); + + // Seller 조회 (Store ID → Seller) + List storeIds = new ArrayList<>(productsByStoreId.keySet()); + Map sellerByStoreId = sellerRepository.findByStoreIdIn(storeIds) + .stream() + .collect(Collectors.toMap(s -> s.getStore().getId(), Function.identity())); + + Receiver receiver = Receiver.of( + address.getRecipientName(), + address.getPhone(), + null, + address.getAddress(), + address.getAddressDetail(), + address.getZipCode() + ); + + // 동일 결제 세션의 스토어별 주문들을 논리적으로 묶는 그룹 ID (30자 이내) + String orderGroupId = UUID.randomUUID().toString().replace("-", "").substring(0, 30); + + List createdOrders = new ArrayList<>(); + String representativeOrderNumber = null; + + for (Map.Entry> entry : productsByStoreId.entrySet()) { + Long storeId = entry.getKey(); + List storeProducts = entry.getValue(); + Store store = storeProducts.get(0).getBoard().getStore(); + + Seller seller = sellerByStoreId.get(storeId); + if (seller == null) { + throw new BbangleException(BbangleErrorCode.STORE_NOT_FOUND); + } + + // 1st pass: Board별 배송비 계산 + Map> productsByBoardId = storeProducts.stream() + .collect(Collectors.groupingBy(p -> p.getBoard().getId())); + + int storeDeliveryFee = 0; + int storeProductAmount = 0; + + for (Map.Entry> boardEntry : productsByBoardId.entrySet()) { + List boardProducts = boardEntry.getValue(); + Board board = boardProducts.get(0).getBoard(); + int boardSubtotal = 0; + + for (Product product : boardProducts) { + int quantity = quantityByProductId.getOrDefault(product.getId(), 0); + int unitPrice = calcUnitPrice(product); + boardSubtotal += unitPrice * quantity; + } + + storeDeliveryFee += calcBoardDeliveryFee(board, boardSubtotal); + storeProductAmount += boardSubtotal; + } + + String orderNumber = generateOrderNumber(); + + // 2nd pass: Order 엔티티 생성 (올바른 배송비/총액 포함) + Order order = Order.builder() + .orderNumber(orderNumber) + .orderGroupId(orderGroupId) + .orderDate(LocalDateTime.now()) + .buyerName(member.getName()) + .buyerPhone(member.getPhone()) + .buyerSubPhone(null) + .deliveryFee(storeDeliveryFee) + .totalAmount(storeProductAmount + storeDeliveryFee) + .member(member) + .seller(seller) + .build(); + + Sender sender = Sender.of( + store.getName(), + store.getPhoneNumberVO().getPhoneNumber(), + store.getOriginAddressLine(), + store.getOriginAddressDetail(), + "" + ); + + // OrderItem + OrderDelivery 생성 후 Order에 연결 + for (Product product : storeProducts) { + int quantity = quantityByProductId.getOrDefault(product.getId(), 0); + int unitPrice = calcUnitPrice(product); + int totalPrice = unitPrice * quantity; + + OrderItem orderItem = OrderItem.builder() + .quantity(quantity) + .productPrice(product.getPrice()) + .unitPrice(unitPrice) + .orderStatus(OrderStatus.PAYMENT_PENDING) + .orderDeliveryStatus(OrderDeliveryStatus.PREPARING) + .totalPrice(totalPrice) + .order(order) + .product(product) + .build(); + + OrderDelivery orderDelivery = OrderDelivery.create( + sender, + receiver, + Shipping.withMemo(request.deliveryRequest()), + OrderDeliveryStatus.PREPARING, + orderItem + ); + orderItem.addOrderDelivery(orderDelivery); + order.addOrderItem(orderItem); + } + + Order savedOrder = orderRepository.save(order); + + Payment payment = Payment.create( + savedOrder, + PaymentStatus.PENDING, + request.paymentMethod(), + null + ); + paymentRepository.save(payment); + + createdOrders.add(savedOrder); + + if (representativeOrderNumber == null) { + representativeOrderNumber = orderNumber; + } + } + + List orderIds = createdOrders.stream() + .map(Order::getId) + .toList(); + + return new OrderCreateResponse(orderIds, representativeOrderNumber); + } + + /** + * Board 단위 배송비를 계산합니다. 소계가 무료배송 조건 이상이면 0을 반환합니다. + */ + private int calcBoardDeliveryFee(Board board, int boardSubtotal) { + Integer boardDeliveryFee = board.getDeliveryFee(); + if (boardDeliveryFee == null) { + return 0; + } + Integer freeShippingCondition = board.getFreeShippingConditions(); + if (freeShippingCondition != null && boardSubtotal >= freeShippingCondition) { + return 0; + } + return boardDeliveryFee; + } + + /** + * 할인 유형에 따라 단가를 계산합니다. + * Product.price = Board.price + 추가금액(plusPrice) + */ + private int calcUnitPrice(Product product) { + Board board = product.getBoard(); + int productPrice = product.getPrice(); + DiscountType discountType = board.getDiscountType(); + + if (discountType == null) { + return productPrice; + } + + return switch (discountType) { + case RATE -> { + Integer rate = board.getDiscountRate(); + yield rate == null ? productPrice : productPrice * (100 - rate) / 100; + } + case AMOUNT -> Math.max(0, productPrice - board.getDiscountValue()); + }; + } + + /** + * 주문번호 생성: "ORD" + yyyyMMddHHmmss + UUID 앞 8자리 (총 25자, VARCHAR(30) 이내) + */ + private String generateOrderNumber() { + String datePart = LocalDateTime.now().format(ORDER_DATE_FORMAT); + String uuidPart = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + return "ORD" + datePart + uuidPart; + } + + /** + * 요청 상품을 조회하고 누락 상품이 있으면 예외를 던집니다. + */ + private List fetchAndValidateProducts(List productIds) { + List products = productRepository.findAllWithBoardAndStoreByIdIn(productIds); + if (products.size() != new HashSet<>(productIds).size()) { + throw new BbangleException(BbangleErrorCode.ORDER_PRODUCT_EMPTY); + } + return products; + } + + /** + * 배송지 ID가 주어지면 해당 배송지를, 없으면 기본 배송지를 반환합니다. + */ + private MemberDeliveryAddress resolveDeliveryAddress(Long memberId, Long deliveryAddressId) { + if (deliveryAddressId != null) { + return memberDeliveryAddressRepository.findById(deliveryAddressId) + .filter(a -> a.getMember().getId().equals(memberId) && !a.isDeleted()) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND)); + } + return memberDeliveryAddressRepository + .findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(memberId) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND)); + } + + /** + * 상품 ID → 수량 맵 생성 (동일 상품 중복 요청 시 수량 합산) + */ + private Map buildQuantityMap(List items) { + return items.stream() + .collect(Collectors.toMap( + ProductQuantityEntry::productId, + ProductQuantityEntry::quantity, + Integer::sum + )); + } + + private OrderPreviewResponse.DeliveryAddressInfo toDeliveryAddressInfo(MemberDeliveryAddress address) { + if (address == null) { + return null; + } + return new OrderPreviewResponse.DeliveryAddressInfo( + address.getId(), + address.getAddressName(), + address.getRecipientName(), + address.getPhone(), + address.getAddress(), + address.getAddressDetail(), + address.getZipCode(), + address.isDefault() + ); + } + + private record ProductQuantityEntry(Long productId, Integer quantity) {} + +} diff --git a/src/main/java/com/bbangle/bbangle/order/domain/Order.java b/src/main/java/com/bbangle/bbangle/order/domain/Order.java index f4f21d550..129d0c16a 100644 --- a/src/main/java/com/bbangle/bbangle/order/domain/Order.java +++ b/src/main/java/com/bbangle/bbangle/order/domain/Order.java @@ -40,6 +40,9 @@ public class Order extends BaseEntity { @Column(name = "order_number", columnDefinition = "VARCHAR(30)") private String orderNumber; + @Column(name = "order_group_id", length = 30, nullable = false) + private String orderGroupId; + @Column(name = "order_date") private LocalDateTime orderDate; diff --git a/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java b/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java index ff75a728c..421835e80 100644 --- a/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java +++ b/src/main/java/com/bbangle/bbangle/order/domain/OrderItem.java @@ -70,6 +70,16 @@ public class OrderItem extends BaseEntity { @OneToMany(mappedBy = "orderItem", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY) private List orderDeliveries = new ArrayList<>(); + /** + * 결제 승인 완료 시점에 PAYMENT_PENDING → PAYMENT_COMPLETED로 전환합니다. + */ + public void completePayment() { + if (this.orderStatus != OrderStatus.PAYMENT_PENDING) { + throw new BbangleException(BbangleErrorCode.INVALID_ORDER_STATUS_TRANSITION); + } + this.orderStatus = OrderStatus.PAYMENT_COMPLETED; + } + public boolean confirmOrder() { if (this.orderStatus != OrderStatus.PAYMENT_COMPLETED) { return false; diff --git a/src/main/java/com/bbangle/bbangle/order/domain/model/OrderStatus.java b/src/main/java/com/bbangle/bbangle/order/domain/model/OrderStatus.java index 7047181ee..f8f426340 100644 --- a/src/main/java/com/bbangle/bbangle/order/domain/model/OrderStatus.java +++ b/src/main/java/com/bbangle/bbangle/order/domain/model/OrderStatus.java @@ -13,6 +13,7 @@ public enum OrderStatus { // 주문관리 페이지에서 조회의 효율성을 위해 상태를 세분화함 // 일반 + PAYMENT_PENDING("결제대기"), PAYMENT_COMPLETED("결제완료"), ORDER_CONFIRMED("발주확인"), IN_PRODUCTION("상품제작중"), diff --git a/src/main/java/com/bbangle/bbangle/order/repository/OrderRepository.java b/src/main/java/com/bbangle/bbangle/order/repository/OrderRepository.java index a86024bb5..dc7f4d980 100644 --- a/src/main/java/com/bbangle/bbangle/order/repository/OrderRepository.java +++ b/src/main/java/com/bbangle/bbangle/order/repository/OrderRepository.java @@ -1,7 +1,13 @@ package com.bbangle.bbangle.order.repository; import com.bbangle.bbangle.order.domain.Order; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderRepository extends JpaRepository, OrderDSLRepository { + + Optional findByOrderNumber(String orderNumber); + + List findByOrderGroupId(String orderGroupId); } diff --git a/src/main/java/com/bbangle/bbangle/payment/client/PaymentClient.java b/src/main/java/com/bbangle/bbangle/payment/client/PaymentClient.java new file mode 100644 index 000000000..83a7b4566 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/client/PaymentClient.java @@ -0,0 +1,8 @@ +package com.bbangle.bbangle.payment.client; + +import com.bbangle.bbangle.payment.client.dto.PaymentConfirmResult; + +public interface PaymentClient { + + PaymentConfirmResult confirm(String paymentKey, String orderNumber, Long amount); +} diff --git a/src/main/java/com/bbangle/bbangle/payment/client/StubPaymentClient.java b/src/main/java/com/bbangle/bbangle/payment/client/StubPaymentClient.java new file mode 100644 index 000000000..3f56e7838 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/client/StubPaymentClient.java @@ -0,0 +1,19 @@ +package com.bbangle.bbangle.payment.client; + +import com.bbangle.bbangle.payment.client.dto.PaymentConfirmResult; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +// TossPayments 연동 전 스텁 — 항상 성공 반환. 실제 연동 시 TossPaymentClient로 교체 후 이 @Primary를 제거한다. +@Profile({"local", "test", "dev"}) +@Primary +@Component +public class StubPaymentClient implements PaymentClient { + + @Override + public PaymentConfirmResult confirm(String paymentKey, String orderNumber, Long amount) { + return new PaymentConfirmResult(paymentKey, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/bbangle/bbangle/payment/client/dto/PaymentConfirmResult.java b/src/main/java/com/bbangle/bbangle/payment/client/dto/PaymentConfirmResult.java new file mode 100644 index 000000000..0ca33dd12 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/client/dto/PaymentConfirmResult.java @@ -0,0 +1,7 @@ +package com.bbangle.bbangle.payment.client.dto; + +import java.time.LocalDateTime; + +public record PaymentConfirmResult(String paymentKey, LocalDateTime approvedAt) { + +} diff --git a/src/main/java/com/bbangle/bbangle/payment/customer/controller/CustomerPaymentController.java b/src/main/java/com/bbangle/bbangle/payment/customer/controller/CustomerPaymentController.java new file mode 100644 index 000000000..1790c63a6 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/customer/controller/CustomerPaymentController.java @@ -0,0 +1,29 @@ +package com.bbangle.bbangle.payment.customer.controller; + +import com.bbangle.bbangle.payment.customer.dto.request.PaymentConfirmRequest; +import com.bbangle.bbangle.payment.customer.dto.response.PaymentConfirmResponse; +import com.bbangle.bbangle.payment.customer.service.CustomerPaymentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/customer/payments") +@RequiredArgsConstructor +public class CustomerPaymentController { + + private final CustomerPaymentService customerPaymentService; + + @PostMapping("/confirm") + public ResponseEntity confirm( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody PaymentConfirmRequest request + ) { + return ResponseEntity.ok(customerPaymentService.confirm(memberId, request)); + } +} diff --git a/src/main/java/com/bbangle/bbangle/payment/customer/dto/request/PaymentConfirmRequest.java b/src/main/java/com/bbangle/bbangle/payment/customer/dto/request/PaymentConfirmRequest.java new file mode 100644 index 000000000..dd404168f --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/customer/dto/request/PaymentConfirmRequest.java @@ -0,0 +1,13 @@ +package com.bbangle.bbangle.payment.customer.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record PaymentConfirmRequest( + @NotBlank String paymentKey, + @NotBlank String orderNumber, + @NotNull @Positive Long amount +) { + +} diff --git a/src/main/java/com/bbangle/bbangle/payment/customer/dto/response/PaymentConfirmResponse.java b/src/main/java/com/bbangle/bbangle/payment/customer/dto/response/PaymentConfirmResponse.java new file mode 100644 index 000000000..3ac271b07 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/customer/dto/response/PaymentConfirmResponse.java @@ -0,0 +1,5 @@ +package com.bbangle.bbangle.payment.customer.dto.response; + +public record PaymentConfirmResponse(String orderNumber, String paymentStatus) { + +} diff --git a/src/main/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentService.java b/src/main/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentService.java new file mode 100644 index 000000000..210868319 --- /dev/null +++ b/src/main/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentService.java @@ -0,0 +1,76 @@ +package com.bbangle.bbangle.payment.customer.service; + +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.domain.OrderItem; +import com.bbangle.bbangle.payment.client.PaymentClient; +import com.bbangle.bbangle.payment.client.dto.PaymentConfirmResult; +import com.bbangle.bbangle.payment.customer.dto.request.PaymentConfirmRequest; +import com.bbangle.bbangle.payment.customer.dto.response.PaymentConfirmResponse; +import com.bbangle.bbangle.payment.domain.Payment; +import com.bbangle.bbangle.payment.domain.PaymentStatus; +import com.bbangle.bbangle.payment.repository.PaymentRepository; +import com.bbangle.bbangle.order.repository.OrderRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class CustomerPaymentService { + + private final OrderRepository orderRepository; + private final PaymentRepository paymentRepository; + private final PaymentClient paymentClient; + + public PaymentConfirmResponse confirm(Long memberId, PaymentConfirmRequest request) { + // 1. 대표 주문 조회 (프론트가 전달한 orderNumber) + Order representativeOrder = orderRepository.findByOrderNumber(request.orderNumber()) + .orElseThrow(() -> new BbangleException(BbangleErrorCode.ORDER_NOT_FOUND)); + + // 2. 주문 소유자 검증 + if (!representativeOrder.getMember().getId().equals(memberId)) { + throw new BbangleException(BbangleErrorCode.ORDER_ACCESS_DENIED); + } + + // 3. orderGroupId로 그룹 전체 Payment를 비관적 락으로 조회 (동시성 제어) + String orderGroupId = representativeOrder.getOrderGroupId(); + List payments = paymentRepository.findByOrderGroupIdWithLock(orderGroupId); + + if (payments.isEmpty()) { + throw new BbangleException(BbangleErrorCode.PAYMENT_NOT_FOUND); + } + + // 4. 모든 Payment가 PENDING 상태인지 검증 (CANCELED, REFUNDED, COMPLETED 재승인 차단) + payments.forEach(p -> { + if (p.getPaymentStatus() != PaymentStatus.PENDING) { + throw new BbangleException(BbangleErrorCode.PAYMENT_ALREADY_COMPLETED); + } + }); + + // 5. 금액 검증: 그룹 전체 Order.totalAmount 합계 vs 요청 금액 + long totalGroupAmount = payments.stream() + .mapToLong(p -> p.getOrder().getTotalAmount()) + .sum(); + if (!request.amount().equals(totalGroupAmount)) { + throw new BbangleException(BbangleErrorCode.PAYMENT_AMOUNT_MISMATCH); + } + + // 6. 외부 결제 승인 1회 (PG는 단일 결제 세션) + PaymentConfirmResult result = paymentClient.confirm( + request.paymentKey(), request.orderNumber(), request.amount() + ); + + // 7. 그룹 전체 Payment와 OrderItem 상태를 COMPLETED / PAYMENT_COMPLETED로 전환 + for (Payment payment : payments) { + payment.confirm(result.paymentKey(), result.approvedAt()); + List orderItems = payment.getOrder().getOrderItems(); + orderItems.forEach(OrderItem::completePayment); + } + + return new PaymentConfirmResponse(representativeOrder.getOrderNumber(), PaymentStatus.COMPLETED.name()); + } +} diff --git a/src/main/java/com/bbangle/bbangle/payment/domain/Payment.java b/src/main/java/com/bbangle/bbangle/payment/domain/Payment.java index 398c185b9..f3148817f 100644 --- a/src/main/java/com/bbangle/bbangle/payment/domain/Payment.java +++ b/src/main/java/com/bbangle/bbangle/payment/domain/Payment.java @@ -1,6 +1,8 @@ package com.bbangle.bbangle.payment.domain; import com.bbangle.bbangle.common.domain.BaseEntity; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; import com.bbangle.bbangle.order.domain.Order; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -43,6 +45,9 @@ public class Payment extends BaseEntity { private LocalDateTime paidAt; + @Column(name = "payment_key", length = 200) + private String paymentKey; + @Builder(access = AccessLevel.PRIVATE) private Payment(Order order, PaymentStatus paymentStatus, @@ -54,6 +59,22 @@ private Payment(Order order, this.paidAt = paidAt; } + public void confirm(String paymentKey, LocalDateTime approvedAt) { + if (this.paymentStatus != PaymentStatus.PENDING) { + throw new BbangleException(BbangleErrorCode.INVALID_ORDER_STATUS_TRANSITION); + } + this.paymentKey = paymentKey; + this.paidAt = approvedAt; + this.paymentStatus = PaymentStatus.COMPLETED; + } + + public void fail() { + if (this.paymentStatus != PaymentStatus.PENDING) { + throw new BbangleException(BbangleErrorCode.INVALID_ORDER_STATUS_TRANSITION); + } + this.paymentStatus = PaymentStatus.FAILED; + } + public static Payment create( Order order, PaymentStatus paymentStatus, diff --git a/src/main/java/com/bbangle/bbangle/payment/repository/PaymentRepository.java b/src/main/java/com/bbangle/bbangle/payment/repository/PaymentRepository.java index 2eebb4f62..29eb15c59 100644 --- a/src/main/java/com/bbangle/bbangle/payment/repository/PaymentRepository.java +++ b/src/main/java/com/bbangle/bbangle/payment/repository/PaymentRepository.java @@ -1,8 +1,19 @@ package com.bbangle.bbangle.payment.repository; import com.bbangle.bbangle.payment.domain.Payment; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PaymentRepository extends JpaRepository { + Optional findByOrderOrderNumber(String orderNumber); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT DISTINCT p FROM Payment p JOIN FETCH p.order o LEFT JOIN FETCH o.orderItems WHERE o.orderGroupId = :orderGroupId") + List findByOrderGroupIdWithLock(@Param("orderGroupId") String orderGroupId); } diff --git a/src/main/resources/flyway/V60__app.sql b/src/main/resources/flyway/V60__app.sql new file mode 100644 index 000000000..2626c6c67 --- /dev/null +++ b/src/main/resources/flyway/V60__app.sql @@ -0,0 +1,17 @@ +CREATE TABLE member_delivery_address ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + address_name VARCHAR(50) NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + recipient_name VARCHAR(100) NOT NULL, + phone VARCHAR(20) NOT NULL, + address VARCHAR(255) NOT NULL, + address_detail VARCHAR(255), + zip_code VARCHAR(10), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + modified_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + CONSTRAINT fk_mda_member FOREIGN KEY (member_id) REFERENCES member (id) +); + +CREATE INDEX idx_mda_member_id ON member_delivery_address (member_id); diff --git a/src/main/resources/flyway/V61__app.sql b/src/main/resources/flyway/V61__app.sql new file mode 100644 index 000000000..26f40eef7 --- /dev/null +++ b/src/main/resources/flyway/V61__app.sql @@ -0,0 +1 @@ +ALTER TABLE payment ADD COLUMN payment_key VARCHAR(200) NULL; diff --git a/src/main/resources/flyway/V62__app.sql b/src/main/resources/flyway/V62__app.sql new file mode 100644 index 000000000..4eb499058 --- /dev/null +++ b/src/main/resources/flyway/V62__app.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders ADD COLUMN order_group_id VARCHAR(30) NULL; +CREATE INDEX idx_order_group_id ON orders(order_group_id); diff --git a/src/main/resources/flyway/V63__app.sql b/src/main/resources/flyway/V63__app.sql new file mode 100644 index 000000000..f6759b6fa --- /dev/null +++ b/src/main/resources/flyway/V63__app.sql @@ -0,0 +1,5 @@ +-- order_group_id NOT NULL 제약 추가 +ALTER TABLE orders MODIFY order_group_id VARCHAR(30) NOT NULL; + +-- order_number UNIQUE 제약 추가 +ALTER TABLE orders ADD UNIQUE KEY uk_order_number (order_number); diff --git a/src/test/java/com/bbangle/bbangle/fixture/order/domain/OrderFixture.java b/src/test/java/com/bbangle/bbangle/fixture/order/domain/OrderFixture.java index 915a3db87..036054775 100644 --- a/src/test/java/com/bbangle/bbangle/fixture/order/domain/OrderFixture.java +++ b/src/test/java/com/bbangle/bbangle/fixture/order/domain/OrderFixture.java @@ -13,6 +13,7 @@ private OrderFixture() { public static Order.OrderBuilder defaultOrder() { return Order.builder() .orderNumber("ORDER-2025-01-01-00001") + .orderGroupId("test-group-id-12345678901") .orderDate(LocalDateTime.of(2025, 1, 1, 10, 0, 0)) .buyerName("홍길동") .buyerPhone("01012345678") diff --git a/src/test/java/com/bbangle/bbangle/fixture/payment/domain/PaymentFixture.java b/src/test/java/com/bbangle/bbangle/fixture/payment/domain/PaymentFixture.java index e69422d84..3fb1104cb 100644 --- a/src/test/java/com/bbangle/bbangle/fixture/payment/domain/PaymentFixture.java +++ b/src/test/java/com/bbangle/bbangle/fixture/payment/domain/PaymentFixture.java @@ -20,6 +20,10 @@ public static Payment createPaymentWithStatus(PaymentStatus status) { return createPaymentWithStatusAndMethod(null, status, PaymentMethod.CARD); } + public static Payment createPaymentWithStatus(Order order, PaymentStatus status) { + return createPaymentWithStatusAndMethod(order, status, PaymentMethod.CARD); + } + public static Payment createPaymentWithMethod(PaymentMethod method) { return createPaymentWithStatusAndMethod(null, PaymentStatus.COMPLETED, method); } diff --git a/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceIntegrationTest.java b/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceIntegrationTest.java new file mode 100644 index 000000000..5798db54e --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceIntegrationTest.java @@ -0,0 +1,408 @@ +package com.bbangle.bbangle.member.customer.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bbangle.bbangle.auth.oauth.OauthServerType; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.member.customer.controller.dto.request.DeliveryAddressSaveRequest; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import com.bbangle.bbangle.member.repository.MemberDeliveryAddressRepository; +import com.bbangle.bbangle.member.repository.MemberRepository; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@DisplayName("[통합 테스트] DeliveryAddressService") +@SpringBootTest +@ActiveProfiles("test") +class DeliveryAddressServiceIntegrationTest { + + @Autowired + private DeliveryAddressService deliveryAddressService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberDeliveryAddressRepository deliveryAddressRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + // ----------------------------------------------------------------------- + // Issue 1 (High): 기본 배송지 동시 생성 방지 — Member 행 잠금 동시성 검증 + // + // @Transactional 사용 불가: 비관적 잠금은 커밋 시점에 해제되므로 + // 클래스 레벨 @Transactional이 있으면 경합 자체가 발생하지 않는다. + // @AfterEach JDBC 직접 정리로 격리 보장. + // ----------------------------------------------------------------------- + @Nested + @DisplayName("기본 배송지 동시 설정 — Member 행 잠금 동시성 검증") + class ConcurrentDefaultAddressTest { + + private Member savedMember; + + @BeforeEach + void setUp() { + savedMember = memberRepository.save(Member.builder() + .name("동시성_테스트_회원") + .email("concurrent@test.com") + .provider(OauthServerType.KAKAO) + .providerId("concurrent-test-id") + .build()); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update( + "DELETE FROM member_delivery_address WHERE member_id = ?", + savedMember.getId() + ); + jdbcTemplate.update( + "DELETE FROM member WHERE id = ?", + savedMember.getId() + ); + } + + @Test + @DisplayName("N개 스레드가 동시에 isDefault=true로 배송지 추가해도 기본 배송지는 1개만 존재한다") + void concurrent_addDefaultAddress_onlyOneDefaultExists() throws InterruptedException { + int threadCount = 5; + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + List errors = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + Long memberId = savedMember.getId(); + + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + startGate.await(); + deliveryAddressService.addDeliveryAddress(memberId, + new DeliveryAddressSaveRequest( + "집" + idx, "홍길동", "010-0000-0000", + "서울시 강남구", "101호", "12345", true + )); + } catch (Exception e) { + errors.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + startGate.countDown(); + boolean finished = doneLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(finished).as("모든 스레드가 10초 내에 완료되어야 함").isTrue(); + assertThat(errors).as("동시 요청 중 예외 발생: %s", errors).isEmpty(); + + List addresses = + deliveryAddressRepository.findAllByMemberIdAndIsDeletedFalse(memberId); + long defaultCount = addresses.stream() + .filter(MemberDeliveryAddress::isDefault) + .count(); + + assertThat(addresses).as("배송지는 스레드 수만큼 생성되어야 함").hasSize(threadCount); + assertThat(defaultCount).as("기본 배송지는 정확히 1개여야 함").isEqualTo(1); + } + + @Test + @DisplayName("N개 스레드가 동시에 setDefaultDeliveryAddress를 호출해도 기본 배송지는 1개만 존재한다") + void concurrent_setDefaultDeliveryAddress_onlyOneDefaultExists() throws InterruptedException { + int threadCount = 5; + Long memberId = savedMember.getId(); + + // 기본 배송지 후보 N개 미리 생성 (isDefault=false) + List addressIds = new CopyOnWriteArrayList<>(); + for (int i = 0; i < threadCount; i++) { + MemberDeliveryAddress addr = deliveryAddressRepository.save( + MemberDeliveryAddress.builder() + .member(savedMember) + .addressName("집" + i) + .recipientName("홍길동") + .phone("010-0000-0000") + .address("서울시 강남구") + .addressDetail("101호") + .zipCode("12345") + .isDefault(false) + .build() + ); + addressIds.add(addr.getId()); + } + + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + List errors = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + final Long addressId = addressIds.get(i); + executor.submit(() -> { + try { + startGate.await(); + deliveryAddressService.setDefaultDeliveryAddress(memberId, addressId); + } catch (Exception e) { + errors.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + startGate.countDown(); + boolean finished = doneLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(finished).as("모든 스레드가 10초 내에 완료되어야 함").isTrue(); + assertThat(errors).as("동시 요청 중 예외 발생: %s", errors).isEmpty(); + + List addresses = + deliveryAddressRepository.findAllByMemberIdAndIsDeletedFalse(memberId); + long defaultCount = addresses.stream() + .filter(MemberDeliveryAddress::isDefault) + .count(); + + assertThat(defaultCount).as("기본 배송지는 정확히 1개여야 함").isEqualTo(1); + } + } + + // ----------------------------------------------------------------------- + // Issue 2 (Medium): isDefault null — DB 실제 상태 확인 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("isDefault null 처리 — DB 상태 검증") + class IsDefaultNullHandlingTest { + + @Test + @Transactional + @DisplayName("updateDeliveryAddress: isDefault=null이면 기존 기본 배송지 상태가 DB에 유지된다") + void update_withNullIsDefault_preservesExistingDefaultState() { + Member member = memberRepository.save(Member.builder() + .name("테스트_회원_null") + .email("null-default@test.com") + .provider(OauthServerType.KAKAO) + .providerId("null-default-id") + .build()); + + MemberDeliveryAddress defaultAddress = deliveryAddressRepository.save( + MemberDeliveryAddress.builder() + .member(member) + .addressName("집") + .recipientName("홍길동") + .phone("010-0000-0000") + .address("서울시 강남구") + .addressDetail("101호") + .zipCode("12345") + .isDefault(true) + .build() + ); + + // isDefault 필드 없이(null) 다른 필드만 수정 + deliveryAddressService.updateDeliveryAddress( + member.getId(), + defaultAddress.getId(), + new DeliveryAddressSaveRequest( + "집(수정)", "홍길순", "010-1111-2222", + "서울시 마포구", "202호", "00000", null + ) + ); + + MemberDeliveryAddress updated = + deliveryAddressRepository.findById(defaultAddress.getId()).orElseThrow(); + + // 다른 필드는 변경되고 isDefault 상태는 유지 + assertThat(updated.isDefault()).isTrue(); + assertThat(updated.getAddressName()).isEqualTo("집(수정)"); + assertThat(updated.getRecipientName()).isEqualTo("홍길순"); + } + + @Test + @Transactional + @DisplayName("addDeliveryAddress: isDefault=null이면 기본 배송지가 설정되지 않는다") + void add_withNullIsDefault_doesNotSetAsDefault() { + Member member = memberRepository.save(Member.builder() + .name("테스트_회원_null2") + .email("null-add@test.com") + .provider(OauthServerType.KAKAO) + .providerId("null-add-id") + .build()); + + deliveryAddressService.addDeliveryAddress( + member.getId(), + new DeliveryAddressSaveRequest( + "집", "홍길동", "010-0000-0000", + "서울시", "101호", "12345", null + ) + ); + + List addresses = + deliveryAddressRepository.findAllByMemberIdAndIsDeletedFalse(member.getId()); + + assertThat(addresses).hasSize(1); + assertThat(addresses.get(0).isDefault()).isFalse(); + } + + @Test + @Transactional + @DisplayName("updateDeliveryAddress: isDefault=false이면 기본 배송지가 DB에서 해제된다") + void update_withFalseIsDefault_unsetsDefaultInDb() { + Member member = memberRepository.save(Member.builder() + .name("테스트_회원_false") + .email("false-default@test.com") + .provider(OauthServerType.KAKAO) + .providerId("false-default-id") + .build()); + + MemberDeliveryAddress defaultAddress = deliveryAddressRepository.save( + MemberDeliveryAddress.builder() + .member(member) + .addressName("집") + .recipientName("홍길동") + .phone("010-0000-0000") + .address("서울시") + .addressDetail("101호") + .zipCode("12345") + .isDefault(true) + .build() + ); + + deliveryAddressService.updateDeliveryAddress( + member.getId(), + defaultAddress.getId(), + new DeliveryAddressSaveRequest( + "집", "홍길동", "010-0000-0000", + "서울시", "101호", "12345", false + ) + ); + + MemberDeliveryAddress updated = + deliveryAddressRepository.findById(defaultAddress.getId()).orElseThrow(); + + assertThat(updated.isDefault()).isFalse(); + } + } + + // ----------------------------------------------------------------------- + // Issue 3 (Low): ID 열거 방지 — 소유권 포함 단일 쿼리 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("배송지 ID 열거 방지 — 소유권 포함 조회 검증") + class IdEnumerationPreventionTest { + + @Test + @Transactional + @DisplayName("타인 소유 배송지 ID 접근 시 403이 아닌 404가 반환된다 (ID 존재 여부 비노출)") + void accessOtherMembersAddress_returns404NotForbidden() { + Member owner = memberRepository.save(Member.builder() + .name("소유자") + .email("owner@test.com") + .provider(OauthServerType.KAKAO) + .providerId("owner-id") + .build()); + + Member attacker = memberRepository.save(Member.builder() + .name("공격자") + .email("attacker@test.com") + .provider(OauthServerType.KAKAO) + .providerId("attacker-id") + .build()); + + MemberDeliveryAddress ownerAddress = deliveryAddressRepository.save( + MemberDeliveryAddress.builder() + .member(owner) + .addressName("집") + .recipientName("소유자") + .phone("010-0000-0000") + .address("서울시") + .addressDetail("101호") + .zipCode("12345") + .isDefault(false) + .build() + ); + + // 공격자가 소유자의 배송지 ID로 삭제 시도 → 403이 아닌 404 + assertThatThrownBy(() -> + deliveryAddressService.deleteDeliveryAddress(attacker.getId(), ownerAddress.getId())) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + + @Test + @Transactional + @DisplayName("타인 소유 배송지를 기본 배송지로 설정 시도해도 404가 반환된다") + void setDefaultOtherMembersAddress_returns404() { + Member owner = memberRepository.save(Member.builder() + .name("소유자2") + .email("owner2@test.com") + .provider(OauthServerType.KAKAO) + .providerId("owner2-id") + .build()); + + Member attacker = memberRepository.save(Member.builder() + .name("공격자2") + .email("attacker2@test.com") + .provider(OauthServerType.KAKAO) + .providerId("attacker2-id") + .build()); + + MemberDeliveryAddress ownerAddress = deliveryAddressRepository.save( + MemberDeliveryAddress.builder() + .member(owner) + .addressName("집") + .recipientName("소유자2") + .phone("010-0000-0000") + .address("서울시") + .addressDetail("101호") + .zipCode("12345") + .isDefault(false) + .build() + ); + + assertThatThrownBy(() -> + deliveryAddressService.setDefaultDeliveryAddress(attacker.getId(), ownerAddress.getId())) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + + @Test + @Transactional + @DisplayName("존재하지 않는 배송지 ID 접근 시 404가 반환된다") + void accessNonExistentAddress_returns404() { + Member member = memberRepository.save(Member.builder() + .name("테스트_회원_notfound") + .email("notfound@test.com") + .provider(OauthServerType.KAKAO) + .providerId("notfound-id") + .build()); + + Long nonExistentId = Long.MAX_VALUE; + + assertThatThrownBy(() -> + deliveryAddressService.deleteDeliveryAddress(member.getId(), nonExistentId)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceUnitTest.java b/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceUnitTest.java new file mode 100644 index 000000000..13af5248b --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/member/customer/service/DeliveryAddressServiceUnitTest.java @@ -0,0 +1,293 @@ +package com.bbangle.bbangle.member.customer.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.member.customer.controller.dto.request.DeliveryAddressSaveRequest; +import com.bbangle.bbangle.member.customer.controller.dto.response.DeliveryAddressResponse; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import com.bbangle.bbangle.member.repository.MemberDeliveryAddressRepository; +import com.bbangle.bbangle.member.repository.MemberRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("[단위 테스트] DeliveryAddressService") +@ExtendWith(MockitoExtension.class) +class DeliveryAddressServiceUnitTest { + + @Mock + private MemberDeliveryAddressRepository deliveryAddressRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private DeliveryAddressService deliveryAddressService; + + private static final Long MEMBER_ID = 1L; + private static final Long ADDRESS_ID = 10L; + + private Member member; + + @BeforeEach + void setUp() { + member = Member.builder() + .id(MEMBER_ID) + .name("홍길동") + .email("test@test.com") + .build(); + } + + private MemberDeliveryAddress buildAddress(boolean isDefault) { + MemberDeliveryAddress address = MemberDeliveryAddress.builder() + .member(member) + .addressName("집") + .isDefault(isDefault) + .recipientName("홍길동") + .phone("010-1234-5678") + .address("서울시 강남구") + .addressDetail("101호") + .zipCode("12345") + .build(); + ReflectionTestUtils.setField(address, "id", ADDRESS_ID); + return address; + } + + private DeliveryAddressSaveRequest buildRequest(Boolean isDefault) { + return new DeliveryAddressSaveRequest( + "집", "홍길동", "010-1234-5678", + "서울시 강남구", "101호", "12345", isDefault + ); + } + + // ----------------------------------------------------------------------- + // Issue 1 (High): 기본 배송지 동시 생성 방지 — Member 행 잠금 직렬화 검증 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("기본 배송지 동시 생성 방지 (Member 행 잠금)") + class MemberRowLockForDefaultAddress { + + @Test + @DisplayName("addDeliveryAddress: isDefault=true 요청 시 Member 행 잠금 획득 후 기존 기본 배송지 해제") + void addDeliveryAddress_isDefaultTrue_acquiresMemberRowLock() { + // Given + MemberDeliveryAddress existingDefault = buildAddress(true); + MemberDeliveryAddress newAddress = buildAddress(false); + + given(memberRepository.findByIdWithLock(MEMBER_ID)).willReturn(Optional.of(member)); + given(deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(MEMBER_ID)) + .willReturn(Optional.of(existingDefault)); + given(deliveryAddressRepository.save(any())).willReturn(newAddress); + + // When + deliveryAddressService.addDeliveryAddress(MEMBER_ID, buildRequest(true)); + + // Then — Member 행 잠금 메서드가 반드시 호출되어야 함 + then(memberRepository).should().findByIdWithLock(MEMBER_ID); + assertThat(existingDefault.isDefault()).isFalse(); // 기존 기본 배송지 해제 확인 + } + + @Test + @DisplayName("addDeliveryAddress: isDefault=false 요청 시 Member 행 잠금 미호출") + void addDeliveryAddress_isDefaultFalse_doesNotAcquireMemberLock() { + // Given + MemberDeliveryAddress newAddress = buildAddress(false); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(deliveryAddressRepository.save(any())).willReturn(newAddress); + + // When + deliveryAddressService.addDeliveryAddress(MEMBER_ID, buildRequest(false)); + + // Then — Member 행 잠금 메서드가 호출되지 않아야 함 + then(memberRepository).should(never()).findByIdWithLock(anyLong()); + } + + @Test + @DisplayName("setDefaultDeliveryAddress: 항상 Member 행 잠금 후 기존 기본 배송지 해제") + void setDefaultDeliveryAddress_alwaysAcquiresMemberRowLock() { + // Given + MemberDeliveryAddress target = buildAddress(false); + MemberDeliveryAddress existingDefault = buildAddress(true); + ReflectionTestUtils.setField(existingDefault, "id", 20L); + + given(memberRepository.findByIdWithLock(MEMBER_ID)).willReturn(Optional.of(member)); + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(target)); + given(deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(MEMBER_ID)) + .willReturn(Optional.of(existingDefault)); + + // When + deliveryAddressService.setDefaultDeliveryAddress(MEMBER_ID, ADDRESS_ID); + + // Then + then(memberRepository).should().findByIdWithLock(MEMBER_ID); + assertThat(existingDefault.isDefault()).isFalse(); + assertThat(target.isDefault()).isTrue(); + } + + @Test + @DisplayName("updateDeliveryAddress: isDefault=true 변경 시 Member 행 잠금 후 기존 기본 배송지 해제") + void updateDeliveryAddress_changeToDefault_acquiresMemberRowLock() { + // Given + MemberDeliveryAddress target = buildAddress(false); // 현재 비기본 + MemberDeliveryAddress existingDefault = buildAddress(true); + ReflectionTestUtils.setField(existingDefault, "id", 20L); + + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(target)); + given(memberRepository.findByIdWithLock(MEMBER_ID)).willReturn(Optional.of(member)); + given(deliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(MEMBER_ID)) + .willReturn(Optional.of(existingDefault)); + + // When + deliveryAddressService.updateDeliveryAddress(MEMBER_ID, ADDRESS_ID, buildRequest(true)); + + // Then + then(memberRepository).should().findByIdWithLock(MEMBER_ID); + assertThat(existingDefault.isDefault()).isFalse(); + assertThat(target.isDefault()).isTrue(); + } + } + + // ----------------------------------------------------------------------- + // Issue 2 (Medium): isDefault null 필드 누락 시 기존 상태 보존 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("isDefault null 처리 — 기존 기본 배송지 상태 보존") + class IsDefaultNullHandling { + + @Test + @DisplayName("addDeliveryAddress: isDefault=null이면 기본 배송지 아닌 주소 추가") + void addDeliveryAddress_isDefaultNull_treatedAsFalse() { + // Given + MemberDeliveryAddress newAddress = buildAddress(false); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(deliveryAddressRepository.save(any())).willReturn(newAddress); + + // When + DeliveryAddressResponse response = deliveryAddressService.addDeliveryAddress( + MEMBER_ID, buildRequest(null)); + + // Then — 비관적 잠금 미호출, 기본 배송지 아님 + then(memberRepository).should(never()).findByIdWithLock(anyLong()); + assertThat(response.isDefault()).isFalse(); + } + + @Test + @DisplayName("updateDeliveryAddress: isDefault=null이면 기존 기본 배송지 상태 유지 (잠금 미호출)") + void updateDeliveryAddress_isDefaultNull_preservesExistingDefaultState() { + // Given — 현재 기본 배송지인 주소 + MemberDeliveryAddress currentDefault = buildAddress(true); + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(currentDefault)); + + // When — isDefault 필드 없이 다른 필드만 수정 + deliveryAddressService.updateDeliveryAddress(MEMBER_ID, ADDRESS_ID, buildRequest(null)); + + // Then — 비관적 잠금 미호출, 기본 배송지 상태 유지 + then(memberRepository).should(never()).findByIdWithLock(anyLong()); + assertThat(currentDefault.isDefault()).isTrue(); + } + + @Test + @DisplayName("updateDeliveryAddress: isDefault=false이면 기본 배송지 해제") + void updateDeliveryAddress_isDefaultFalse_unsetsDefault() { + // Given — 현재 기본 배송지인 주소 + MemberDeliveryAddress currentDefault = buildAddress(true); + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(currentDefault)); + + // When + deliveryAddressService.updateDeliveryAddress(MEMBER_ID, ADDRESS_ID, buildRequest(false)); + + // Then + assertThat(currentDefault.isDefault()).isFalse(); + } + + @Test + @DisplayName("updateDeliveryAddress: 이미 기본 배송지인 주소에 isDefault=true 재요청 시 잠금 미호출") + void updateDeliveryAddress_alreadyDefault_noLockAcquired() { + // Given — 이미 기본 배송지 + MemberDeliveryAddress currentDefault = buildAddress(true); + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(currentDefault)); + + // When + deliveryAddressService.updateDeliveryAddress(MEMBER_ID, ADDRESS_ID, buildRequest(true)); + + // Then — 이미 기본이므로 잠금 불필요 + then(memberRepository).should(never()).findByIdWithLock(anyLong()); + assertThat(currentDefault.isDefault()).isTrue(); + } + } + + // ----------------------------------------------------------------------- + // Issue 3 (Low): 배송지 ID 열거 방지 — 소유권 포함 단일 쿼리 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("배송지 ID 열거 방지 — 소유권 포함 단일 조회") + class IdEnumerationPrevention { + + @Test + @DisplayName("존재하지 않는 배송지 접근 시 404 반환") + void deleteDeliveryAddress_addressNotFound_throws404() { + // Given + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.empty()); + + // When & Then — 404 예외 발생 + assertThatThrownBy(() -> + deliveryAddressService.deleteDeliveryAddress(MEMBER_ID, ADDRESS_ID)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + + @Test + @DisplayName("타인 소유 배송지 접근 시 403이 아닌 404 반환 (ID 열거 방지)") + void deleteDeliveryAddress_otherMembersAddress_throws404NotForbidden() { + // Given — 다른 memberId로 조회하면 empty (소유권 포함 쿼리) + Long anotherMemberId = 999L; + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, anotherMemberId)) + .willReturn(Optional.empty()); + + // When & Then — 403이 아닌 404 발생으로 ID 존재 여부 비노출 + assertThatThrownBy(() -> + deliveryAddressService.deleteDeliveryAddress(anotherMemberId, ADDRESS_ID)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + + @Test + @DisplayName("소유한 배송지 삭제 성공") + void deleteDeliveryAddress_ownedAddress_succeeds() { + // Given + MemberDeliveryAddress address = buildAddress(false); + given(deliveryAddressRepository.findByIdAndMemberIdAndIsDeletedFalse(ADDRESS_ID, MEMBER_ID)) + .willReturn(Optional.of(address)); + + // When + deliveryAddressService.deleteDeliveryAddress(MEMBER_ID, ADDRESS_ID); + + // Then — soft delete 처리 + assertThat(address.isDeleted()).isTrue(); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/order/customer/service/CustomerOrderServiceUnitTest.java b/src/test/java/com/bbangle/bbangle/order/customer/service/CustomerOrderServiceUnitTest.java new file mode 100644 index 000000000..0ceb3628e --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/order/customer/service/CustomerOrderServiceUnitTest.java @@ -0,0 +1,219 @@ +package com.bbangle.bbangle.order.customer.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; + +import com.bbangle.bbangle.board.domain.Board; +import com.bbangle.bbangle.board.domain.DiscountType; +import com.bbangle.bbangle.board.domain.Product; +import com.bbangle.bbangle.board.domain.SaleStatus; +import com.bbangle.bbangle.board.repository.ProductImgRepository; +import com.bbangle.bbangle.board.repository.ProductRepository; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.domain.MemberDeliveryAddress; +import com.bbangle.bbangle.member.repository.MemberDeliveryAddressRepository; +import com.bbangle.bbangle.member.repository.MemberRepository; +import com.bbangle.bbangle.order.customer.dto.request.OrderCreateRequest; +import com.bbangle.bbangle.order.customer.dto.request.OrderPreviewRequest; +import com.bbangle.bbangle.order.customer.dto.response.OrderPreviewResponse; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.payment.domain.PaymentMethod; +import com.bbangle.bbangle.payment.repository.PaymentRepository; +import com.bbangle.bbangle.seller.repository.SellerRepository; +import com.bbangle.bbangle.store.domain.Store; +import com.bbangle.bbangle.store.domain.model.EmailVO; +import com.bbangle.bbangle.store.domain.model.PhoneNumberVO; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("[단위 테스트] CustomerOrderService") +@ExtendWith(MockitoExtension.class) +class CustomerOrderServiceUnitTest { + + @Mock private MemberRepository memberRepository; + @Mock private MemberDeliveryAddressRepository memberDeliveryAddressRepository; + @Mock private ProductRepository productRepository; + @Mock private ProductImgRepository productImgRepository; + @Mock private SellerRepository sellerRepository; + @Mock private OrderRepository orderRepository; + @Mock private PaymentRepository paymentRepository; + + @InjectMocks + private CustomerOrderService customerOrderService; + + private static final Long MEMBER_ID = 1L; + + private Store buildStore(Long id) { + Store store = Store.builder() + .name("테스트 스토어") + .identifier("12345") + .profile("test.png") + .introduce("소개") + .phoneNumberVO(PhoneNumberVO.of("01012345678", "01099999999")) + .emailVO(EmailVO.of("test@test.com")) + .originAddressLine("서울") + .originAddressDetail("101동") + .build(); + ReflectionTestUtils.setField(store, "id", id); + return store; + } + + private Board buildBoard(Long boardId, Store store, DiscountType discountType, int price, + Integer discountRate, Integer deliveryFee, Integer freeShippingConditions) { + Board board = Board.builder() + .store(store) + .title("게시판") + .price(price) + .discountType(discountType) + .discountRate(discountRate) + .discountValue(0) + .deliveryFee(deliveryFee) + .saleStatus(SaleStatus.ON_SALE) + .products(new ArrayList<>()) + .productImgs(new ArrayList<>()) + .build(); + ReflectionTestUtils.setField(board, "id", boardId); + ReflectionTestUtils.setField(board, "freeShippingConditions", freeShippingConditions); + return board; + } + + private Product buildProduct(Long productId, Board board, int price) { + Product product = Product.builder() + .title("테스트 상품") + .board(board) + .price(price) + .build(); + ReflectionTestUtils.setField(product, "id", productId); + return product; + } + + // ----------------------------------------------------------------------- + // Edge 1: 상품 미존재 → ORDER_PRODUCT_EMPTY + // ----------------------------------------------------------------------- + @Nested + @DisplayName("상품 조회 실패 시 ORDER_PRODUCT_EMPTY 예외 발생") + class ProductNotFound { + + @Test + @DisplayName("요청한 상품 ID가 DB에 없으면 getOrderPreview에서 예외가 발생한다") + void getOrderPreview_productNotFound_throwsOrderProductEmpty() { + // 요청: 상품 ID 99L, DB에는 없음 + given(productRepository.findAllWithBoardAndStoreByIdIn(anyList())) + .willReturn(List.of()); // 빈 결과 → size mismatch + + OrderPreviewRequest request = new OrderPreviewRequest( + List.of(new OrderPreviewRequest.OrderProductItem(99L, 1)) + ); + + assertThatThrownBy(() -> customerOrderService.getOrderPreview(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.ORDER_PRODUCT_EMPTY); + } + } + + // ----------------------------------------------------------------------- + // Edge 2: 회원 미존재 → NOTFOUND_MEMBER + // ----------------------------------------------------------------------- + @Nested + @DisplayName("회원 미존재 시 NOTFOUND_MEMBER 예외 발생") + class MemberNotFound { + + @Test + @DisplayName("존재하지 않는 memberId로 createOrders 호출 시 예외가 발생한다") + void createOrders_memberNotFound_throwsNotFoundMember() { + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.empty()); + + OrderCreateRequest request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderProductItem(1L, 1)), + null, + null, + PaymentMethod.CARD + ); + + assertThatThrownBy(() -> customerOrderService.createOrders(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.NOTFOUND_MEMBER); + } + } + + // ----------------------------------------------------------------------- + // Edge 3: 배송지 미존재 → DELIVERY_ADDRESS_NOT_FOUND + // ----------------------------------------------------------------------- + @Nested + @DisplayName("배송지 미존재 시 DELIVERY_ADDRESS_NOT_FOUND 예외 발생") + class DeliveryAddressNotFound { + + @Test + @DisplayName("deliveryAddressId 미전달 + 기본 배송지 없으면 예외가 발생한다") + void createOrders_noDefaultAddress_throwsDeliveryAddressNotFound() { + Member member = Member.builder().name("홍길동").phone("010-1234-5678").build(); + given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(member)); + given(memberDeliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(MEMBER_ID)) + .willReturn(Optional.empty()); + + OrderCreateRequest request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderProductItem(1L, 1)), + null, // deliveryAddressId 없음 + null, + PaymentMethod.CARD + ); + + assertThatThrownBy(() -> customerOrderService.createOrders(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .extracting(e -> ((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.DELIVERY_ADDRESS_NOT_FOUND); + } + } + + // ----------------------------------------------------------------------- + // Success: RATE 할인 + 배송비 임계 미달 계산 검증 + // ----------------------------------------------------------------------- + @Nested + @DisplayName("getOrderPreview 성공 — 할인율·배송비 계산 검증") + class OrderPreviewSuccess { + + @Test + @DisplayName("RATE 10% 할인 상품 1개(수량2), 배송비 미달 시 배송비 포함 합계 반환") + void getOrderPreview_rateDiscount_calculatesCorrectTotals() { + // 상품가 10,000원, RATE 10% 할인 → 단가 9,000원 + // 수량 2 → 소계 18,000원, 무료배송 조건 30,000원 → 배송비 3,000원 부과 + Store store = buildStore(10L); + Board board = buildBoard(20L, store, DiscountType.RATE, 10_000, 10, 3_000, 30_000); + Product product = buildProduct(1L, board, 10_000); + + given(productRepository.findAllWithBoardAndStoreByIdIn(anyList())) + .willReturn(List.of(product)); + given(productImgRepository.findThumbnailsByBoardIdIn(anyList())) + .willReturn(List.of()); + given(memberDeliveryAddressRepository.findByMemberIdAndIsDefaultTrueAndIsDeletedFalse(MEMBER_ID)) + .willReturn(Optional.empty()); + + OrderPreviewRequest request = new OrderPreviewRequest( + List.of(new OrderPreviewRequest.OrderProductItem(1L, 2)) + ); + + OrderPreviewResponse response = customerOrderService.getOrderPreview(MEMBER_ID, request); + + assertThat(response.totalProductAmount()).isEqualTo(18_000); // 9,000 × 2 + assertThat(response.totalDeliveryFee()).isEqualTo(3_000); // 배송비 미달 + assertThat(response.totalPaymentAmount()).isEqualTo(21_000); // 18,000 + 3,000 + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceIntegrationTest.java b/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceIntegrationTest.java new file mode 100644 index 000000000..fdc182c65 --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceIntegrationTest.java @@ -0,0 +1,188 @@ +package com.bbangle.bbangle.payment.customer.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bbangle.bbangle.auth.oauth.OauthServerType; +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.fixture.order.domain.OrderFixture; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.member.repository.MemberRepository; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.payment.customer.dto.request.PaymentConfirmRequest; +import com.bbangle.bbangle.payment.customer.dto.response.PaymentConfirmResponse; +import com.bbangle.bbangle.payment.domain.Payment; +import com.bbangle.bbangle.payment.domain.PaymentMethod; +import com.bbangle.bbangle.payment.domain.PaymentStatus; +import com.bbangle.bbangle.payment.repository.PaymentRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@DisplayName("[통합 테스트] CustomerPaymentService") +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CustomerPaymentServiceIntegrationTest { + + @Autowired + private CustomerPaymentService customerPaymentService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @Autowired + private EntityManager em; + + private static final String ORDER_NUMBER = "ORD-PAYMENT-TEST-001"; + private static final String ORDER_GROUP_ID = "paytest001grp0000000000000001"; + private static final String PAYMENT_KEY = "toss_stub_key_abc123"; + private static final Long CORRECT_AMOUNT = 50000L; + + private Member member; + private Order order; + private Payment pendingPayment; + + @BeforeEach + void setUp() { + // 회원 저장 + member = memberRepository.save(Member.builder() + .name("결제테스터") + .email("payment-test@bbangle.com") + .provider(OauthServerType.KAKAO) + .providerId("payment-test-kakao-id") + .build()); + + // 주문 저장 (회원 연결, 금액 50000, orderGroupId 포함) + order = orderRepository.save( + OrderFixture.createOrderWithAmount(CORRECT_AMOUNT.intValue()) + .toBuilder() + .orderNumber(ORDER_NUMBER) + .orderGroupId(ORDER_GROUP_ID) + .member(member) + .build() + ); + + // PENDING 상태의 결제 저장 + pendingPayment = paymentRepository.save( + Payment.create(order, PaymentStatus.PENDING, PaymentMethod.CARD, null) + ); + + em.flush(); + em.clear(); + } + + @Nested + @DisplayName("결제 승인 — 성공") + class ConfirmSuccess { + + @Test + @DisplayName("올바른 요청이면 Payment 상태가 COMPLETED로 변경되고 paymentKey·paidAt이 저장된다") + void success() { + // given + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when + PaymentConfirmResponse response = customerPaymentService.confirm(member.getId(), request); + + em.flush(); + em.clear(); + + // then — 응답 검증 + assertThat(response.orderNumber()).isEqualTo(ORDER_NUMBER); + assertThat(response.paymentStatus()).isEqualTo(PaymentStatus.COMPLETED.name()); + + // DB 반영 검증 + Payment saved = paymentRepository.findByOrderOrderNumber(ORDER_NUMBER).orElseThrow(); + assertThat(saved.getPaymentStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(saved.getPaymentKey()).isEqualTo(PAYMENT_KEY); + assertThat(saved.getPaidAt()).isNotNull(); + } + } + + @Nested + @DisplayName("결제 승인 — 실패") + class ConfirmFail { + + @Test + @DisplayName("존재하지 않는 orderNumber이면 ORDER_NOT_FOUND 예외가 발생한다") + void orderNotFound() { + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, "WRONG-ORDER-NUMBER", CORRECT_AMOUNT + ); + + assertThatThrownBy(() -> customerPaymentService.confirm(member.getId(), request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.ORDER_NOT_FOUND)); + } + + @Test + @DisplayName("다른 회원의 주문이면 ORDER_ACCESS_DENIED 예외가 발생한다") + void accessDenied() { + Member otherMember = memberRepository.save(Member.builder() + .name("다른회원") + .email("other@bbangle.com") + .provider(OauthServerType.KAKAO) + .providerId("other-kakao-id") + .build()); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + assertThatThrownBy(() -> customerPaymentService.confirm(otherMember.getId(), request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.ORDER_ACCESS_DENIED)); + } + + @Test + @DisplayName("이미 COMPLETED인 결제에 재승인 요청하면 PAYMENT_ALREADY_COMPLETED 예외가 발생한다") + void alreadyCompleted() { + // PENDING → COMPLETED로 먼저 전환 + Payment payment = paymentRepository.findByOrderOrderNumber(ORDER_NUMBER).orElseThrow(); + payment.confirm(PAYMENT_KEY, java.time.LocalDateTime.now()); + em.flush(); + em.clear(); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + assertThatThrownBy(() -> customerPaymentService.confirm(member.getId(), request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_ALREADY_COMPLETED)); + } + + @Test + @DisplayName("요청 금액이 주문 총액과 다르면 PAYMENT_AMOUNT_MISMATCH 예외가 발생한다") + void amountMismatch() { + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + 1000L + ); + + assertThatThrownBy(() -> customerPaymentService.confirm(member.getId(), request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_AMOUNT_MISMATCH)); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceUnitTest.java b/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceUnitTest.java new file mode 100644 index 000000000..3990a9439 --- /dev/null +++ b/src/test/java/com/bbangle/bbangle/payment/customer/service/CustomerPaymentServiceUnitTest.java @@ -0,0 +1,275 @@ +package com.bbangle.bbangle.payment.customer.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.bbangle.bbangle.exception.BbangleErrorCode; +import com.bbangle.bbangle.exception.BbangleException; +import com.bbangle.bbangle.fixture.order.domain.OrderFixture; +import com.bbangle.bbangle.fixture.payment.domain.PaymentFixture; +import com.bbangle.bbangle.member.domain.Member; +import com.bbangle.bbangle.order.domain.Order; +import com.bbangle.bbangle.order.repository.OrderRepository; +import com.bbangle.bbangle.payment.client.PaymentClient; +import com.bbangle.bbangle.payment.client.dto.PaymentConfirmResult; +import com.bbangle.bbangle.payment.customer.dto.request.PaymentConfirmRequest; +import com.bbangle.bbangle.payment.customer.dto.response.PaymentConfirmResponse; +import com.bbangle.bbangle.payment.domain.Payment; +import com.bbangle.bbangle.payment.domain.PaymentStatus; +import com.bbangle.bbangle.payment.repository.PaymentRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("[단위 테스트] CustomerPaymentService") +@ExtendWith(MockitoExtension.class) +class CustomerPaymentServiceUnitTest { + + @Mock + private OrderRepository orderRepository; + @Mock + private PaymentRepository paymentRepository; + @Mock + private PaymentClient paymentClient; + + @InjectMocks + private CustomerPaymentService customerPaymentService; + + private static final Long MEMBER_ID = 1L; + private static final Long OTHER_MEMBER_ID = 2L; + private static final String ORDER_NUMBER = "ORD20250101120000ABCD1234"; + private static final String ORDER_GROUP_ID = "groupid123456789012345678901"; + private static final String PAYMENT_KEY = "toss_pay_key_12345"; + private static final Long CORRECT_AMOUNT = 50000L; + + private Order order; + private Payment pendingPayment; + + @BeforeEach + void setUp() { + // totalAmount=50000, orderGroupId·orderNumber·member 세팅 + order = OrderFixture.createOrderWithAmount(CORRECT_AMOUNT.intValue()); + ReflectionTestUtils.setField(order, "orderNumber", ORDER_NUMBER); + ReflectionTestUtils.setField(order, "orderGroupId", ORDER_GROUP_ID); + + Member member = Member.builder().id(MEMBER_ID).name("테스터").build(); + ReflectionTestUtils.setField(order, "member", member); + + // Payment는 order와 연결 + pendingPayment = PaymentFixture.createPaymentWithStatus(order, PaymentStatus.PENDING); + } + + @Nested + @DisplayName("결제 승인 — 성공") + class ConfirmSuccess { + + @Test + @DisplayName("올바른 요청이면 Payment 상태가 COMPLETED로 변경된다") + void success() { + // given + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)) + .willReturn(List.of(pendingPayment)); + given(paymentClient.confirm(PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT)) + .willReturn(new PaymentConfirmResult(PAYMENT_KEY, LocalDateTime.now())); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when + PaymentConfirmResponse response = customerPaymentService.confirm(MEMBER_ID, request); + + // then + assertThat(response.orderNumber()).isEqualTo(ORDER_NUMBER); + assertThat(response.paymentStatus()).isEqualTo(PaymentStatus.COMPLETED.name()); + assertThat(pendingPayment.getPaymentStatus()).isEqualTo(PaymentStatus.COMPLETED); + } + } + + @Nested + @DisplayName("결제 승인 — 실패: 주문 없음") + class OrderNotFound { + + @Test + @DisplayName("존재하지 않는 orderNumber이면 ORDER_NOT_FOUND 예외가 발생한다") + void orderNotFound() { + // given + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.empty()); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.ORDER_NOT_FOUND)); + + verify(paymentRepository, never()).findByOrderGroupIdWithLock(anyString()); + verify(paymentClient, never()).confirm(anyString(), anyString(), anyLong()); + } + } + + @Nested + @DisplayName("결제 승인 — 실패: 접근 권한 없음") + class OrderAccessDenied { + + @Test + @DisplayName("다른 회원의 주문이면 ORDER_ACCESS_DENIED 예외가 발생한다") + void accessDenied() { + // given + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then — OTHER_MEMBER_ID로 접근 + assertThatThrownBy(() -> customerPaymentService.confirm(OTHER_MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.ORDER_ACCESS_DENIED)); + + verify(paymentRepository, never()).findByOrderGroupIdWithLock(anyString()); + } + } + + @Nested + @DisplayName("결제 승인 — 실패: 결제 정보 없음") + class PaymentNotFound { + + @Test + @DisplayName("그룹 내 결제 정보가 없으면 PAYMENT_NOT_FOUND 예외가 발생한다") + void paymentNotFound() { + // given + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)).willReturn(List.of()); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_NOT_FOUND)); + + verify(paymentClient, never()).confirm(anyString(), anyString(), anyLong()); + } + } + + @Nested + @DisplayName("결제 승인 — 실패: 이미 완료된 결제") + class PaymentAlreadyCompleted { + + @Test + @DisplayName("COMPLETED 상태 결제에 재승인 요청하면 PAYMENT_ALREADY_COMPLETED 예외가 발생한다") + void alreadyCompleted() { + // given + Payment completedPayment = PaymentFixture.createPaymentWithStatus(order, PaymentStatus.COMPLETED); + + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)) + .willReturn(List.of(completedPayment)); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_ALREADY_COMPLETED)); + + verify(paymentClient, never()).confirm(anyString(), anyString(), anyLong()); + } + + @Test + @DisplayName("CANCELED 상태 결제에 재승인 요청하면 PAYMENT_ALREADY_COMPLETED 예외가 발생한다") + void canceledPaymentRejected() { + // given + Payment canceledPayment = PaymentFixture.createPaymentWithStatus(order, PaymentStatus.CANCELED); + + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)) + .willReturn(List.of(canceledPayment)); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_ALREADY_COMPLETED)); + } + + @Test + @DisplayName("REFUNDED 상태 결제에 재승인 요청하면 PAYMENT_ALREADY_COMPLETED 예외가 발생한다") + void refundedPaymentRejected() { + // given + Payment refundedPayment = PaymentFixture.createPaymentWithStatus(order, PaymentStatus.REFUNDED); + + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)) + .willReturn(List.of(refundedPayment)); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, CORRECT_AMOUNT + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_ALREADY_COMPLETED)); + } + } + + @Nested + @DisplayName("결제 승인 — 실패: 금액 불일치") + class PaymentAmountMismatch { + + @Test + @DisplayName("요청 금액이 그룹 전체 주문 총액과 다르면 PAYMENT_AMOUNT_MISMATCH 예외가 발생한다") + void amountMismatch() { + // given + Long wrongAmount = CORRECT_AMOUNT + 1000L; + + given(orderRepository.findByOrderNumber(ORDER_NUMBER)).willReturn(Optional.of(order)); + given(paymentRepository.findByOrderGroupIdWithLock(ORDER_GROUP_ID)) + .willReturn(List.of(pendingPayment)); + + PaymentConfirmRequest request = new PaymentConfirmRequest( + PAYMENT_KEY, ORDER_NUMBER, wrongAmount + ); + + // when & then + assertThatThrownBy(() -> customerPaymentService.confirm(MEMBER_ID, request)) + .isInstanceOf(BbangleException.class) + .satisfies(e -> assertThat(((BbangleException) e).getBbangleErrorCode()) + .isEqualTo(BbangleErrorCode.PAYMENT_AMOUNT_MISMATCH)); + + verify(paymentClient, never()).confirm(anyString(), anyString(), anyLong()); + } + } +} diff --git a/src/test/java/com/bbangle/bbangle/settlement/seller/controller/SellerSettlementItemControllerIntegrationTest.java b/src/test/java/com/bbangle/bbangle/settlement/seller/controller/SellerSettlementItemControllerIntegrationTest.java index 5547dda26..e6fbc2876 100644 --- a/src/test/java/com/bbangle/bbangle/settlement/seller/controller/SellerSettlementItemControllerIntegrationTest.java +++ b/src/test/java/com/bbangle/bbangle/settlement/seller/controller/SellerSettlementItemControllerIntegrationTest.java @@ -39,10 +39,10 @@ "'BANK_TRANSFER', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 9101, 'DS-2002', 0.00, 0.00)", // 주문 등록 (seller_id 포함) - "INSERT INTO orders (id, order_number, order_date, buyer_name, buyer_phone, delivery_fee, total_amount, seller_id, created_at, modified_at) " + + "INSERT INTO orders (id, order_number, order_group_id, order_date, buyer_name, buyer_phone, delivery_fee, total_amount, seller_id, created_at, modified_at) " + "VALUES " + - "(3001, 'ORD-20260401-001', CURRENT_TIMESTAMP, '홍길동', '01011111111', 2500, 50000, 9101, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)," + - "(3002, 'ORD-20260405-002', CURRENT_TIMESTAMP, '김철수', '01022222222', 2500, 30000, 9101, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + "(3001, 'ORD-20260401-001', 'settle-test-grp-3001000000', CURRENT_TIMESTAMP, '홍길동', '01011111111', 2500, 50000, 9101, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)," + + "(3002, 'ORD-20260405-002', 'settle-test-grp-3002000000', CURRENT_TIMESTAMP, '김철수', '01022222222', 2500, 30000, 9101, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", // 상품 등록 (is_deleted, stock 포함) "INSERT INTO product (id, title, price, is_soldout, is_deleted, stock, monday, tuesday, wednesday, thursday, friday, saturday, sunday, created_at, modified_at) " + diff --git a/src/test/java/com/bbangle/bbangle/settlement/seller/excel/controller/SellerSettlementItemExcelIntegrationTest.java b/src/test/java/com/bbangle/bbangle/settlement/seller/excel/controller/SellerSettlementItemExcelIntegrationTest.java index 7da96d88e..5a05755e6 100644 --- a/src/test/java/com/bbangle/bbangle/settlement/seller/excel/controller/SellerSettlementItemExcelIntegrationTest.java +++ b/src/test/java/com/bbangle/bbangle/settlement/seller/excel/controller/SellerSettlementItemExcelIntegrationTest.java @@ -42,9 +42,9 @@ "'BANK_TRANSFER', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 9201, 'DS-7001', -1000.00, -1000.00)", // 주문 등록 - "INSERT INTO orders (id, order_number, order_date, buyer_name, buyer_phone, delivery_fee, total_amount, seller_id, created_at, modified_at) " + + "INSERT INTO orders (id, order_number, order_group_id, order_date, buyer_name, buyer_phone, delivery_fee, total_amount, seller_id, created_at, modified_at) " + "VALUES " + - "(8001, 'ORD-20260401-EXCEL', CURRENT_TIMESTAMP, '엑셀테스터', '01099999999', 2500, 50000, 9201, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + "(8001, 'ORD-20260401-EXCEL', 'settle-test-grp-8001000000', CURRENT_TIMESTAMP, '엑셀테스터', '01099999999', 2500, 50000, 9201, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", // 상품 등록 (is_deleted, stock 포함) "INSERT INTO product (id, title, price, is_soldout, is_deleted, stock, monday, tuesday, wednesday, thursday, friday, saturday, sunday, created_at, modified_at) " +