Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5f2b4dc
refactor: UUID BINARY(16) 식별자 체계로 전환
shAn-kor Mar 3, 2026
db7160c
feat: 쿠폰 어드민 API와 주문 유즈케이스를 통합한다
shAn-kor Mar 6, 2026
44f249d
test: Testcontainers 설정을 ImportTestcontainers 패턴으로 정리
shAn-kor Mar 3, 2026
0d45893
test: 상품 좋아요 동시성 테스트 추가
shAn-kor Mar 3, 2026
19ec48c
test: 상품 재고 차감 동시성 테스트 추가
shAn-kor Mar 4, 2026
920301e
test: 쿠폰 도메인 단위 테스트 추가
shAn-kor Mar 5, 2026
26ccff2
test: 쿠폰 애플리케이션·컨트롤러 통합 테스트 추가
shAn-kor Mar 5, 2026
e487c3f
test: 쿠폰 API E2E 테스트 추가
shAn-kor Mar 5, 2026
15ecbdf
test: 쿠폰 발급·사용 동시성 테스트 추가
shAn-kor Mar 5, 2026
d8ea265
test: 쿠폰·주문 취소 통합 시나리오를 보강한다
shAn-kor Mar 6, 2026
5ab62e0
test: application 서비스 테스트를 통합 테스트로 분리한다
shAn-kor Mar 6, 2026
071ee8b
docs: 주문·쿠폰 설계문서와 체크리스트 정합성 업데이트
shAn-kor Mar 5, 2026
f409f31
test: 재고 낙관락 재시도 지표를 업데이트한다
shAn-kor Mar 6, 2026
02b1fc9
docs: 재고 차감 동시성 ADR을 추가한다
shAn-kor Mar 6, 2026
04cb566
fix: 원자적 재고/좋아요 업데이트 경로와 주문 컨트롤러 의존성을 정합화한다
shAn-kor Mar 6, 2026
2ff8e60
fix: 주문-쿠폰 연계 계약을 memberId 기반으로 정합화한다
shAn-kor Mar 6, 2026
279df31
test: UUID/memberId 모델 변경에 맞춰 동시성·도메인 테스트를 정합화한다
shAn-kor Mar 6, 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 @@ -6,6 +6,7 @@
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.UUID;

