Skip to content

Commit 384face

Browse files
authored
Merge pull request #117 from sylee6529/round3-clean
[volume 4] 트랜잭션 및 동시성 구현
2 parents 2e5a9a5 + 5a9e372 commit 384face

39 files changed

Lines changed: 2415 additions & 44 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build/
44
!gradle/wrapper/gradle-wrapper.jar
55
!**/src/main/**/build/
66
!**/src/test/**/build/
7+
**/generated/**
78

89
### STS ###
910
.apt_generated

apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@
88
@Getter
99
@Builder
1010
public class OrderCommand {
11-
11+
1212
private final String memberId;
1313
private final List<OrderLineCommand> orderLines;
14-
14+
private final Long memberCouponId;
15+
1516
public static OrderCommand of(String memberId, List<OrderLineCommand> orderLines) {
1617
return OrderCommand.builder()
1718
.memberId(memberId)
1819
.orderLines(orderLines)
20+
.memberCouponId(null)
21+
.build();
22+
}
23+
24+
public static OrderCommand of(String memberId, List<OrderLineCommand> orderLines, Long memberCouponId) {
25+
return OrderCommand.builder()
26+
.memberId(memberId)
27+
.orderLines(orderLines)
28+
.memberCouponId(memberCouponId)
1929
.build();
2030
}
2131
}

apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ public OrderInfo placeOrder(OrderCommand command) {
2424

2525
OrderPlacementCommand domainCommand = OrderPlacementCommand.of(
2626
command.getMemberId(),
27-
domainOrderLines
27+
domainOrderLines,
28+
command.getMemberCouponId()
2829
);
2930

3031
Order order = orderPlacementService.placeOrder(domainCommand);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.common.vo.Money;
4+
import com.loopers.domain.coupon.enums.DiscountType;
5+
import com.loopers.support.error.CoreException;
6+
import com.loopers.support.error.ErrorType;
7+
import jakarta.persistence.*;
8+
import lombok.AccessLevel;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
12+
import java.math.BigDecimal;
13+
14+
@Entity
15+
@Table(name = "coupons")
16+
@Getter
17+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
18+
public class Coupon {
19+
20+
@Id
21+
@GeneratedValue(strategy = GenerationType.IDENTITY)
22+
private Long id;
23+
24+
@Column(nullable = false)
25+
private String name;
26+
27+
@Enumerated(EnumType.STRING)
28+
@Column(nullable = false)
29+
private DiscountType discountType;
30+
31+
@Column(nullable = false)
32+
private BigDecimal discountValue;
33+
34+
private Coupon(String name, DiscountType discountType, BigDecimal discountValue) {
35+
validate(name, discountType, discountValue);
36+
this.name = name;
37+
this.discountType = discountType;
38+
this.discountValue = discountValue;
39+
}
40+
41+
public static Coupon createFixedCoupon(String name, BigDecimal discountValue) {
42+
return new Coupon(name, DiscountType.FIXED, discountValue);
43+
}
44+
45+
public static Coupon createPercentageCoupon(String name, BigDecimal discountValue) {
46+
return new Coupon(name, DiscountType.PERCENTAGE, discountValue);
47+
}
48+
49+
public Money calculateDiscount(Money originalPrice) {
50+
return discountType.calculateDiscount(originalPrice, discountValue);
51+
}
52+
53+
private void validate(String name, DiscountType discountType, BigDecimal discountValue) {
54+
if (name == null || name.isBlank()) {
55+
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 필수입니다.");
56+
}
57+
if (discountType == null) {
58+
throw new CoreException(ErrorType.BAD_REQUEST, "할인 타입은 필수입니다.");
59+
}
60+
if (discountValue == null || discountValue.compareTo(BigDecimal.ZERO) <= 0) {
61+
throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다.");
62+
}
63+
if (discountType == DiscountType.PERCENTAGE && discountValue.compareTo(BigDecimal.valueOf(100)) > 0) {
64+
throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100%를 초과할 수 없습니다.");
65+
}
66+
}
67+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.common.vo.Money;
4+
import com.loopers.support.error.CoreException;
5+
import com.loopers.support.error.ErrorType;
6+
import jakarta.persistence.*;
7+
import lombok.AccessLevel;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
@Entity
12+
@Table(name = "member_coupons")
13+
@Getter
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
public class MemberCoupon {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@Column(nullable = false)
22+
private String memberId;
23+
24+
@ManyToOne(fetch = FetchType.LAZY)
25+
@JoinColumn(name = "coupon_id", nullable = false)
26+
private Coupon coupon;
27+
28+
@Column(nullable = false)
29+
private boolean used;
30+
31+
private MemberCoupon(String memberId, Coupon coupon) {
32+
validate(memberId, coupon);
33+
this.memberId = memberId;
34+
this.coupon = coupon;
35+
this.used = false;
36+
}
37+
38+
public static MemberCoupon issue(String memberId, Coupon coupon) {
39+
return new MemberCoupon(memberId, coupon);
40+
}
41+
42+
public void use() {
43+
if (this.used) {
44+
throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다.");
45+
}
46+
this.used = true;
47+
}
48+
49+
public boolean isUsable() {
50+
return !this.used;
51+
}
52+
53+
public Money calculateDiscount(Money originalPrice) {
54+
return coupon.calculateDiscount(originalPrice);
55+
}
56+
57+
public void validateOwnership(String memberId) {
58+
if (!this.memberId.equals(memberId)) {
59+
throw new CoreException(ErrorType.BAD_REQUEST, "본인의 쿠폰만 사용할 수 있습니다.");
60+
}
61+
}
62+
63+
public void validateUsable() {
64+
if (!isUsable()) {
65+
throw new CoreException(ErrorType.BAD_REQUEST, "사용할 수 없는 쿠폰입니다.");
66+
}
67+
}
68+
69+
private void validate(String memberId, Coupon coupon) {
70+
if (memberId == null || memberId.isBlank()) {
71+
throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다.");
72+
}
73+
if (coupon == null) {
74+
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 정보는 필수입니다.");
75+
}
76+
}
77+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.loopers.domain.coupon.enums;
2+
3+
import com.loopers.domain.common.vo.Money;
4+
5+
import java.math.BigDecimal;
6+
import java.math.RoundingMode;
7+
8+
public enum DiscountType {
9+
10+
FIXED {
11+
@Override
12+
public Money calculateDiscount(Money originalPrice, BigDecimal discountValue) {
13+
Money discount = Money.of(discountValue);
14+
if (discount.isGreaterThanOrEqual(originalPrice)) {
15+
return originalPrice;
16+
}
17+
return discount;
18+
}
19+
},
20+
21+
PERCENTAGE {
22+
@Override
23+
public Money calculateDiscount(Money originalPrice, BigDecimal discountValue) {
24+
BigDecimal percentage = discountValue.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
25+
BigDecimal discountAmount = originalPrice.getAmount().multiply(percentage).setScale(0, RoundingMode.DOWN);
26+
Money discount = Money.of(discountAmount);
27+
if (discount.isGreaterThanOrEqual(originalPrice)) {
28+
return originalPrice;
29+
}
30+
return discount;
31+
}
32+
};
33+
34+
public abstract Money calculateDiscount(Money originalPrice, BigDecimal discountValue);
35+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.loopers.domain.coupon.repository;
2+
3+
import com.loopers.domain.coupon.Coupon;
4+
5+
import java.util.Optional;
6+
7+
public interface CouponRepository {
8+
9+
Optional<Coupon> findById(Long id);
10+
11+
Coupon save(Coupon coupon);
12+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.loopers.domain.coupon.repository;
2+
3+
import com.loopers.domain.coupon.MemberCoupon;
4+
5+
import java.util.List;
6+
import java.util.Optional;
7+
8+
public interface MemberCouponRepository {
9+
10+
Optional<MemberCoupon> findById(Long id);
11+
12+
Optional<MemberCoupon> findByIdForUpdate(Long id);
13+
14+
List<MemberCoupon> findByMemberId(String memberId);
15+
16+
MemberCoupon save(MemberCoupon memberCoupon);
17+
}

apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import com.loopers.support.error.ErrorType;
99
import lombok.RequiredArgsConstructor;
1010
import org.springframework.stereotype.Component;
11-
import org.springframework.transaction.annotation.Transactional;
1211

1312
@RequiredArgsConstructor
1413
@Component
@@ -17,31 +16,29 @@ public class LikeService {
1716
private final LikeRepository likeRepository;
1817
private final ProductRepository productRepository;
1918

20-
@Transactional
2119
public void like(String memberId, Long productId) {
2220
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
2321
return;
2422
}
2523

26-
Product product = productRepository.findById(productId)
27-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
28-
2924
likeRepository.save(new Like(memberId, productId));
30-
product.increaseLikeCount();
31-
productRepository.save(product);
25+
26+
int updated = productRepository.incrementLikeCount(productId);
27+
if (updated == 0) {
28+
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
29+
}
3230
}
3331

34-
@Transactional
3532
public void unlike(String memberId, Long productId) {
3633
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
3734
return;
3835
}
3936

40-
Product product = productRepository.findById(productId)
41-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
42-
4337
likeRepository.deleteByMemberIdAndProductId(memberId, productId);
44-
product.decreaseLikeCount();
45-
productRepository.save(product);
38+
39+
int updated = productRepository.decrementLikeCount(productId);
40+
if (updated == 0) {
41+
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
42+
}
4643
}
4744
}

apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ public static Order create(String memberId, List<OrderItem> items) {
4747
return new Order(memberId, items, totalPrice);
4848
}
4949

50+
public static Order create(String memberId, List<OrderItem> items, Money finalPrice) {
51+
validateMemberId(memberId);
52+
validateItems(items);
53+
validateTotalPrice(finalPrice);
54+
return new Order(memberId, items, finalPrice);
55+
}
56+
5057
private void addItem(OrderItem item) {
5158
this.items.add(item);
5259
item.assignOrder(this);

0 commit comments

Comments
 (0)