*/
@Service
@RequiredArgsConstructor
@@ -44,7 +41,6 @@
public class CartFacade {
private final CartService cartService;
- private final UserService userService;
private final ProductService productService;
private final StockService stockService;
private final BrandService brandService;
@@ -55,13 +51,11 @@ public class CartFacade {
*
장바구니 항목에 상품, 브랜드, 재고 정보를 조합하여
* 주문 가능 여부와 불가 사유를 포함한 CartInfo 목록을 반환한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함)
*/
- public List getCart(String loginId, String loginPw) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return buildCartInfoList(cartService.getCartItems(user.getUserId()));
+ public List getCart(Long userId) {
+ return buildCartInfoList(cartService.getCartItems(userId));
}
/**
@@ -69,23 +63,18 @@ public List getCart(String loginId, String loginPw) {
*
*
상품 주문 가능 여부와 재고 초과 여부를 검증한 뒤 장바구니에 추가한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @param productId 추가할 상품 ID
* @param qty 수량
*/
@Transactional
- public void addItem(String loginId, String loginPw, String productId, int qty) {
- UserModel user = userService.authenticate(loginId, loginPw);
-
+ public void addItem(Long userId, Long productId, int qty) {
productService.findOrderableById(productId);
ProductStockModel stock = stockService.findByProductId(productId);
- if (stock.getAvailableQty() < qty) {
- throw new CoreException(ErrorType.CART_STOCK_EXCEEDED);
- }
+ stock.validateCanHold(qty);
- cartService.addItem(user.getUserId(), productId, qty);
+ cartService.addItem(userId, productId, qty);
}
/**
@@ -93,21 +82,27 @@ public void addItem(String loginId, String loginPw, String productId, int qty) {
*
*
재고 초과 여부를 검증한 뒤 수량을 변경한다.
*
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
+ * @param userId 사용자 ID
* @param productId 수량을 변경할 상품 ID
* @param newQty 변경할 새 수량
*/
@Transactional
- public void changeQuantity(String loginId, String loginPw, String productId, int newQty) {
- UserModel user = userService.authenticate(loginId, loginPw);
-
+ public void changeQuantity(Long userId, Long productId, int newQty) {
ProductStockModel stock = stockService.findByProductId(productId);
- if (stock.getAvailableQty() < newQty) {
- throw new CoreException(ErrorType.CART_STOCK_EXCEEDED);
- }
+ stock.validateCanHold(newQty);
- cartService.changeQuantity(user.getUserId(), productId, newQty);
+ cartService.changeQuantity(userId, productId, newQty);
+ }
+
+ /**
+ * 장바구니에서 상품을 삭제한다.
+ *
+ * @param userId 사용자 ID
+ * @param productId 삭제할 상품 ID
+ */
+ @Transactional
+ public void removeItem(Long userId, Long productId) {
+ cartService.removeItem(userId, productId);
}
/**
@@ -119,7 +114,7 @@ public void changeQuantity(String loginId, String loginPw, String productId, int
* @param userId 조회할 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록 (상품/브랜드/재고 정보 포함)
*/
- public List getCartForAdmin(String userId) {
+ public List getCartForAdmin(Long userId) {
return buildCartInfoList(cartService.getCartItems(userId));
}
@@ -132,18 +127,18 @@ private List buildCartInfoList(List items) {
return List.of();
}
- List productIds = items.stream()
+ List productIds = items.stream()
.map(CartItemModel::getProductId).distinct().toList();
- Map productMap = productService.findAllByIds(productIds)
+ Map productMap = productService.findAllByIds(productIds)
.stream().collect(Collectors.toMap(ProductModel::getProductId, Function.identity()));
- Set brandIds = productMap.values().stream()
+ Set brandIds = productMap.values().stream()
.map(ProductModel::getBrandId).collect(Collectors.toSet());
- Map brandMap = brandService.findAllByIds(brandIds)
+ Map brandMap = brandService.findAllByIds(brandIds)
.stream().collect(Collectors.toMap(BrandModel::getBrandId, Function.identity()));
- Map stockMap = stockService.findAllByProductIds(productIds)
+ Map stockMap = stockService.findAllByProductIds(productIds)
.stream().collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
List result = new ArrayList<>();
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
index 22fe99ac7..4fd575108 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java
@@ -20,14 +20,14 @@
@Getter
@Builder
public class CartInfo {
- private final String userId;
- private final String productId;
+ private final Long userId;
+ private final Long productId;
private final int quantity;
private final boolean available;
private final UnavailableReason unavailableReason;
private final String productName;
private final BigDecimal price;
- private final String brandId;
+ private final Long brandId;
private final String brandName;
private final String imageUrl;
private final int availableStock;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java
deleted file mode 100644
index e6844943d..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeAppService.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.loopers.application.like;
-
-import com.loopers.domain.like.LikeService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 좋아요 도메인 Application Service.
- * 사용자 인증과 좋아요 도메인 서비스를 조합하고 Model → Info 변환을 담당한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class LikeAppService {
-
- private final UserService userService;
- private final LikeService likeService;
-
- /**
- * 상품에 좋아요를 등록한다. 이미 좋아요한 경우 무시한다 (멱등).
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @param productId 상품 ID
- */
- @Transactional
- public void addLike(String loginId, String loginPw, String productId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- likeService.addLike(user.getUserId(), productId);
- }
-
- /**
- * 상품 좋아요를 취소한다. 좋아요가 없으면 무시한다 (멱등).
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @param productId 상품 ID
- */
- @Transactional
- public void removeLike(String loginId, String loginPw, String productId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- likeService.removeLike(user.getUserId(), productId);
- }
-
- /**
- * 특정 사용자의 좋아요 목록을 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @return 좋아요 정보 DTO 목록
- */
- public List getMyLikes(String loginId, String loginPw) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return likeService.getMyLikes(user.getUserId()).stream()
- .map(LikeInfo::from)
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
deleted file mode 100644
index bfae8c31e..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.loopers.application.like;
-
-import com.loopers.domain.like.LikeModel;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.LocalDateTime;
-
-/**
- * 좋아요 정보 DTO.
- * 도메인 모델({@link LikeModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다.
- */
-@Getter
-@Builder
-public class LikeInfo {
- private final String userId;
- private final String productId;
- private final LocalDateTime createdAt;
-
- /**
- * LikeModel을 LikeInfo DTO로 변환한다.
- *
- * @param model 변환할 좋아요 엔티티
- * @return 좋아요 정보 DTO
- */
- public static LikeInfo from(LikeModel model) {
- return LikeInfo.builder()
- .userId(model.getUserId())
- .productId(model.getProductId())
- .createdAt(model.getCreatedAt())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java
deleted file mode 100644
index 52f0bf151..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAppService.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.loopers.application.order;
-
-import com.loopers.domain.order.OrderItemModel;
-import com.loopers.domain.order.OrderModel;
-import com.loopers.domain.order.OrderService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 주문 도메인 Application Service.
- *
- *
단일 도메인 서비스(OrderService)만 호출하는 얇은 조회 메서드를 담당한다.
- * 고객용 메서드는 UserService 인증을 포함한다.
- * Model → Info 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class OrderAppService {
-
- private final OrderService orderService;
- private final UserService userService;
-
- /**
- * 주문 상세 정보를 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param orderId 조회할 주문 ID
- * @return 주문 상세 정보
- */
- public OrderInfo getOrderDetail(String loginId, String loginPw, String orderId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- OrderModel order = orderService.findByIdAndUserId(orderId, user.getUserId());
- List items = orderService.findOrderItems(order.getOrderId());
- return OrderInfo.from(order, items);
- }
-
- /**
- * 기간별 주문 목록을 조회한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param start 조회 시작 일시
- * @param end 조회 종료 일시
- * @return 조회 기간 내 주문 목록
- */
- public List getOrders(String loginId, String loginPw,
- LocalDateTime start, LocalDateTime end) {
- UserModel user = userService.authenticate(loginId, loginPw);
- List orders = orderService.findAllByUserId(user.getUserId(), start, end);
- return toOrderInfoList(orders);
- }
-
- /**
- * 주문 ID로 주문 상세를 조회한다 (관리자용).
- *
- * @param orderId 주문 ID
- * @return 주문 정보 DTO
- */
- public OrderInfo findOrderById(String orderId) {
- OrderModel order = orderService.findOrderById(orderId);
- List items = orderService.findOrderItems(order.getOrderId());
- return OrderInfo.from(order, items);
- }
-
- /**
- * 기간별 전체 주문 목록을 조회한다 (관리자용).
- *
- * @param start 조회 시작 일시
- * @param end 조회 종료 일시
- * @return 주문 정보 DTO 목록
- */
- public List findAllOrders(LocalDateTime start, LocalDateTime end) {
- List orders = orderService.findAllOrders(start, end);
- return toOrderInfoList(orders);
- }
-
- /**
- * 주문 목록을 배치 로딩으로 OrderInfo 목록으로 변환한다.
- * N+1 쿼리 대신 단일 IN 쿼리로 주문 항목을 일괄 조회한다.
- */
- private List toOrderInfoList(List orders) {
- if (orders.isEmpty()) {
- return List.of();
- }
- List orderIds = orders.stream().map(OrderModel::getOrderId).toList();
- Map> itemMap = orderService.findOrderItemsByOrderIds(orderIds);
- return orders.stream()
- .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index b165045ad..bfb60f321 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -3,6 +3,9 @@
import com.loopers.domain.brand.BrandModel;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.cart.CartService;
+import com.loopers.domain.coupon.CouponModel;
+import com.loopers.domain.coupon.CouponService;
+import com.loopers.domain.coupon.UserCouponModel;
import com.loopers.domain.order.OrderCartRestoreModel;
import com.loopers.domain.order.OrderItemCommand;
import com.loopers.domain.order.OrderItemModel;
@@ -12,8 +15,6 @@
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.StockService;
-import com.loopers.domain.user.UserModel;
-import com.loopers.domain.user.UserService;
import com.loopers.support.enums.OrderType;
import com.loopers.support.enums.RestoreReason;
import com.loopers.support.enums.RestoreTriggerSource;
@@ -23,24 +24,28 @@
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
/**
* 주문 Facade (퍼사드)
*
- *
*/
@Service
@RequiredArgsConstructor
@@ -48,62 +53,103 @@
public class OrderFacade {
private final OrderService orderService;
- private final UserService userService;
private final ProductService productService;
private final BrandService brandService;
private final StockService stockService;
private final CartService cartService;
+ private final CouponService couponService;
/**
* 직접 주문을 생성한다.
*
- *
상품 상세 페이지에서 바로 주문하는 DIRECT 주문 방식이다.
- * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며,
- * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param items 주문할 상품 항목 목록
+ * @param userId 사용자 ID
+ * @param items 주문할 상품 항목 목록
+ * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null)
* @return 생성된 주문 정보
*/
@Transactional
- public OrderInfo createDirectOrder(String loginId, String loginPw, List items) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return processOrder(user.getUserId(), OrderType.DIRECT, items);
+ public OrderInfo createDirectOrder(Long userId, List items, Long userCouponId) {
+ return processOrder(userId, OrderType.DIRECT, items, userCouponId);
}
/**
* 장바구니 주문을 생성한다.
*
- *
장바구니에서 선택한 상품들을 주문하는 CART 주문 방식이다.
- * 상품 검증 → 브랜드 조회 → 재고 hold → 주문 저장 순으로 진행하며,
- * 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param items 주문할 상품 항목 목록
+ * @param userId 사용자 ID
+ * @param items 주문할 상품 항목 목록
+ * @param userCouponId 적용할 발급 쿠폰 ID (없으면 null)
* @return 생성된 주문 정보
*/
@Transactional
- public OrderInfo createCartOrder(String loginId, String loginPw, List items) {
- UserModel user = userService.authenticate(loginId, loginPw);
- return processOrder(user.getUserId(), OrderType.CART, items);
+ public OrderInfo createCartOrder(Long userId, List items, Long userCouponId) {
+ return processOrder(userId, OrderType.CART, items, userCouponId);
+ }
+
+ /**
+ * 주문 목록을 조회한다 (기간 필터).
+ */
+ public List getOrders(Long userId, LocalDateTime start, LocalDateTime end) {
+ List orders = orderService.findAllByUserId(userId, start, end);
+ Map> itemMap = orderService.batchLoadOrderItems(orders);
+ return orders.stream()
+ .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
+ .toList();
+ }
+
+ /**
+ * 주문 상세 정보를 조회한다.
+ */
+ public OrderInfo getOrderDetail(Long userId, Long orderId) {
+ OrderModel order = orderService.findByIdAndUserId(orderId, userId);
+ List items = orderService.findOrderItems(order.getOrderId());
+ return OrderInfo.from(order, items);
+ }
+
+ /**
+ * 관리자용 전체 주문 목록을 조회한다 (기간 필터).
+ */
+ public List getOrdersForAdmin(LocalDateTime start, LocalDateTime end) {
+ List orders = orderService.findAllOrders(start, end);
+ Map> itemMap = orderService.batchLoadOrderItems(orders);
+ return orders.stream()
+ .map(order -> OrderInfo.from(order, itemMap.getOrDefault(order.getOrderId(), List.of())))
+ .toList();
+ }
+
+ /**
+ * 관리자용 주문 상세 정보를 조회한다.
+ */
+ public OrderInfo getOrderDetailForAdmin(Long orderId) {
+ OrderModel order = orderService.findOrderById(orderId);
+ List items = orderService.findOrderItems(order.getOrderId());
+ return OrderInfo.from(order, items);
}
/**
* 주문 생성 공통 로직.
*
- * 주문 항목 검증/병합 → 상품 조회 → 브랜드 조회 → 재고 hold → 스냅샷 생성 → 주문 저장.
+ * 쿠폰 검증 → 재고 hold → 할인 배분 → 주문 저장 → 쿠폰 사용 처리.
* 데드락 방지를 위해 productId 오름차순으로 재고 예약을 수행한다.
* 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
*
*/
- private OrderInfo processOrder(String userId, OrderType orderType, List items) {
+ private OrderInfo processOrder(Long userId, OrderType orderType, List items,
+ Long userCouponId) {
List merged = orderService.validateAndPrepare(userId, items);
- List snapshots = new ArrayList<>();
- List heldProductIds = new ArrayList<>();
- BigDecimal totalAmount = BigDecimal.ZERO;
+ // Step 1: 쿠폰 검증 (재고 hold 전에 수행 → 보상 복잡도 최소화)
+ UserCouponModel userCoupon = null;
+ CouponModel coupon = null;
+ if (userCouponId != null) {
+ userCoupon = couponService.validateAndGetUserCoupon(userId, userCouponId);
+ coupon = couponService.findByIdForAdmin(userCoupon.getCouponId());
+ }
+
+ // Step 2: 재고 hold + 스냅샷 생성 + 쿠폰 할인 계산
+ List rawSnapshots = new ArrayList<>();
+ List heldProductIds = new ArrayList<>();
+ BigDecimal totalOriginalAmount = BigDecimal.ZERO;
+ BigDecimal totalDiscount = BigDecimal.ZERO;
try {
for (OrderItemCommand item : merged) {
@@ -113,47 +159,87 @@ private OrderInfo processOrder(String userId, OrderType orderType, List finalSnapshots = applyDiscountProportionally(rawSnapshots, totalOriginalAmount,
+ totalDiscount);
+ BigDecimal totalFinalAmount = totalOriginalAmount.subtract(totalDiscount);
+
+ // Step 4: 주문 생성
+ OrderModel order = orderService.createOrder(userId, orderType, totalFinalAmount, finalSnapshots);
+
+ // Step 5: 쿠폰 사용 처리 (동일 트랜잭션)
+ if (userCoupon != null) {
+ couponService.markCouponAsUsed(userCoupon.getUserCouponId(), order.getOrderId());
+ }
+
List orderItems = orderService.findOrderItems(order.getOrderId());
return OrderInfo.from(order, orderItems);
}
+ /**
+ * 할인 금액을 주문 항목별 originalAmount 비율로 배분한다.
+ * 반올림 오차는 마지막 항목이 흡수한다.
+ */
+ private List applyDiscountProportionally(List rawSnapshots,
+ BigDecimal totalOriginalAmount,
+ BigDecimal totalDiscount) {
+ if (totalDiscount.compareTo(BigDecimal.ZERO) == 0) {
+ return rawSnapshots;
+ }
+
+ List result = new ArrayList<>();
+ BigDecimal allocatedDiscount = BigDecimal.ZERO;
+
+ for (int i = 0; i < rawSnapshots.size(); i++) {
+ OrderItemSnapshot raw = rawSnapshots.get(i);
+ boolean isLast = (i == rawSnapshots.size() - 1);
+ BigDecimal itemDiscount;
+ if (isLast) {
+ itemDiscount = totalDiscount.subtract(allocatedDiscount);
+ } else {
+ itemDiscount = totalDiscount
+ .multiply(raw.originalAmount())
+ .divide(totalOriginalAmount, 0, RoundingMode.FLOOR);
+ allocatedDiscount = allocatedDiscount.add(itemDiscount);
+ }
+
+ OrderItemSnapshot snapshotWithDiscount = new OrderItemSnapshot(
+ raw.productId(), raw.quantity(), raw.productName(),
+ raw.unitPrice(), raw.brandId(), raw.brandName(), raw.imageUrl(),
+ raw.originalAmount(), itemDiscount, raw.originalAmount().subtract(itemDiscount));
+ result.add(snapshotWithDiscount);
+ }
+ return result;
+ }
/**
* 주문을 취소한다.
- *
- *
CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 로그인 비밀번호
- * @param orderId 취소할 주문 ID
*/
@Transactional
- public void cancelOrder(String loginId, String loginPw, String orderId) {
- UserModel user = userService.authenticate(loginId, loginPw);
- Optional order = orderService.cancelOrder(user.getUserId(), orderId);
+ public void cancelOrder(Long userId, Long orderId) {
+ Optional order = orderService.cancelOrder(userId, orderId);
order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.USER_CANCELLED, RestoreTriggerSource.CANCEL_API));
}
/**
* 배치 스케줄러가 주문을 만료 처리한다.
- *
- *
CAS 상태 전이 후 재고를 해제하고, DIRECT 주문인 경우 장바구니를 복원한다.
- * 인증이 필요하지 않은 시스템 내부 호출용 메서드이다.
- *
- * @param orderId 주문 ID
*/
@Transactional
- public void expireOrder(String orderId) {
+ public void expireOrder(Long orderId) {
Optional order = orderService.expireOrder(orderId);
order.ifPresent(o -> releaseStocksAndRestore(o, RestoreReason.EXPIRED, RestoreTriggerSource.EXPIRE_JOB));
}
@@ -161,9 +247,9 @@ public void expireOrder(String orderId) {
/**
* 재고 hold 실패 시 이미 hold된 재고를 역순으로 release한다.
*/
- private void compensateHeldStocks(List heldProductIds, List merged) {
+ private void compensateHeldStocks(List heldProductIds, List merged) {
for (int i = heldProductIds.size() - 1; i >= 0; i--) {
- String heldProductId = heldProductIds.get(i);
+ Long heldProductId = heldProductIds.get(i);
int qty = merged.stream()
.filter(m -> m.productId().equals(heldProductId))
.findFirst().map(OrderItemCommand::quantity).orElse(0);
@@ -173,11 +259,6 @@ private void compensateHeldStocks(List heldProductIds, List
- * 주문 항목의 재고를 productId 오름차순으로 해제하고,
- * DIRECT 주문인 경우 장바구니 복원을 수행한다.
- * PK 충돌 시 이미 복원된 것으로 간주하여 skip한다 (멱등 보장).
- *
*/
private void releaseStocksAndRestore(OrderModel order, RestoreReason reason,
RestoreTriggerSource triggerSource) {
@@ -190,6 +271,9 @@ private void releaseStocksAndRestore(OrderModel order, RestoreReason reason,
stockService.release(item.getProductId(), item.getQuantity());
}
+ // 쿠폰 복원 (멱등)
+ couponService.restoreCoupon(order.getOrderId());
+
if (order.getOrderType() == OrderType.DIRECT) {
if (!orderService.existsCartRestore(order.getOrderId())) {
orderService.saveCartRestore(
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
index 029d03d2f..5e5f8a6b6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -22,8 +22,8 @@
@Getter
@Builder
public class OrderInfo {
- private final String orderId;
- private final String userId;
+ private final Long orderId;
+ private final Long userId;
private final OrderType orderType;
private final OrderStatus status;
private final BigDecimal totalAmount;
@@ -33,10 +33,6 @@ public class OrderInfo {
/**
* OrderModel과 OrderItemModel 목록을 조합하여 OrderInfo DTO로 변환한다.
- *
- * @param order 주문 엔티티
- * @param items 주문 항목 엔티티 목록 (null이면 빈 리스트)
- * @return 주문 정보 DTO (주문 항목 스냅샷 포함)
*/
public static OrderInfo from(OrderModel order, List items) {
return OrderInfo.builder()
@@ -55,29 +51,25 @@ public static OrderInfo from(OrderModel order, List items) {
/**
* 주문 항목 정보 DTO.
- *
- * 주문 시점의 상품 스냅샷 데이터(상품명, 단가, 브랜드, 이미지 등)를 포함하여
- * 주문 후 상품 정보가 변경되더라도 주문 당시 정보를 보존한다.
- *
*/
@Getter
@Builder
public static class OrderItemInfo {
- private final String orderId;
+ private final Long orderId;
private final int orderItemSeq;
- private final String productId;
+ private final Long productId;
private final int quantity;
private final String snapshotProductName;
private final BigDecimal snapshotUnitPrice;
private final String snapshotBrandId;
private final String snapshotBrandName;
private final String snapshotImageUrl;
+ private final BigDecimal originalAmount;
+ private final BigDecimal discountAmount;
+ private final BigDecimal finalAmount;
/**
* OrderItemModel을 OrderItemInfo DTO로 변환한다.
- *
- * @param item 주문 항목 엔티티
- * @return 주문 항목 정보 DTO (스냅샷 데이터 포함)
*/
public static OrderItemInfo from(OrderItemModel item) {
return OrderItemInfo.builder()
@@ -90,6 +82,9 @@ public static OrderItemInfo from(OrderItemModel item) {
.snapshotBrandId(item.getSnapshotBrandId())
.snapshotBrandName(item.getSnapshotBrandName())
.snapshotImageUrl(item.getSnapshotImageUrl())
+ .originalAmount(item.getOriginalAmount())
+ .discountAmount(item.getDiscountAmount())
+ .finalAmount(item.getFinalAmount())
.build();
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
deleted file mode 100644
index af296ee8c..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.ProductService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 상품 도메인 Application Service.
- *
- *
단일 도메인 서비스(ProductService)만 호출하는 얇은 메서드를 담당한다.
- * Model → Info 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class ProductAppService {
-
- private final ProductService productService;
-
- /**
- * 상품을 소프트 삭제한다.
- *
- * @param productId 삭제할 상품 ID
- */
- @Transactional
- public void deleteProduct(String productId) {
- productService.deleteProduct(productId);
- }
-
- /**
- * 상품의 변경 이력(Revision) 목록을 조회한다.
- *
- * @param productId 이력을 조회할 상품 ID
- * @return 상품 변경 이력 Info 목록
- */
- public List getRevisions(String productId) {
- return productService.findRevisionsByProductId(productId).stream()
- .map(ProductRevisionInfo::from)
- .toList();
- }
-
- /**
- * 특정 상품의 개별 변경 이력 상세를 조회한다.
- *
- * @param productId 상품 ID
- * @param revisionSeq 변경 순번
- * @return 변경 이력 Info
- */
- public ProductRevisionInfo getRevisionDetail(String productId, Long revisionSeq) {
- return ProductRevisionInfo.from(productService.findRevisionById(productId, revisionSeq));
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
index e8ac41a43..a7a6a3eb7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java
@@ -14,7 +14,7 @@
* @param description 상품 설명
* @param initialStock 초기 재고 수량
*/
-public record ProductCreateCommand(String productName, String brandId,
+public record ProductCreateCommand(String productName, Long brandId,
BigDecimal price, String description,
int initialStock) {
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
index 4cf8e5060..cf54f6d89 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -2,21 +2,20 @@
import com.loopers.domain.brand.BrandModel;
import com.loopers.domain.brand.BrandService;
-import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.ProductStockModel;
import com.loopers.domain.product.StockService;
import com.loopers.interfaces.api.PageResponse;
import com.loopers.support.enums.ProductSortType;
+import com.loopers.support.page.PageQuery;
+import com.loopers.support.page.PagedResult;
import lombok.RequiredArgsConstructor;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Sort;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -45,7 +44,6 @@ public class ProductFacade {
private final ProductService productService;
private final StockService stockService;
private final BrandService brandService;
- private final LikeService likeService;
/**
* 고객용 상품 목록을 정렬 + 페이징하여 조회한다.
@@ -60,24 +58,34 @@ public class ProductFacade {
* @param size 페이지 크기
* @return 페이징된 상품 정보 목록 (재고, 브랜드명, 좋아요 수 포함)
*/
- public PageResponse getProductsForCustomer(String keyword, String brandId,
+ public PageResponse getProductsForCustomer(String keyword, Long brandId,
ProductSortType sort, int page, int size) {
- if (sort == ProductSortType.LIKES_DESC) {
- return getProductsSortedByLikes(keyword, brandId, page, size);
+ if (keyword == null && page == 0 && size == 20) {
+ return getCachedProductList(brandId, sort, page, size);
}
+ return getProductsFromDb(keyword, brandId, sort, page, size);
+ }
+
+ @Cacheable(cacheNames = "productList",
+ key = "T(String).valueOf(#brandId) + ':' + #sort.name() + ':p' + #page + ':s' + #size")
+ public PageResponse getCachedProductList(Long brandId,
+ ProductSortType sort, int page, int size) {
+ return getProductsFromDb(null, brandId, sort, page, size);
+ }
- Sort dbSort = switch (sort) {
- case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt");
- case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price");
- default -> Sort.by(Sort.Direction.DESC, "createdAt");
+ private PageResponse getProductsFromDb(String keyword, Long brandId,
+ ProductSortType sort, int page, int size) {
+ PageQuery query = switch (sort) {
+ case LATEST -> new PageQuery(page, size, "createdAt", false);
+ case PRICE_ASC -> new PageQuery(page, size, "price", true);
+ case LIKES_DESC -> new PageQuery(page, size, "likeCount", false);
};
- Page productPage = productService.findAllForCustomer(keyword, brandId,
- PageRequest.of(page, size, dbSort));
- List enriched = enrichProducts(productPage.getContent());
+ PagedResult productPage = productService.findAllForCustomer(keyword, brandId, query);
+ List enriched = enrichProducts(productPage.content());
- return new PageResponse<>(enriched, productPage.getNumber(), productPage.getSize(),
- productPage.getTotalElements(), productPage.getTotalPages());
+ return new PageResponse<>(enriched, productPage.page(), productPage.size(),
+ productPage.totalElements(), productPage.totalPages());
}
/**
@@ -88,12 +96,11 @@ public PageResponse getProductsForCustomer(String keyword, String b
* @param productId 조회할 상품 ID
* @return 상품 상세 정보 (재고, 브랜드명, 좋아요 수 포함)
*/
- public ProductInfo getProductDetailForCustomer(String productId) {
+ public ProductInfo getProductDetailForCustomer(Long productId) {
ProductModel product = productService.findById(productId);
ProductStockModel stock = stockService.findByProductId(productId);
BrandModel brand = brandService.findById(product.getBrandId());
- long likeCount = likeService.countByProductId(productId);
- return ProductInfo.from(product, stock, brand.getBrandName(), likeCount);
+ return ProductInfo.from(product, stock, brand.getBrandName(), product.getLikeCount());
}
/**
@@ -106,11 +113,11 @@ public ProductInfo getProductDetailForCustomer(String productId) {
*/
public List getProductsForAdmin(boolean includeDeleted) {
List products = productService.findAllForAdmin(includeDeleted);
+ List productIds = products.stream().map(ProductModel::getProductId).toList();
+ Map stockMap = stockService.findAllByProductIds(productIds).stream()
+ .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
return products.stream()
- .map(product -> {
- ProductStockModel stock = stockService.findByProductId(product.getProductId());
- return ProductInfo.from(product, stock);
- })
+ .map(product -> ProductInfo.from(product, stockMap.get(product.getProductId())))
.toList();
}
@@ -122,6 +129,7 @@ public List getProductsForAdmin(boolean includeDeleted) {
* @param command 상품 생성 커맨드
* @return 생성된 상품 정보
*/
+ @CacheEvict(cacheNames = "productList", allEntries = true)
@Transactional
public ProductInfo createProduct(ProductCreateCommand command) {
brandService.findById(command.brandId());
@@ -139,6 +147,7 @@ public ProductInfo createProduct(ProductCreateCommand command) {
* @param command 상품 수정 커맨드
* @return 수정된 상품 정보
*/
+ @CacheEvict(cacheNames = "productList", allEntries = true)
@Transactional
public ProductInfo updateProduct(ProductUpdateCommand command) {
ProductModel product = productService.updateProduct(
@@ -149,48 +158,28 @@ public ProductInfo updateProduct(ProductUpdateCommand command) {
}
/**
- * 좋아요 수 기준 내림차순 정렬 + 수동 페이징. like count가 별도 테이블이므로 DB-level 정렬 불가.
- */
- private PageResponse getProductsSortedByLikes(String keyword, String brandId,
- int page, int size) {
- List allProducts = productService.findAllForCustomer(keyword, brandId);
- List enriched = enrichProducts(allProducts);
-
- List sorted = enriched.stream()
- .sorted(Comparator.comparingLong(ProductInfo::getLikeCount).reversed())
- .toList();
-
- int totalElements = sorted.size();
- int totalPages = (totalElements + size - 1) / size;
- int fromIndex = Math.min(page * size, totalElements);
- int toIndex = Math.min(fromIndex + size, totalElements);
- List pageContent = sorted.subList(fromIndex, toIndex);
-
- return new PageResponse<>(pageContent, page, size, totalElements, totalPages);
- }
-
- /**
- * 상품 목록에 재고, 브랜드명, 좋아요 수를 배치 조회하여 결합한다 (N+1 방지).
+ * 상품 목록에 재고, 브랜드명을 배치 조회하여 결합한다 (N+1 방지).
+ * likeCount는 ProductModel에서 직접 읽는다 (비정규화).
*/
private List enrichProducts(List products) {
if (products.isEmpty()) {
return List.of();
}
- List productIds = products.stream().map(ProductModel::getProductId).toList();
- List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList();
+ List productIds = products.stream().map(ProductModel::getProductId).toList();
+ List brandIds = products.stream().map(ProductModel::getBrandId).distinct().toList();
- Map brandMap = brandService.findAllByIds(brandIds).stream()
+ Map stockMap = stockService.findAllByProductIds(productIds).stream()
+ .collect(Collectors.toMap(ProductStockModel::getProductId, Function.identity()));
+ Map brandMap = brandService.findAllByIds(brandIds).stream()
.collect(Collectors.toMap(BrandModel::getBrandId, Function.identity()));
- Map likeCountMap = likeService.countByProductIds(productIds);
return products.stream()
.map(product -> {
- ProductStockModel stock = stockService.findByProductId(product.getProductId());
+ ProductStockModel stock = stockMap.get(product.getProductId());
BrandModel brand = brandMap.get(product.getBrandId());
String brandName = brand != null ? brand.getBrandName() : null;
- long likeCount = likeCountMap.getOrDefault(product.getProductId(), 0L);
- return ProductInfo.from(product, stock, brandName, likeCount);
+ return ProductInfo.from(product, stock, brandName, product.getLikeCount());
})
.toList();
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
index e50766ac2..c906abf04 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
@@ -20,8 +20,8 @@
@Getter
@Builder
public class ProductInfo {
- private final String productId;
- private final String brandId;
+ private final Long productId;
+ private final Long brandId;
private final String productName;
private final String description;
private final BigDecimal price;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java
deleted file mode 100644
index 3abcb23fa..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRevisionInfo.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.ProductRevisionModel;
-import com.loopers.support.enums.ProductRevisionAction;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.LocalDateTime;
-
-/**
- * 상품 변경 이력 정보 DTO.
- *
- * 도메인 모델({@link ProductRevisionModel})을 직접 노출하지 않고
- * interfaces 계층에 전달하기 위한 응답 객체이다.
- *
- */
-@Getter
-@Builder
-public class ProductRevisionInfo {
- private final String productId;
- private final Long revisionSeq;
- private final ProductRevisionAction action;
- private final String changedBy;
- private final String changeReason;
- private final String beforeSnapshot;
- private final String afterSnapshot;
- private final LocalDateTime createdAt;
-
- /**
- * ProductRevisionModel을 ProductRevisionInfo DTO로 변환한다.
- *
- * @param model 상품 변경 이력 엔티티
- * @return 변경 이력 정보 DTO
- */
- public static ProductRevisionInfo from(ProductRevisionModel model) {
- return ProductRevisionInfo.builder()
- .productId(model.getProductId())
- .revisionSeq(model.getRevisionSeq())
- .action(model.getAction())
- .changedBy(model.getChangedBy())
- .changeReason(model.getChangeReason())
- .beforeSnapshot(model.getBeforeSnapshot())
- .afterSnapshot(model.getAfterSnapshot())
- .createdAt(model.getCreatedAt())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
index 6b94398b1..ab90a13be 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java
@@ -14,7 +14,7 @@
* @param description 변경할 상품 설명
* @param imageUrl 변경할 이미지 URL
*/
-public record ProductUpdateCommand(String productId, String productName,
+public record ProductUpdateCommand(Long productId, String productName,
BigDecimal price, String description,
String imageUrl) {
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java
deleted file mode 100644
index 4d0bdc982..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsAppService.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.loopers.application.stats;
-
-import com.loopers.domain.stats.StatsService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDate;
-import java.util.List;
-
-/**
- * 운영 통계 Application Service.
- * 도메인 서비스를 호출하고 StatsProjection → StatsInfo 변환을 수행한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class StatsAppService {
-
- private final StatsService statsService;
-
- public StatsInfo.Overview getOverview(LocalDate startAt, LocalDate endAt) {
- return StatsInfo.Overview.from(statsService.getOverview(startAt, endAt));
- }
-
- public List getDailyOrderStats(LocalDate startAt, LocalDate endAt) {
- return statsService.getDailyOrderStats(startAt, endAt).stream()
- .map(StatsInfo.DailyOrderStat::from)
- .toList();
- }
-
- public List getTopLikedProducts(int limit) {
- return statsService.getTopLikedProducts(limit).stream()
- .map(StatsInfo.ProductStat::from)
- .toList();
- }
-
- public List getTopOrderedProducts(int limit) {
- return statsService.getTopOrderedProducts(limit).stream()
- .map(StatsInfo.ProductStat::from)
- .toList();
- }
-
- public List getLowStockProducts(int threshold) {
- return statsService.getLowStockProducts(threshold).stream()
- .map(StatsInfo.LowStockProduct::from)
- .toList();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java
deleted file mode 100644
index 955835776..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/stats/StatsInfo.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.loopers.application.stats;
-
-import com.loopers.domain.stats.StatsProjection;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.math.BigDecimal;
-import java.time.LocalDate;
-
-/**
- * 운영 통계 정보 DTO 모음.
- * 주문 현황 개요, 일별 주문 통계, 인기 상품, 저재고 상품 정보를 포함한다.
- */
-public class StatsInfo {
-
- /**
- * 주문 현황 개요 DTO.
- * 결제 대기, 취소, 만료 건수를 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class Overview {
- private final long pendingCount;
- private final long cancelledCount;
- private final long expiredCount;
-
- public static Overview from(StatsProjection.Overview projection) {
- return Overview.builder()
- .pendingCount(projection.getPendingCount())
- .cancelledCount(projection.getCancelledCount())
- .expiredCount(projection.getExpiredCount())
- .build();
- }
- }
-
- /**
- * 일별 주문 통계 DTO.
- * 특정 날짜의 주문 건수와 총 주문 금액을 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class DailyOrderStat {
- private final LocalDate date;
- private final long orderCount;
- private final BigDecimal totalAmount;
-
- public static DailyOrderStat from(StatsProjection.DailyOrderStat projection) {
- return DailyOrderStat.builder()
- .date(projection.getDate())
- .orderCount(projection.getOrderCount())
- .totalAmount(projection.getTotalAmount())
- .build();
- }
- }
-
- /**
- * 상품 통계 DTO.
- * 인기 좋아요 상품, 인기 주문 상품 등 상품별 집계 결과를 표현한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class ProductStat {
- private final String productId;
- private final String productName;
- private final long count;
-
- public static ProductStat from(StatsProjection.ProductStat projection) {
- return ProductStat.builder()
- .productId(projection.getProductId())
- .productName(projection.getProductName())
- .count(projection.getCount())
- .build();
- }
- }
-
- /**
- * 저재고 상품 DTO.
- * 가용 재고가 임계값 이하인 상품의 재고 현황을 포함한다.
- */
- @Getter
- @Builder
- @AllArgsConstructor
- public static class LowStockProduct {
- private final String productId;
- private final String productName;
- private final int onHand;
- private final int reserved;
- private final int availableQty;
-
- public static LowStockProduct from(StatsProjection.LowStockProduct projection) {
- return LowStockProduct.builder()
- .productId(projection.getProductId())
- .productName(projection.getProductName())
- .onHand(projection.getOnHand())
- .reserved(projection.getReserved())
- .availableQty(projection.getAvailableQty())
- .build();
- }
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java
deleted file mode 100644
index 96444aa99..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserAppService.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.loopers.application.user;
-
-import com.loopers.domain.user.UserRegisterCommand;
-import com.loopers.domain.user.UserService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * 사용자 도메인 Application Service.
- * 도메인 서비스를 호출하고 Model → Info 변환을 담당한다.
- */
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class UserAppService {
-
- private final UserService userService;
-
- /**
- * 회원가입을 수행한다.
- *
- * @param command 회원가입 커맨드
- * @return 생성된 사용자 정보
- */
- @Transactional
- public UserInfo register(UserRegisterCommand command) {
- return UserInfo.from(userService.register(command));
- }
-
- /**
- * 인증 후 본인 정보를 조회하여 UserInfo로 반환한다.
- *
- * @param loginId 로그인 ID
- * @param loginPw 비밀번호
- * @return 사용자 정보 DTO (마스킹된 이름 포함)
- */
- public UserInfo getMyInfo(String loginId, String loginPw) {
- return UserInfo.from(userService.authenticate(loginId, loginPw));
- }
-
- /**
- * 인증 헤더로 인증한 뒤 비밀번호를 변경한다.
- *
- * @param loginId 인증 헤더의 로그인 ID
- * @param loginPw 인증 헤더의 비밀번호
- * @param currentPw 현재 비밀번호 (body)
- * @param newPw 새 비밀번호 (body)
- */
- @Transactional
- public void changePassword(String loginId, String loginPw,
- String currentPw, String newPw) {
- userService.authenticateAndChangePassword(loginId, loginPw, currentPw, newPw);
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
deleted file mode 100644
index 7b8c49a3a..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.loopers.application.user;
-
-import com.loopers.domain.user.UserModel;
-import lombok.Builder;
-import lombok.Getter;
-
-/**
- * 사용자 정보 DTO.
- * 도메인 모델({@link UserModel})을 직접 노출하지 않고 인터페이스 레이어에 전달하기 위한 응답 객체이다.
- * 비밀번호를 제외하고 마스킹된 이름을 포함한다.
- */
-@Getter
-@Builder
-public class UserInfo {
- private final String userId;
- private final String loginId;
- private final String maskedName;
- private final String birthday;
- private final String email;
- private final String address;
-
- /**
- * UserModel을 UserInfo DTO로 변환한다. password를 제외하고 maskedName을 포함한다.
- *
- * @param user 변환할 사용자 엔티티
- * @return 사용자 정보 DTO
- */
- public static UserInfo from(UserModel user) {
- return UserInfo.builder()
- .userId(user.getUserId())
- .loginId(user.getLoginId())
- .maskedName(user.getMaskedName())
- .birthday(user.getBirthday())
- .email(user.getEmail())
- .address(user.getAddress())
- .build();
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
index 2e36ad88c..f4303b59e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/batch/OrderExpiryScheduler.java
@@ -28,13 +28,13 @@ public class OrderExpiryScheduler {
*/
@Scheduled(fixedDelay = 60000)
public void expireOrders() {
- List expiredOrderIds = orderService.findExpiredPendingOrderIds();
+ List expiredOrderIds = orderService.findExpiredPendingOrderIds();
if (expiredOrderIds.isEmpty()) {
return;
}
log.info("만료 대상 주문 {}건 처리 시작", expiredOrderIds.size());
int successCount = 0;
- for (String orderId : expiredOrderIds) {
+ for (Long orderId : expiredOrderIds) {
try {
orderFacade.expireOrder(orderId);
successCount++;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
index f6fe7cd95..39ce24388 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java
@@ -8,12 +8,13 @@
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
-import org.hibernate.annotations.UuidGenerator;
/**
* 브랜드 JPA 엔티티.
@@ -27,9 +28,9 @@
public class BrandModel extends BaseStringIdEntity {
@Id
- @UuidGenerator
- @Column(name = "brand_id", length = 36)
- private String brandId;
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "brand_id")
+ private Long brandId;
@Column(name = "brand_name", nullable = false)
private String brandName;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
index 76d279438..bc3b11ba2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -26,7 +26,7 @@ public interface BrandRepository {
* @param brandId 브랜드 ID
* @return 브랜드 (Optional)
*/
- Optional findById(String brandId);
+ Optional findById(Long brandId);
/**
* 전체 브랜드 목록을 조회한다 (관리자용, 삭제 포함).
@@ -58,5 +58,5 @@ public interface BrandRepository {
* @param brandIds 브랜드 ID 목록
* @return 해당 브랜드 목록
*/
- List findAllByIds(Collection brandIds);
+ List findAllByIds(Collection brandIds);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
index 10781f353..0370df605 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -1,9 +1,12 @@
package com.loopers.domain.brand;
+import com.loopers.domain.product.ProductService;
import com.loopers.support.enums.DisplayStatus;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +23,7 @@
public class BrandService {
private final BrandRepository brandRepository;
+ private final ProductService productService;
/**
* 새 브랜드를 등록한다.
@@ -57,11 +61,12 @@ public List findAllForAdmin() {
* @param brandIds 브랜드 ID 목록
* @return 브랜드 엔티티 목록
*/
- public List findAllByIds(Collection brandIds) {
+ public List findAllByIds(Collection brandIds) {
return brandRepository.findAllByIds(brandIds);
}
- public BrandModel findById(String brandId) {
+ @Cacheable(cacheNames = "brandDetail", key = "#brandId")
+ public BrandModel findById(Long brandId) {
return brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
}
@@ -73,7 +78,7 @@ public BrandModel findById(String brandId) {
* @return 브랜드 정보 DTO
* @throws CoreException 브랜드가 존재하지 않거나 비노출 상태일 때 (BRAND_NOT_FOUND)
*/
- public BrandModel findVisibleById(String brandId) {
+ public BrandModel findVisibleById(Long brandId) {
BrandModel brand = findById(brandId);
if (!brand.isVisibleForCustomer()) {
throw new CoreException(ErrorType.BRAND_NOT_FOUND);
@@ -104,23 +109,25 @@ public List findAllVisibleBrands(String keyword) {
* @return 수정된 브랜드 정보 DTO
* @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND)
*/
+ @CacheEvict(cacheNames = "brandDetail", key = "#brandId")
@Transactional
- public BrandModel updateBrand(String brandId, String brandName, String description, String address) {
+ public BrandModel updateBrand(Long brandId, String brandName, String description, String address) {
BrandModel brand = findById(brandId);
brand.updateInfo(brandName, description, address);
return brand;
}
/**
- * 브랜드를 소프트 삭제한다.
+ * 브랜드를 소프트 삭제한다. 소속 상품도 연쇄 소프트 삭제된다.
*
* @param brandId 삭제할 브랜드 ID
* @throws CoreException 브랜드가 존재하지 않을 때 (BRAND_NOT_FOUND)
- * @수정요망 : 브랜드 삭제시 브랜드의 상품들도 소프트 딜리트
*/
+ @CacheEvict(cacheNames = "brandDetail", key = "#brandId")
@Transactional
- public void deleteBrand(String brandId) {
+ public void deleteBrand(Long brandId) {
BrandModel brand = findById(brandId);
brand.softDelete();
+ productService.softDeleteByBrandId(brandId);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
index 94860f810..5850db315 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemId.java
@@ -20,6 +20,6 @@
@AllArgsConstructor
@EqualsAndHashCode
public class CartItemId implements Serializable {
- private String userId;
- private String productId;
+ private Long userId;
+ private Long productId;
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
index 7e545c338..b2c511330 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemModel.java
@@ -30,12 +30,12 @@
public class CartItemModel {
@Id
- @Column(name = "user_id", length = 36)
- private String userId;
+ @Column(name = "user_id")
+ private Long userId;
@Id
- @Column(name = "product_id", length = 36)
- private String productId;
+ @Column(name = "product_id")
+ private Long productId;
@Column(nullable = false)
private int quantity;
@@ -46,7 +46,7 @@ public class CartItemModel {
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
- private CartItemModel(String userId, String productId, int quantity) {
+ private CartItemModel(Long userId, Long productId, int quantity) {
validateQuantity(quantity);
this.userId = userId;
this.productId = productId;
@@ -63,7 +63,7 @@ private CartItemModel(String userId, String productId, int quantity) {
* @return 생성된 CartItemModel 인스턴스
* @throws CoreException quantity <= 0인 경우 (BAD_REQUEST)
*/
- public static CartItemModel create(String userId, String productId, int quantity) {
+ public static CartItemModel create(Long userId, Long productId, int quantity) {
return new CartItemModel(userId, productId, quantity);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
index a7f10739f..85f19b1de 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItemRepository.java
@@ -41,5 +41,5 @@ public interface CartItemRepository {
* @param userId 사용자 ID
* @return 해당 사용자의 장바구니 항목 목록
*/
- List findAllByUserId(String userId);
+ List findAllByUserId(Long userId);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
index e7cf9f76b..6e7a787a0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java
@@ -28,7 +28,7 @@ public class CartService {
* @param productId 상품 ID
* @param quantity 복원할 수량
*/
- public record RestoreItem(String productId, int quantity) {
+ public record RestoreItem(Long productId, int quantity) {
}
private final CartItemRepository cartItemRepository;
@@ -45,7 +45,7 @@ public record RestoreItem(String productId, int quantity) {
* @param qty 추가할 수량
*/
@Transactional
- public void addItem(String userId, String productId, int qty) {
+ public void addItem(Long userId, Long productId, int qty) {
CartItemId cartItemId = new CartItemId(userId, productId);
cartItemRepository.findById(cartItemId).ifPresentOrElse(
existingItem -> existingItem.mergeQuantity(qty),
@@ -62,7 +62,7 @@ public void addItem(String userId, String productId, int qty) {
* @throws CoreException 장바구니 항목이 존재하지 않을 때 (CART_ITEM_NOT_FOUND)
*/
@Transactional
- public void changeQuantity(String userId, String productId, int newQty) {
+ public void changeQuantity(Long userId, Long productId, int newQty) {
CartItemId cartItemId = new CartItemId(userId, productId);
CartItemModel item = cartItemRepository.findById(cartItemId)
.orElseThrow(() -> new CoreException(ErrorType.CART_ITEM_NOT_FOUND));
@@ -76,7 +76,7 @@ public void changeQuantity(String userId, String productId, int newQty) {
* @param productId 상품 ID
*/
@Transactional
- public void removeItem(String userId, String productId) {
+ public void removeItem(Long userId, Long productId) {
CartItemId cartItemId = new CartItemId(userId, productId);
cartItemRepository.findById(cartItemId).ifPresent(cartItemRepository::delete);
}
@@ -90,7 +90,7 @@ public void removeItem(String userId, String productId) {
* @param userId 사용자 ID
* @return 장바구니 항목 엔티티 목록
*/
- public List getCartItems(String userId) {
+ public List getCartItems(Long userId) {
return cartItemRepository.findAllByUserId(userId);
}
@@ -104,7 +104,7 @@ public List getCartItems(String userId) {
* @param items 복원할 항목 목록 (상품 ID + 수량)
*/
@Transactional
- public void restoreFromOrder(String userId, List items) {
+ public void restoreFromOrder(Long userId, List items) {
for (RestoreItem item : items) {
CartItemId cartItemId = new CartItemId(userId, item.productId());
cartItemRepository.findById(cartItemId).ifPresentOrElse(
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
new file mode 100644
index 000000000..3230f4b91
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
@@ -0,0 +1,130 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.domain.BaseStringIdEntity;
+import com.loopers.support.enums.DiscountType;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+
+/**
+ * 쿠폰 템플릿 JPA 엔티티.
+ *
+ * 고정 금액(FIXED) 또는 정률(RATE) 할인을 제공하는 쿠폰 템플릿이다.
+ * {@link BaseStringIdEntity}를 상속하여 소프트 삭제를 지원한다.
+ *