Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bb47b53
feat: 쿠폰 도메인 구현 (CRUD, 발급, 비관적 락 수량 관리)
juoklee Mar 5, 2026
10566b6
refactor: update() → updateInfo() 메서드 리네이밍 (코드 리뷰 반영)
juoklee Mar 5, 2026
53b8c87
feat: 주문-쿠폰 통합 + 트랜잭션 경계 정리
juoklee Mar 5, 2026
4007344
feat: 동시성 제어 + 동시성 E2E 테스트
juoklee Mar 5, 2026
a7588f2
feat: 기간제 쿠폰(validDays) 지원 — 개인별 만료일 관리
juoklee Mar 5, 2026
453728b
fix: enum valueOf() 안전 변환 처리 및 E2E 테스트 추가
juoklee Mar 6, 2026
ee5cab8
refactor: 내 쿠폰 조회 N+1 쿼리 제거 — 배치 조회로 변경
juoklee Mar 6, 2026
54676ad
fix: 브랜드 좋아요 증감 시 존재하지 않는 브랜드에 대한 NOT_FOUND 예외 처리
juoklee Mar 6, 2026
f8cf21f
fix: MemberCoupon 만료 시간 기반 사용 가능 여부 검증 추가
juoklee Mar 6, 2026
8f3b78a
fix: Order 생성 시 쿠폰 적용 금액 정합성 검증 추가
juoklee Mar 6, 2026
7bcf457
fix: 상품 수정 시 비관적 락 적용 및 좋아요 증감 NOT_FOUND 예외 처리
juoklee Mar 6, 2026
131421b
fix: 배송지 수정 시 비관적 락 적용으로 동시성 문제 방지
juoklee Mar 6, 2026
55d3677
test: 쿠폰 발급, 주문 취소, 좋아요 토글 등 동시성 E2E 테스트 추가
juoklee Mar 6, 2026
5cb3f15
fix: 상품 좋아요 증감 쿼리에 soft-delete 필터 추가
juoklee Mar 6, 2026
b392d59
fix: 비관적 락 타임아웃 설정 및 PessimisticLockingFailureException 핸들링 추가
juoklee Mar 6, 2026
b967fa5
fix: 재고 수정 시 비관적 락 적용으로 동시성 문제 방지
juoklee Mar 6, 2026
5d0ffaf
fix: 브랜드 좋아요 증감 쿼리에 soft-delete 필터 추가
juoklee Mar 6, 2026
203adbf
refactor: 주문 취소 시 중복 Order 조회 제거
juoklee Mar 6, 2026
6f3b81c
refactor: 동시성 E2E 테스트를 도메인별 파일로 분리
juoklee Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ public List<AddressInfo> getAddresses(String loginId) {
.toList();
}

