Skip to content

Commit f8dc399

Browse files
authored
Merge pull request #157 from yeonjiyeon/feature/week6
[volume-6] 외부 시스템 장애 및 지연 대응
2 parents 6859902 + 007c152 commit f8dc399

62 files changed

Lines changed: 2079 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/commerce-api/build.gradle.kts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ dependencies {
1111
implementation("org.springframework.boot:spring-boot-starter-actuator")
1212
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
1313

14+
// Resilience4j (Spring Boot 3.x 기준)
15+
implementation("io.github.resilience4j:resilience4j-spring-boot3")
16+
17+
// AOP
18+
implementation("org.springframework.boot:spring-boot-starter-aop")
19+
20+
//Micrometer Prometheus
21+
implementation("io.micrometer:micrometer-registry-prometheus")
22+
23+
//Spring Cloud OpenFeign
24+
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
25+
26+
1427
// querydsl
1528
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
1629
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
77
import java.util.TimeZone;
8+
import org.springframework.cloud.openfeign.EnableFeignClients;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
810

11+
@EnableScheduling
12+
@EnableFeignClients
913
@ConfigurationPropertiesScan
1014
@SpringBootApplication
1115
public class CommerceApiApplication {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.loopers.domain.money.Money;
44
import com.loopers.domain.order.Order;
55
import com.loopers.domain.order.OrderItem;
6+
import com.loopers.domain.payment.Payment;
67
import java.util.List;
78

89
public record OrderInfo(
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.money.Money;
4+
import com.loopers.domain.payment.Payment;
5+
import com.loopers.domain.payment.PaymentCommand;
6+
import com.loopers.domain.payment.PaymentExecutor;
7+
import com.loopers.domain.payment.PaymentService;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Component;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class PaymentFacade {
14+
15+
private final PaymentService paymentService;
16+
private final PaymentExecutor paymentExecutor;
17+
18+
public Payment processPaymentRequest(PaymentCommand.CreatePayment command) {
19+
20+
return paymentService.findValidPayment(command.orderId())
21+
.orElseGet(() -> {
22+
Payment newPayment = paymentService.createPendingPayment(command.userId(),
23+
command.orderId(),
24+
new Money(command.amount()),
25+
command.cardType(),
26+
command.cardNo());
27+
28+
try {
29+
String pgTxnId = paymentExecutor.execute(newPayment);
30+
31+
paymentService.registerPgToken(newPayment, pgTxnId);
32+
33+
return newPayment;
34+
} catch (Exception e) {
35+
paymentService.failPayment(newPayment);
36+
throw e;
37+
}
38+
});
39+
}
40+
41+
42+
public void handlePaymentCallback(String pgTxnId, boolean isSuccess) {
43+
Payment payment = paymentService.getPaymentByPgTxnId(pgTxnId);
44+
45+
if (isSuccess) {
46+
paymentService.completePayment(payment);
47+
} else {
48+
paymentService.failPayment(payment);
49+
}
50+
}
51+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.payment.CardType;
4+
import com.loopers.domain.payment.Payment;
5+
import com.loopers.domain.payment.PaymentStatus;
6+
7+
public record PaymentInfo(
8+
String paymentId,
9+
Long orderId,
10+
CardType cardType,
11+
String cardNo,
12+
Long amount,
13+
PaymentStatus status,
14+
String transactionId) {
15+
16+
public static PaymentInfo from(Payment payment) {
17+
return new PaymentInfo(
18+
payment.getId().toString(),
19+
payment.getOrderId(),
20+
payment.getCardType(),
21+
payment.getCardNo(),
22+
payment.getAmount().getValue(),
23+
payment.getStatus(),
24+
payment.getTransactionId()
25+
);
26+
}
27+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.payment.Payment;
4+
import com.loopers.domain.payment.PaymentRepository;
5+
import com.loopers.domain.payment.PaymentStatus;
6+
import com.loopers.infrastructure.pg.PgClient;
7+
import com.loopers.infrastructure.pg.PgV1Dto.PgDetail;
8+
import com.loopers.infrastructure.pg.PgV1Dto.PgOrderResponse;
9+
import jakarta.transaction.Transactional;
10+
import java.time.LocalDateTime;
11+
import java.util.List;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.scheduling.annotation.Scheduled;
14+
import org.springframework.stereotype.Component;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class PaymentRecoveryScheduler {
19+
20+
private final PaymentRepository paymentRepository;
21+
private final PgClient pgClient;
22+
23+
@Scheduled(fixedDelay = 60000)
24+
@Transactional
25+
public void recover() {
26+
LocalDateTime timeLimit = LocalDateTime.now().minusMinutes(5);
27+
List<Payment> stuckPayments = paymentRepository.findAllByStatusAndCreatedAtBefore(
28+
PaymentStatus.READY, timeLimit
29+
);
30+
31+
for (Payment payment : stuckPayments) {
32+
try {
33+
PgOrderResponse response = pgClient.getTransactionsByOrder(
34+
payment.getUserId(), String.valueOf(payment.getOrderId())
35+
);
36+
37+
if (response.transactions() != null && !response.transactions().isEmpty()) {
38+
PgDetail detail = response.transactions().get(0);
39+
40+
if ("SUCCESS".equals(detail.status())) {
41+
payment.completePayment();
42+
} else if ("FAIL".equals(detail.status())) {
43+
payment.failPayment();
44+
}
45+
} else {
46+
payment.failPayment();
47+
}
48+
} catch (Exception e) {
49+
}
50+
}
51+
}
52+
}

apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connec
3333

3434
objectMapper.activateDefaultTyping(
3535
BasicPolymorphicTypeValidator.builder()
36-
.allowIfBaseType(Object.class)
36+
.allowIfBaseType("com.loopers")
37+
.allowIfBaseType("java.util")
3738
.build(),
3839
ObjectMapper.DefaultTyping.EVERYTHING
3940
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.loopers.domain.payment;
2+
3+
public enum CardType {
4+
SAMSUNG,
5+
KB,
6+
HYUNDAI
7+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.loopers.domain.payment;
2+
3+
import com.loopers.domain.BaseEntity;
4+
import com.loopers.domain.money.Money;
5+
import com.loopers.support.error.CoreException;
6+
import com.loopers.support.error.ErrorType;
7+
import jakarta.persistence.AttributeOverride;
8+
import jakarta.persistence.Column;
9+
import jakarta.persistence.Embedded;
10+
import jakarta.persistence.Entity;
11+
import jakarta.persistence.EnumType;
12+
import jakarta.persistence.Enumerated;
13+
import jakarta.persistence.Table;
14+
import java.util.UUID;
15+
import lombok.AccessLevel;
16+
import lombok.Getter;
17+
import lombok.NoArgsConstructor;
18+
import org.springframework.util.StringUtils;
19+
20+
@Getter
21+
@Entity
22+
@Table(name = "payment")
23+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
24+
public class Payment extends BaseEntity {
25+
26+
@Column(name = "ref_user_id", nullable = false)
27+
private Long userId;
28+
29+
@Column(name = "ref_order_id", nullable = false)
30+
private Long orderId;
31+
32+
@Column(name = "transaction_id", nullable = false, unique = true)
33+
private String transactionId;//멱등키
34+
35+
@Enumerated(EnumType.STRING)
36+
@Column(name = "card_type", nullable = false)
37+
private CardType cardType;
38+
39+
@Column(name = "card_no", nullable = false)
40+
private String cardNo;
41+
42+
@Embedded
43+
@AttributeOverride(name = "value", column = @Column(name = "amount"))
44+
private Money amount;
45+
46+
@Enumerated(EnumType.STRING)
47+
@Column(nullable = false)
48+
private PaymentStatus status;
49+
50+
@Column(name = "pg_txn_id")
51+
private String pgTxnId;
52+
53+
public Payment(Long orderId, Long userId, Money amount, CardType cardType, String cardNo) {
54+
validateConstructor(orderId, userId, amount, cardNo);
55+
56+
this.orderId = orderId;
57+
this.userId = userId;
58+
this.amount = amount;
59+
this.cardType = cardType;
60+
this.cardNo = cardNo;
61+
this.status = PaymentStatus.READY;
62+
63+
this.transactionId = UUID.randomUUID().toString();
64+
}
65+
66+
private void validateConstructor(Long orderId, Long userId, Money amount, String cardNo) {
67+
if (orderId == null) {
68+
throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다.");
69+
}
70+
if (userId == null) {
71+
throw new CoreException(ErrorType.BAD_REQUEST, "사용자 정보는 필수입니다.");
72+
}
73+
if (amount == null) {
74+
throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다.");
75+
}
76+
if (!StringUtils.hasText(cardNo)) {
77+
throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
78+
}
79+
}
80+
81+
public void completePayment() {
82+
if (this.status == PaymentStatus.PAID || this.status == PaymentStatus.CANCELLED) {
83+
return;
84+
}
85+
this.status = PaymentStatus.PAID;
86+
}
87+
88+
public void failPayment() {
89+
if (this.status == PaymentStatus.PAID) {
90+
throw new CoreException(ErrorType.BAD_REQUEST, "이미 성공한 결제는 실패 처리할 수 없습니다.");
91+
}
92+
this.status = PaymentStatus.FAILED;
93+
}
94+
95+
public boolean isProcessingOrCompleted() {
96+
return this.status == PaymentStatus.PAID || this.status == PaymentStatus.READY;
97+
}
98+
99+
public void setPgTxnId(String pgTxnId) {
100+
this.pgTxnId = pgTxnId;
101+
}
102+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.loopers.domain.payment;
2+
3+
import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentRequest;
4+
5+
public class PaymentCommand {
6+
7+
public record CreatePayment(
8+
Long userId,
9+
Long orderId,
10+
Long amount,
11+
CardType cardType,
12+
String cardNo
13+
) {
14+
15+
public static CreatePayment from(Long userId, PaymentRequest request) {
16+
return new CreatePayment(
17+
userId,
18+
request.orderId(),
19+
request.amount(),
20+
request.cardType(),
21+
request.cardNo()
22+
);
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)