@Component
@RequiredArgsConstructor
Expand All @@ -15,8 +16,8 @@ public class BrandAdminFacade {
private final ProductApplicationService productApplicationService;
private final LikeApplicationService likeApplicationService;

public void delete(Long brandId) {
List<Long> productIds = productApplicationService.findActiveProductIdsByBrandId(brandId);
public void delete(UUID brandId) {
List<UUID> productIds = productApplicationService.findActiveProductIdsByBrandId(brandId);
likeApplicationService.deleteByProductIds(productIds);
productApplicationService.deleteSoftByBrandId(brandId);
brandApplicationService.delete(brandId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.UUID;

@Service
@RequiredArgsConstructor
Expand All @@ -42,7 +43,7 @@ public Brand create(CreateBrandCommand command) {
}

@Transactional(readOnly = true)
public Brand findById(Long id) {
public Brand findById(UUID id) {
return brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
}
Expand All @@ -53,7 +54,7 @@ public Page<Brand> list(Pageable pageable) {
}

@Transactional(readOnly = true)
public Map<Long, String> findNamesByIds(Collection<Long> brandIds) {
public Map<UUID, String> findNamesByIds(Collection<UUID> brandIds) {
return brandIds.stream()
.distinct()
.collect(Collectors.toMap(
Expand All @@ -65,7 +66,7 @@ public Map<Long, String> findNamesByIds(Collection<Long> brandIds) {
}

@Transactional
public Brand update(Long id, UpdateBrandCommand command) {
public Brand update(UUID id, UpdateBrandCommand command) {
Brand brand = brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));

Expand All @@ -74,7 +75,7 @@ public Brand update(Long id, UpdateBrandCommand command) {
}

@Transactional
public void delete(Long id) {
public void delete(UUID id) {
Brand brand = brandRepository.findById(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
brandRepository.delete(brand);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.loopers.application.coupon;

import com.loopers.application.coupon.command.CreateCouponCommand;
import com.loopers.application.coupon.command.UpdateCouponCommand;
import com.loopers.application.coupon.view.CouponIssueView;
import com.loopers.domain.coupon.Coupon;
import com.loopers.domain.coupon.CouponRepository;
import com.loopers.domain.coupon.IssuedCouponRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Service
@RequiredArgsConstructor
public class CouponAdminApplicationService {

private final CouponRepository couponRepository;
private final IssuedCouponRepository issuedCouponRepository;

@Transactional(readOnly = true)
public Page<Coupon> list(Pageable pageable) {
return couponRepository.findAll(pageable);
}

@Transactional(readOnly = true)
public Coupon findById(UUID couponId) {
return couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다."));
}

@Transactional
public Coupon create(CreateCouponCommand command) {
Coupon coupon = new Coupon(
command.name(),
command.type(),
command.value(),
command.minOrderAmount(),
command.expiredAt()
);
return couponRepository.save(coupon);
}

@Transactional
public Coupon update(UUID couponId, UpdateCouponCommand command) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다."));
Coupon updated = coupon.update(
command.name(),
command.type(),
command.value(),
command.minOrderAmount(),
command.expiredAt()
);
return couponRepository.save(updated);
}

@Transactional
public void delete(UUID couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다."));
couponRepository.delete(coupon);
}

@Transactional(readOnly = true)
public Page<CouponIssueView> listIssues(UUID couponId, Pageable pageable) {
couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다."));
return issuedCouponRepository.findByCouponId(couponId, pageable).map(CouponIssueView::from);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.loopers.application.coupon;

import com.loopers.application.coupon.command.UseCouponCommand;
import com.loopers.application.coupon.view.MyCouponView;
import com.loopers.domain.coupon.Coupon;
import com.loopers.domain.coupon.CouponRepository;
import com.loopers.domain.coupon.CouponStatus;
import com.loopers.domain.coupon.IssuedCoupon;
import com.loopers.domain.coupon.IssuedCouponRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class CouponApplicationService {

private final CouponRepository couponRepository;
private final IssuedCouponRepository issuedCouponRepository;

@Transactional
public void issue(UUID couponId, String memberId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));

if (!coupon.isUsableAt(LocalDateTime.now())) {
throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다.");
}

IssuedCoupon issuedCoupon = new IssuedCoupon(
memberId,
couponId,
CouponStatus.AVAILABLE,
LocalDateTime.now(),
coupon.expiredAt(),
null
);

try {
issuedCouponRepository.save(issuedCoupon);
} catch (DataIntegrityViolationException e) {
throw new CoreException(ErrorType.CONFLICT, "이미 발급된 쿠폰입니다.");
}
}

@Transactional(readOnly = true)
public List<MyCouponView> listMyCoupons(String memberId) {
List<IssuedCoupon> issuedCoupons = issuedCouponRepository.findByMemberId(memberId);
if (issuedCoupons.isEmpty()) {
return List.of();
}

List<UUID> couponIds = issuedCoupons.stream().map(IssuedCoupon::couponId).distinct().toList();
Map<UUID, Coupon> couponsById = couponRepository.findAllMapByIdIn(couponIds);
LocalDateTime now = LocalDateTime.now();

return issuedCoupons.stream()
.map(issued -> {
Coupon coupon = couponsById.get(issued.couponId());
if (coupon == null) {
throw new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.");
}
return new MyCouponView(
issued.couponId(),
coupon.name(),
coupon.type(),
coupon.value(),
coupon.minOrderAmount(),
issued.expiredAt(),
issued.resolveStatusAt(now)
);
})
.toList();
}

@Transactional
public void use(UseCouponCommand command) {
IssuedCoupon issuedCoupon = issuedCouponRepository.findByMemberIdAndCouponId(command.memberId(), command.couponId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다."));

Coupon coupon = couponRepository.findById(command.couponId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));

issuedCoupon.validateOwner(command.memberId());
if (command.orderAmount() < coupon.minOrderAmount()) {
throw new CoreException(ErrorType.BAD_REQUEST, "최소 주문 금액 미달로 쿠폰을 사용할 수 없습니다.");
}

int updatedCount = issuedCouponRepository.markUsedAtomically(command.memberId(), command.couponId(), LocalDateTime.now());
if (updatedCount == 0) {
throw new CoreException(ErrorType.CONFLICT, "이미 사용되었거나 만료된 쿠폰입니다.");
}
}

@Transactional
public void cancelUse(UUID couponId, String memberId) {
IssuedCoupon issuedCoupon = issuedCouponRepository.findByMemberIdAndCouponId(memberId, couponId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다."));

issuedCoupon.validateOwner(memberId);
int updatedCount = issuedCouponRepository.markAvailableAtomically(memberId, couponId, LocalDateTime.now());
if (updatedCount == 0) {
throw new CoreException(ErrorType.CONFLICT, "사용 취소할 수 없는 쿠폰입니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.loopers.application.category;
package com.loopers.application.coupon.category;

import com.loopers.domain.category.Category;
import com.loopers.domain.category.CategoryRepository;
Expand All @@ -8,13 +8,14 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class CategoryApplicationService {
private final CategoryRepository categoryRepository;

public Category findById(Long categoryId) {
public Category findById(UUID categoryId) {
return categoryRepository.findById(categoryId)
.orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "카테고리를 찾을 수 없습니다."));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.application.coupon.command;

import com.loopers.domain.coupon.CouponType;

import java.time.LocalDateTime;

public record CreateCouponCommand(
String name,
CouponType type,
int value,
int minOrderAmount,
LocalDateTime expiredAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.application.coupon.command;

import com.loopers.domain.coupon.CouponType;

import java.time.LocalDateTime;

public record UpdateCouponCommand(
String name,
CouponType type,
int value,
int minOrderAmount,
LocalDateTime expiredAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.loopers.application.coupon.command;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

import java.util.UUID;

public record UseCouponCommand(
UUID couponId,
String memberId,
int orderAmount
) {
public UseCouponCommand {
if (couponId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 ID는 필수입니다.");
}
if (memberId == null || memberId.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "회원 식별자는 필수입니다.");
}
if (orderAmount < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 0 이상이어야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.loopers.application.coupon.view;

import com.loopers.domain.coupon.CouponStatus;
import com.loopers.domain.coupon.IssuedCoupon;

import java.time.LocalDateTime;
import java.util.UUID;

public record CouponIssueView(
UUID couponId,
String memberId,
CouponStatus status,
LocalDateTime issuedAt,
LocalDateTime expiredAt,
LocalDateTime usedAt
) {
public static CouponIssueView from(IssuedCoupon issuedCoupon) {
return new CouponIssueView(
issuedCoupon.couponId(),
issuedCoupon.memberId(),
issuedCoupon.status(),
issuedCoupon.issuedAt(),
issuedCoupon.expiredAt(),
issuedCoupon.usedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.application.coupon.view;

import com.loopers.domain.coupon.CouponStatus;
import com.loopers.domain.coupon.CouponType;

import java.time.LocalDateTime;
import java.util.UUID;

public record MyCouponView(
UUID couponId,
String name,
CouponType type,
int value,
int minOrderAmount,
LocalDateTime expiredAt,
CouponStatus status
) {
}
Loading