public void update(String loginId, Long addressId, String label, String recipientName, String recipientPhone,
String zipCode, String address1, String address2) {
public void updateInfo(String loginId, Long addressId, String label, String recipientName, String recipientPhone,
String zipCode, String address1, String address2) {
Long memberId = getMemberId(loginId);
addressService.update(addressId, memberId, label, recipientName, recipientPhone,
addressService.updateInfo(addressId, memberId, label, recipientName, recipientPhone,
zipCode, address1, address2);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ public PagedInfo<BrandInfo> getBrands(String keyword, int page, int size) {
);
}

public void update(Long id, String name, String description) {
brandService.update(id, name, description);
public void updateInfo(Long id, String name, String description) {
brandService.updateInfo(id, name, description);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.loopers.application.coupon;

import com.loopers.application.PagedInfo;
import com.loopers.domain.PageResult;
import com.loopers.domain.coupon.Coupon;
import com.loopers.domain.coupon.CouponService;
import com.loopers.domain.coupon.CouponType;
import com.loopers.domain.coupon.MemberCoupon;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Component
public class CouponFacade {

private final MemberService memberService;
private final CouponService couponService;

// ── Admin ──

public CouponInfo createCoupon(String name, String type, Long value, Long minOrderAmount,
LocalDateTime expiredAt, Integer validDays, Integer totalQuantity) {
CouponType couponType;
try {
couponType = CouponType.valueOf(type);
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 쿠폰 타입입니다: " + type);
}
Coupon coupon = couponService.createCoupon(name, couponType, value, minOrderAmount, expiredAt, validDays, totalQuantity);
return CouponInfo.of(coupon);
}

public CouponInfo getCoupon(Long couponId) {
Coupon coupon = couponService.getCoupon(couponId);
return CouponInfo.of(coupon);
}

public PagedInfo<CouponInfo> getCoupons(int page, int size) {
PageResult<Coupon> result = couponService.getCoupons(page, size);
List<CouponInfo> infos = result.content().stream().map(CouponInfo::of).toList();
return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size());
}

public void updateCoupon(Long couponId, String name, Long value, Long minOrderAmount, LocalDateTime expiredAt, Integer validDays) {
couponService.updateCoupon(couponId, name, value, minOrderAmount, expiredAt, validDays);
}

public void deleteCoupon(Long couponId) {
couponService.deleteCoupon(couponId);
}

public PagedInfo<MemberCouponInfo> getIssuedCoupons(Long couponId, int page, int size) {
Coupon coupon = couponService.getCoupon(couponId);
CouponInfo couponInfo = CouponInfo.of(coupon);
PageResult<MemberCoupon> result = couponService.getIssuedCoupons(couponId, page, size);
List<MemberCouponInfo> infos = result.content().stream()
.map(mc -> MemberCouponInfo.of(mc, couponInfo))
.toList();
return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size());
}

// ── 대고객 ──

public MemberCouponInfo issueCoupon(String loginId, Long couponId) {
Long memberId = getMemberId(loginId);
MemberCoupon memberCoupon = couponService.issueCoupon(couponId, memberId);
Coupon coupon = couponService.getCoupon(couponId);
return MemberCouponInfo.of(memberCoupon, CouponInfo.of(coupon));
}

public PagedInfo<MemberCouponInfo> getMyCoupons(String loginId, int page, int size) {
Long memberId = getMemberId(loginId);
PageResult<MemberCoupon> result = couponService.getMemberCoupons(memberId, page, size);

List<Long> couponIds = result.content().stream().map(MemberCoupon::getCouponId).distinct().toList();
Map<Long, Coupon> couponMap = couponService.getCoupons(couponIds).stream()
.collect(Collectors.toMap(Coupon::getId, Function.identity()));

List<MemberCouponInfo> infos = result.content().stream()
.map(mc -> MemberCouponInfo.of(mc, CouponInfo.of(couponMap.get(mc.getCouponId()))))
.toList();
return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size());
}

private Long getMemberId(String loginId) {
Member member = memberService.getMemberByLoginId(loginId);
return member.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.Coupon;

import java.time.LocalDateTime;

public record CouponInfo(
Long id,
String name,
String type,
Long value,
Long minOrderAmount,
LocalDateTime expiredAt,
Integer validDays,
Integer totalQuantity,
int issuedQuantity
) {
public static CouponInfo of(Coupon coupon) {
return new CouponInfo(
coupon.getId(),
coupon.getName(),
coupon.getType().name(),
coupon.getValue(),
coupon.getMinOrderAmount(),
coupon.getExpiredAt(),
coupon.getValidDays(),
coupon.getTotalQuantity(),
coupon.getIssuedQuantity()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.MemberCoupon;

import java.time.LocalDateTime;

public record MemberCouponInfo(
Long id,
Long memberId,
Long couponId,
String status,
LocalDateTime usedAt,
LocalDateTime expiredAt,
CouponInfo coupon
) {
public static MemberCouponInfo of(MemberCoupon memberCoupon, CouponInfo couponInfo) {
return new MemberCouponInfo(
memberCoupon.getId(),
memberCoupon.getMemberId(),
memberCoupon.getCouponId(),
memberCoupon.getStatus().name(),
memberCoupon.getUsedAt(),
memberCoupon.getExpiredAt(),
couponInfo
);
}
Comment on lines +16 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "MemberCouponInfo.java" -o -name "*.java" -path "*/coupon/*" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1811


🏁 Script executed:

rg -n -B3 "MemberCouponInfo\.of\(" --type=java -A3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3012


🏁 Script executed:

# Check the actual MemberCouponInfo.java file
cat -n apps/commerce-api/src/main/java/com/loopers/application/coupon/MemberCouponInfo.java | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 985


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4676


🏁 Script executed:

# Check CouponInfo.of() for null handling
cat -n apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1050


🏁 Script executed:

# Check MemberCoupon and Coupon domain classes
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCoupon.java | head -60

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2358


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java | head -60

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2240


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4822


🏁 Script executed:

# Check CouponReader implementation
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponReader.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 439


🏁 Script executed:

# Check if there's error handling in CouponService
rg -n "getCoupon|issueCoupon" apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java -A 5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1607


🏁 Script executed:

# Check for potential null handling in CouponInfo.of and related code
rg -n "couponMap\.get|orElseThrow|Optional" apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java -B 2 -A 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 398


🏁 Script executed:

# Verify the getMyCoupons flow - check if all coupons in couponMap are guaranteed
cat -n apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java | sed -n '73,86p'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1005


🏁 Script executed:

# Check what happens when coupon is null
rg -n "getType|getId" apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java -B 1 -A 1

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


MemberCouponInfo.of() 및 CouponInfo.of()에서 null 입력에 대한 방어가 없다.

실제 null 위험은 CouponFacade.getMyCoupons() 라인 83에서 발생한다. couponMap.get(mc.getCouponId())은 참조된 쿠폰이 삭제된 경우 null을 반환할 수 있으며, 이를 CouponInfo.of()에 전달하면 NPE가 발생한다. 데이터 일관성 문제(고아 MemberCoupon)가 생기면 API 응답 실패로 이어진다.

다음을 고려해야 한다:

  1. CouponInfo.of()MemberCouponInfo.of()에서 null 입력 검증 추가
  2. 또는 getMyCoupons()에서 couponMap.get() 결과 null 체크 및 필터링
  3. 데이터 일관성 검증: 삭제된 쿠폰과 연결된 MemberCoupon을 주기적으로 정리하는 정책 수립
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/MemberCouponInfo.java`
around lines 16 - 26, MemberCouponInfo.of and CouponInfo.of are not defensive
against null inputs causing NPEs when a coupon is missing; add null guards:
update CouponInfo.of(Coupon coupon) to return null immediately if coupon is null
(or throw a clear IllegalArgumentException if you prefer explicit failure), and
update MemberCouponInfo.of(MemberCoupon memberCoupon, CouponInfo couponInfo) to
handle a null couponInfo (e.g., allow creating MemberCouponInfo with a null
couponInfo field instead of dereferencing it); additionally, in
CouponFacade.getMyCoupons where you call couponMap.get(mc.getCouponId()), filter
out or handle entries where couponMap.get(...) returns null (skip those
MemberCoupon entries or map them to MemberCouponInfo with null couponInfo) to
avoid passing null into CouponInfo.of and prevent NPEs.

}
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ public LikeToggleInfo toggleProductLike(String loginId, Long productId) {

boolean liked = likeService.toggleLike(memberId, LikeTargetType.PRODUCT, productId);

int likeCount = liked
? productService.increaseLikeCount(productId)
: productService.decreaseLikeCount(productId);

return new LikeToggleInfo(liked, likeCount);
if (liked) {
productService.increaseLikeCount(productId);
} else {
productService.decreaseLikeCount(productId);
}

Product product = productService.getProduct(productId);
return new LikeToggleInfo(liked, product.getLikeCount());
}

@Transactional
Expand All @@ -50,11 +53,14 @@ public LikeToggleInfo toggleBrandLike(String loginId, Long brandId) {

boolean liked = likeService.toggleLike(memberId, LikeTargetType.BRAND, brandId);

int likeCount = liked
? brandService.increaseLikeCount(brandId)
: brandService.decreaseLikeCount(brandId);
if (liked) {
brandService.increaseLikeCount(brandId);
} else {
brandService.decreaseLikeCount(brandId);
}

return new LikeToggleInfo(liked, likeCount);
Brand brand = brandService.getBrand(brandId);
return new LikeToggleInfo(liked, brand.getLikeCount());
Comment on lines +56 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Brand 토글에도 동일한 stale read 문제 존재

toggleProductLike와 동일하게 @Modifying 쿼리 후 brandService.getBrand(brandId) 호출 시 영속성 컨텍스트 캐시로 인한 stale 데이터 반환 가능성이 있다. 위에서 제안한 동일한 수정안을 적용해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java`
around lines 56 - 63, The call to brandService.getBrand(brandId) can return
stale data after the `@Modifying` increaseLikeCount/decreaseLikeCount update;
after invoking increaseLikeCount or decreaseLikeCount in LikeFacade, ensure the
persistence context is synchronized/cleared before reading back the Brand (e.g.,
call EntityManager.flush() and EntityManager.clear(), or use
EntityManager.refresh(brand) or a brandService method that flushes/clears) so
getBrand(brandId) reads the updated likeCount; update the LikeFacade flow around
the increaseLikeCount/decreaseLikeCount and getBrand calls to perform this
flush/clear/refresh.

}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.loopers.domain.member.Gender;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -22,7 +24,12 @@ public boolean authenticate(String loginId, String rawPassword) {

public MemberInfo register(String loginId, String rawPassword, String name,
LocalDate birthDate, String gender, String email, String phone) {
Gender genderEnum = Gender.valueOf(gender);
Gender genderEnum;
try {
genderEnum = Gender.valueOf(gender);
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 성별입니다: " + gender);
}
Comment on lines +27 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

gender가 null인 경우 NullPointerException이 발생하여 500 에러로 처리된다.

Gender.valueOf(null)IllegalArgumentException이 아닌 NullPointerException을 던진다. 현재 코드는 IllegalArgumentException만 catch하므로, null 입력 시 예상치 못한 500 에러가 반환된다.

🛡️ null 체크 추가 제안
 Gender genderEnum;
+if (gender == null) {
+    throw new CoreException(ErrorType.BAD_REQUEST, "성별은 필수 입력값입니다.");
+}
 try {
     genderEnum = Gender.valueOf(gender);
 } catch (IllegalArgumentException e) {
     throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 성별입니다: " + gender);
 }

운영 환경에서 null이 입력되는 경우, 로그에 NPE 스택트레이스가 남고 클라이언트는 명확하지 않은 500 에러를 받게 된다. null 케이스에 대한 E2E 테스트도 추가를 권장한다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Gender genderEnum;
try {
genderEnum = Gender.valueOf(gender);
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 성별입니다: " + gender);
}
Gender genderEnum;
if (gender == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "성별은 필수 입력값입니다.");
}
try {
genderEnum = Gender.valueOf(gender);
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 성별입니다: " + gender);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java`
around lines 27 - 32, The code calls Gender.valueOf(gender) without handling
null, causing a NullPointerException to escape; update the MemberFacade logic to
explicitly check if the input gender is null (or empty) before calling
Gender.valueOf and throw the same CoreException(ErrorType.BAD_REQUEST, "유효하지 않은
성별입니다: " + gender) for null/invalid values, or alternatively catch both
IllegalArgumentException and NullPointerException around the Gender.valueOf call
to convert them into that CoreException; also add an E2E/unit test that passes
null as gender to assert a BAD_REQUEST response.

Member member = memberService.register(loginId, rawPassword, name, birthDate, genderEnum, email, phone);
return MemberInfo.from(member);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.loopers.domain.PageResult;
import com.loopers.domain.address.Address;
import com.loopers.domain.address.AddressService;
import com.loopers.domain.coupon.CouponService;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberService;
import com.loopers.domain.order.Order;
Expand All @@ -16,6 +17,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -28,9 +30,11 @@ public class OrderFacade {
private final ProductService productService;
private final OrderService orderService;
private final AddressService addressService;
private final CouponService couponService;

@Transactional
public OrderInfo createOrder(String loginId, Long addressId, List<OrderItemRequest> itemRequests) {
public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId,
List<OrderItemRequest> itemRequests) {
Long memberId = getMemberId(loginId);

// 1. 배송지 검증 + 스냅샷
Expand All @@ -42,29 +46,45 @@ public OrderInfo createOrder(String loginId, Long addressId, List<OrderItemReque
.toList();
Map<Long, Integer> mergedItems = orderService.mergeOrderItems(serviceRequests);

// 3. 상품 검증 + 재고 차감 + totalAmount 계산
long totalAmount = 0L;
// 3. 상품 검증 + 재고 차감 + originalAmount 계산
long originalAmount = 0L;
List<OrderService.OrderItemCommand> commands = new java.util.ArrayList<>();

for (Map.Entry<Long, Integer> entry : mergedItems.entrySet()) {
List<Map.Entry<Long, Integer>> sortedEntries = mergedItems.entrySet().stream()
.sorted(Comparator.comparingLong(Map.Entry::getKey))
.toList();

for (Map.Entry<Long, Integer> entry : sortedEntries) {
Long productId = entry.getKey();
int quantity = entry.getValue();

Product product = productService.getProduct(productId);
Product product = productService.getProductForUpdate(productId);
product.validateOrderQuantity(quantity);
product.decreaseStock(quantity);

totalAmount += product.getPrice() * quantity;
originalAmount += product.getPrice() * quantity;
commands.add(new OrderService.OrderItemCommand(
productId, product.getName(), product.getPrice(), quantity
));
}

// 4. 주문 생성
Order order = orderService.createOrder(
memberId, address.getRecipientName(), address.getRecipientPhone(),
address.getZipCode(), address.getAddress1(), address.getAddress2(), totalAmount
);
// 4. 쿠폰 적용
Order order;
if (memberCouponId != null) {
CouponService.CouponApplyResult couponResult = couponService.useCoupon(memberCouponId, memberId, originalAmount);
long totalAmount = originalAmount - couponResult.discountAmount();

order = orderService.createOrder(
memberId, address.getRecipientName(), address.getRecipientPhone(),
address.getZipCode(), address.getAddress1(), address.getAddress2(),
totalAmount, couponResult.memberCouponId(), originalAmount, couponResult.discountAmount()
);
} else {
order = orderService.createOrder(
memberId, address.getRecipientName(), address.getRecipientPhone(),
address.getZipCode(), address.getAddress1(), address.getAddress2(), originalAmount
);
}

// 5. 주문 항목 생성
List<OrderItem> items = orderService.createOrderItems(order.getId(), commands);
Expand All @@ -77,52 +97,55 @@ public void cancelOrder(String loginId, Long orderId) {
Long memberId = getMemberId(loginId);

// 소유권 검증 + 취소 상태 체크 + 상태 전이 (도메인 규칙)
List<OrderItem> items = orderService.cancelOrder(orderId, memberId);
OrderService.CancelOrderResult result = orderService.cancelOrder(orderId, memberId);

// 재고 복원 (cross-domain orchestration) — productId 순 정렬로 데드락 방지
List<OrderItem> sortedItems = result.items().stream()
.sorted(Comparator.comparingLong(OrderItem::getProductId))
.toList();

// 재고 복원 (cross-domain orchestration)
for (OrderItem item : items) {
Product product = productService.getProduct(item.getProductId());
for (OrderItem item : sortedItems) {
Product product = productService.getProductForUpdate(item.getProductId());
product.increaseStock(item.getQuantity());
}

// 쿠폰 복원
if (result.order().getMemberCouponId() != null) {
couponService.restoreCoupon(result.order().getMemberCouponId());
}
}

@Transactional
public OrderInfo updateShippingAddress(String loginId, Long orderId,
String recipientName, String recipientPhone,
String zipCode, String address1, String address2) {
Long memberId = getMemberId(loginId);
Order order = orderService.getOrderForMember(orderId, memberId);

order.updateShippingAddress(recipientName, recipientPhone, zipCode, address1, address2);
Order order = orderService.updateShippingAddress(orderId, memberId,
recipientName, recipientPhone, zipCode, address1, address2);

List<OrderItem> items = orderService.getOrderItems(orderId);
return OrderInfo.of(order, items);
}

@Transactional(readOnly = true)
public OrderInfo getOrder(String loginId, Long orderId) {
Long memberId = getMemberId(loginId);
Order order = orderService.getOrderForMember(orderId, memberId);
List<OrderItem> items = orderService.getOrderItems(orderId);
return OrderInfo.of(order, items);
}

@Transactional(readOnly = true)
public PagedInfo<OrderSummaryInfo> getMyOrders(String loginId, LocalDate startAt, LocalDate endAt,
int page, int size) {
Long memberId = getMemberId(loginId);
PageResult<Order> result = orderService.getMyOrders(memberId, startAt, endAt, page, size);
return toPagedSummary(result);
}

@Transactional(readOnly = true)
public OrderInfo getOrderForAdmin(Long orderId) {
Order order = orderService.getOrder(orderId);
List<OrderItem> items = orderService.getOrderItems(orderId);
return OrderInfo.of(order, items);
}

@Transactional(readOnly = true)
public PagedInfo<OrderSummaryInfo> getOrdersForAdmin(Long memberId, int page, int size) {
PageResult<Order> result = orderService.getOrders(memberId, page, size);
return toPagedSummary(result);
Expand Down
Loading