[220402] 나머지 개발 완료
- purchase 도메인 command, query 개발 - payment 도메인 command, query 개발 - readme update
This commit is contained in:
76
README.md
76
README.md
@@ -1,52 +1,94 @@
|
|||||||
# Payment System with DDD
|
# Payment System with DDD
|
||||||
|
|
||||||
## Project Settings
|
## Project Settings
|
||||||
|
|
||||||
### 1. Module Guide
|
### 1. Module Guide
|
||||||
본 프로젝트는 CQRS 패턴을 이용하여 구현되어 있다.
|
|
||||||
이에 따라 gradle을 이용하여 모듈이 둘로 나뉘어져 있는 디렉토리 구조를 확인할 수 있다.
|
본 프로젝트는 CQRS 패턴을 이용하여 구현되어 있다. 이에 따라 gradle을 이용하여 모듈이 둘로 나뉘어져 있는 디렉토리 구조를 확인할 수 있다. 서비스를 위해서는 `payment-command` 모듈과 `payment-query` 모듈을 각각 실행해야 한다.
|
||||||
서비스를 위해서는 `payment-command` 모듈과 `payment-query` 모듈을 각각 실행해야 한다.
|
|
||||||
|
|
||||||
#### payment-command 모듈
|
#### payment-command 모듈
|
||||||
CRUD 중에서 CUD 만을 담당하는 모듈이다.
|
|
||||||
본인의 테이블에 DB를 저장하며, 해당 이벤트를 Kafka에 발행하여 Query 모듈에 알려준다.
|
CRUD 중에서 CUD 만을 담당하는 모듈이다. 본인의 테이블에 DB를 저장하며, 해당 이벤트를 Kafka에 발행하여 Query 모듈에 알려준다. DDD의 핵심 도메인들은 이곳에 구현이 된다.
|
||||||
DDD의 핵심 도메인들은 이곳에 구현이 된다.
|
|
||||||
|
|
||||||
#### payment-query 모듈
|
#### payment-query 모듈
|
||||||
CRUD 중에서 R 만을 담당하는 모듈이다.
|
|
||||||
payment-command에서 발행한 이벤트를 Kafka로부터 consume하여 본인의 DB를 업데이트 한다.
|
CRUD 중에서 R 만을 담당하는 모듈이다. payment-command에서 발행한 이벤트를 Kafka로부터 consume하여 본인의 DB를 업데이트 한다. 이 모듈은 DDD의 핵심 도메인 중심이 아닌, 클라이언트에게 필요한 정보를 제공한다. 위의 command
|
||||||
이 모듈은 DDD의 핵심 도메인 중심이 아닌, 클라이언트에게 필요한 정보를 제공한다. 위의 command 모듈과는 다르게 조회의 효율성, 클라이언트의 호출과 관련하여 조회에 유연하게 구성해야 한다.
|
모듈과는 다르게 조회의 효율성, 클라이언트의 호출과 관련하여 조회에 유연하게 구성해야 한다.
|
||||||
|
|
||||||
### 2. Infra Used
|
### 2. Infra Used
|
||||||
|
|
||||||
```
|
```
|
||||||
1. MySQL 8.0.21 for Command
|
1. MySQL 8.0.21 for Command
|
||||||
2. MySQL 8.0.21 for Query
|
2. MySQL 8.0.21 for Query
|
||||||
3. Kafka(Zookeeper)
|
3. Kafka(Zookeeper)
|
||||||
```
|
```
|
||||||
위의 인프라는 로컬 실행을 위해서 docker-compose를 이용하였다.
|
|
||||||
|
|
||||||
> :warning: m1 맥북의 경우 docker-compose.yaml 파일에서 platform 관련 주석을 해제하고 실행할 것
|
위의 인프라는 로컬 실행을 위해서 docker-compose를 이용하였다.
|
||||||
|
|
||||||
|
> :warning: m1 맥북의 경우 docker-compose.yaml 파일에서 platform 관련 주석을 해제하고 실행할 것
|
||||||
|
|
||||||
- CQRS에서 DB를 분리하는 방법은 여러가지가 있지만, 가장 보편적인(?) 방식인 물리 DB를 나누는 방식을 이용하였다.
|
- CQRS에서 DB를 분리하는 방법은 여러가지가 있지만, 가장 보편적인(?) 방식인 물리 DB를 나누는 방식을 이용하였다.
|
||||||
command와 query 모듈은 각각 다른 DB를 보게된다.
|
command와 query 모듈은 각각 다른 DB를 보게된다.
|
||||||
- kafka의 경우 application.yaml에 특별한 설정 없이도 kafka에 자동으로 붙게 설정이 되어 있다.(autoconfigure 덕분에)
|
- kafka의 경우 application.yaml에 특별한 설정 없이도 kafka에 자동으로 붙게 설정이 되어 있다.(autoconfigure 덕분에)
|
||||||
이외의 운영을 위한 상세 설정은 생략했다.
|
이외의 운영을 위한 상세 설정은 생략했다.
|
||||||
|
|
||||||
## Domain 소개
|
## Domain 소개
|
||||||
|
|
||||||
이 프로젝트에서는 '결제' 컨텍스트를 예시로하는 간단한 DDD 프로젝트다.
|
이 프로젝트에서는 '결제' 컨텍스트를 예시로하는 간단한 DDD 프로젝트다.
|
||||||
|
|
||||||
1. 유저는 결제창에서 결제를 진행하여, 본인의 계정에 서비스 전용 재화(캐시)를 획득하게 된다. 게임이나 스타벅스 앱의 페이와 같이 충전과 관련된 시스템 대부분의 유형을 따르면 된다.
|
1. 유저는 결제창에서 결제를 진행하여, 본인의 계정에 서비스 전용 재화(캐시)를 획득하게 된다. 게임이나 스타벅스 앱의 페이와 같이 충전과 관련된 시스템 대부분의 유형을 따르면 된다.
|
||||||
2. 결제는 결제 대행사를 이용해서 처리하기 때문에 결제창에서 유저가 결제를 완료하면 본 도메인에서 제공하는 api로 결제 금액 정보가 담긴 결제 완료 요청을 보낸다. 결제창은 웹의 영역이므로 해당 도메인에서 다루지 않아도 무방하다.
|
2. 결제는 결제 대행사를 이용해서 처리하기 때문에 결제창에서 유저가 결제를 완료하면 본 도메인에서 제공하는 api로 결제 금액 정보가 담긴 결제 완료 요청을 보낸다. 결제창은 웹의 영역이므로 해당 도메인에서 다루지 않아도 무방하다.
|
||||||
3. 서비스에는 특정 아이템이 있으며, 서비스 전용 재화를 이용해서 구매할 수 있다.(게임이라면 아이템, 스타벅스라면 구매한 식음료) 다만 아이템은 본 컨텍스트에서는 관리하지 않는다.
|
3. 서비스에는 특정 아이템이 있으며, 서비스 전용 재화를 이용해서 구매할 수 있다.(게임이라면 아이템, 스타벅스라면 구매한 식음료) 다만 아이템은 본 컨텍스트에서는 관리하지 않는다.
|
||||||
|
|
||||||
|
도메인은 대략 다음과 같은 구조를 따른다.
|
||||||
|
|
||||||
|
1. User: 유저는 캐시를 소유할 수 있어 내부 엔터티로 구현. 유저 계정 관리 서비스가 따로 있다고 가정한다. 여기에서는 자체적으로 생성한다.
|
||||||
|
2. Payment: 캐시를 획득하는 행위
|
||||||
|
3. Cash: Payment로부터 생성된 User에게 할당되는 Cash
|
||||||
|
4. Purchase: 캐시를 사용하는 행위. 캐시를 차감한다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Use Case
|
## Use Case
|
||||||
|
|
||||||
### i. Command
|
### i. Command
|
||||||
|
|
||||||
- 외부 결제 시스템 결제로 인한 캐시 증가
|
- 외부 결제 시스템 결제로 인한 캐시 증가
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 해당 유저 아이디가 없을 경우 DB에 추가(계정은 다른 서비스에서 관리한다고 가정)
|
||||||
|
2. 유저 잔액 업데이트
|
||||||
|
2. 결제 기록 추가
|
||||||
|
3. 해당 이벤트를 consumer로 publish
|
||||||
|
```
|
||||||
|
|
||||||
- 각종 이유(구매 등)에 대한 캐시 사용
|
- 각종 이유(구매 등)에 대한 캐시 사용
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 유저의 잔액 확인
|
||||||
|
2. 유저의 잔액 차감
|
||||||
|
3. 구매 기록 저장
|
||||||
|
3. 해당 이벤트를 consumer로 publish
|
||||||
|
```
|
||||||
|
|
||||||
### ii. Query
|
### ii. Query
|
||||||
- 유저의 결제 내역 단건/기간별 조회
|
|
||||||
- 유저의 사용 내역 단건/기간별 조회
|
- 유저의 결제 내역 조회
|
||||||
|
- 유저의 사용 내역 조회
|
||||||
|
|
||||||
|
#### Query Model의 모듈 특징
|
||||||
|
|
||||||
|
- Query 모델에서는 Consuming과 동시에 Query 모델에서 필요한 데이터로 가공한다고 가정한다.
|
||||||
|
- 그러므로 몇 가지 요소가 생략될 수 있다.
|
||||||
|
- Repository가 Controller에 직접 주입될 수 있다. 왜냐하면 DB 쿼리 외의 별다른 로직이 필요없기 때문이다.
|
||||||
|
- 마찬가지 이유로 Response DTO도 사용하지 않는다. 이미 DB에 저장된 모든 정보가 Response에 최적화 시켜져 있기 때문이다.
|
||||||
|
|
||||||
|
#### Query Model의 모듈 특징
|
||||||
|
|
||||||
|
- Query 모델에서는 Consuming과 동시에 Query 모델에서 필요한 데이터로 가공한다고 가정한다.
|
||||||
|
- 그러므로 몇 가지 요소가 생략될 수 있다.
|
||||||
|
- Repository가 Controller에 직접 주입될 수 있다. 왜냐하면 DB 쿼리 외의 별다른 로직이 필요없기 때문이다.
|
||||||
|
- 마찬가지 이유로 Response DTO도 사용하지 않는다. 이미 DB에 저장된 모든 정보가 Response에 최적화 시켜져 있기 때문이다.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
# platform: linux/amd64 # for m1 mac
|
# platform: linux/amd64 # for m1 mac
|
||||||
mysql-query:
|
mysql-query:
|
||||||
container_name: query-mysql
|
container_name: query-mysql
|
||||||
image: mysql:8.0.21
|
image: mysql:8.0.21
|
||||||
@@ -22,10 +22,10 @@ services:
|
|||||||
- MYSQL_ROOT_PASSWORD=payment
|
- MYSQL_ROOT_PASSWORD=payment
|
||||||
- TZ=UTC
|
- TZ=UTC
|
||||||
ports:
|
ports:
|
||||||
- "3307:3307"
|
- "3307:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db2:/var/lib/mysql
|
||||||
# platform: linux/amd64 # for m1 mac
|
# platform: linux/amd64 # for m1 mac
|
||||||
zookeeper:
|
zookeeper:
|
||||||
container_name: payment-zookeeper
|
container_name: payment-zookeeper
|
||||||
image: wurstmeister/zookeeper
|
image: wurstmeister/zookeeper
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.example;
|
|
||||||
|
|
||||||
import org.springframework.kafka.annotation.KafkaListener;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class Listener {
|
|
||||||
@KafkaListener(topics = "topic", groupId = "group1")
|
|
||||||
public void consume(String message) {
|
|
||||||
System.out.println("receive message : " + message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.common.event;
|
||||||
|
|
||||||
|
public interface DomainEvent {
|
||||||
|
|
||||||
|
String topic();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.common.event;
|
||||||
|
|
||||||
|
public interface EventPublisher {
|
||||||
|
|
||||||
|
void publish(DomainEvent event);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.common.infra;
|
||||||
|
|
||||||
|
import com.example.common.event.DomainEvent;
|
||||||
|
import com.example.common.event.EventPublisher;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KafkaEventPublisher implements EventPublisher {
|
||||||
|
|
||||||
|
private final KafkaTemplate<String, DomainEvent> kafkaProducer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(DomainEvent event) {
|
||||||
|
String topic = event.topic();
|
||||||
|
kafkaProducer.send(new ProducerRecord<>(topic, event));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableJpaAuditing
|
||||||
|
public class JpaConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.payment.application;
|
||||||
|
|
||||||
|
import com.example.payment.infra.http.request.PaymentCompleteRequest;
|
||||||
|
|
||||||
|
public interface PaymentService {
|
||||||
|
|
||||||
|
String completePayment(PaymentCompleteRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.payment.application;
|
||||||
|
|
||||||
|
import com.example.common.event.EventPublisher;
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.payment.domain.PaymentRepository;
|
||||||
|
import com.example.payment.domain.event.PaymentCreated;
|
||||||
|
import com.example.payment.infra.http.request.PaymentCompleteRequest;
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import com.example.user.domain.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentServiceImpl implements PaymentService {
|
||||||
|
|
||||||
|
private final EventPublisher eventPublisher;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PaymentRepository paymentRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public String completePayment(PaymentCompleteRequest request) {
|
||||||
|
// 1. request validation
|
||||||
|
request.validate();
|
||||||
|
|
||||||
|
// 2. 유저의 잔액 추가 / 캐시 생성
|
||||||
|
User user = userRepository.getByIdOrDefault(request.getUserId());
|
||||||
|
user.increaseBalance(request.getAmount());
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
// 3. 결제 데이터 생성
|
||||||
|
Payment payment = paymentRepository.save(Payment.of(request.getPgId(), request.getPayBy(), user, null, null));
|
||||||
|
|
||||||
|
// 4. 이벤트 발행
|
||||||
|
eventPublisher.publish(PaymentCreated.builder()
|
||||||
|
.request(request)
|
||||||
|
.userId(user.getUserId())
|
||||||
|
.paymentId(payment.getId())
|
||||||
|
.createdAt(payment.getCreatedAt())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return payment.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.payment.domain;
|
||||||
|
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EntityListeners;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Payment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String pgId;
|
||||||
|
|
||||||
|
private String payBy;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
private ZonedDateTime updatedAt;
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZonedDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Payment of(String pgId, String payBy, User user, ZonedDateTime createdAt, ZonedDateTime updatedAt) {
|
||||||
|
return new Payment(UUID.randomUUID().toString(), pgId, payBy, user, createdAt, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.payment.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface PaymentRepository {
|
||||||
|
|
||||||
|
Payment save(Payment purchase);
|
||||||
|
|
||||||
|
Optional<Payment> getById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.payment.domain.event;
|
||||||
|
|
||||||
|
import com.example.common.event.DomainEvent;
|
||||||
|
import com.example.payment.infra.http.request.PaymentCompleteRequest;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class PaymentCreated implements DomainEvent, Serializable {
|
||||||
|
|
||||||
|
private final String userId;
|
||||||
|
|
||||||
|
private final String paymentId;
|
||||||
|
|
||||||
|
private final BigDecimal amount;
|
||||||
|
|
||||||
|
private final String payBy;
|
||||||
|
|
||||||
|
private final String pgId;
|
||||||
|
|
||||||
|
private final ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String topic() {
|
||||||
|
return "PaymentCreated";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private PaymentCreated(PaymentCompleteRequest request, String userId, String paymentId, ZonedDateTime createdAt) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.paymentId = paymentId;
|
||||||
|
this.amount = request.getAmount();
|
||||||
|
this.payBy = request.getPayBy();
|
||||||
|
this.pgId = request.getPgId();
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.payment.infra.http;
|
||||||
|
|
||||||
|
import com.example.payment.application.PaymentService;
|
||||||
|
import com.example.payment.infra.http.request.PaymentCompleteRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(("/payments"))
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentController {
|
||||||
|
|
||||||
|
private final PaymentService paymentService;
|
||||||
|
|
||||||
|
@PostMapping("/complete")
|
||||||
|
public ResponseEntity<String> paymentComplete(@RequestBody PaymentCompleteRequest request) {
|
||||||
|
return ResponseEntity.ok(paymentService.completePayment(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.payment.infra.http.request;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
public class PaymentCompleteRequest {
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
private BigDecimal amount;
|
||||||
|
private String payBy;
|
||||||
|
private String pgId;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private PaymentCompleteRequest(String userId, BigDecimal amount, String payBy, String pgId) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.payBy = payBy;
|
||||||
|
this.pgId = pgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validate() {
|
||||||
|
if (userId == null) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
if (payBy == null) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
if (pgId == null) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.payment.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
|
||||||
|
public interface PaymentJpaRepository extends CrudRepository<Payment, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.payment.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.payment.domain.PaymentRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentRepositoryImpl implements PaymentRepository {
|
||||||
|
|
||||||
|
private final PaymentJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Payment save(Payment payment) {
|
||||||
|
return jpaRepository.save(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Payment> getById(String id) {
|
||||||
|
return jpaRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.purchase.application;
|
||||||
|
|
||||||
|
import com.example.purchase.infra.http.request.PurchaseRequest;
|
||||||
|
|
||||||
|
public interface PurchaseService {
|
||||||
|
|
||||||
|
String purchase(PurchaseRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.purchase.application;
|
||||||
|
|
||||||
|
import com.example.common.event.EventPublisher;
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import com.example.purchase.domain.event.PurchaseCreated;
|
||||||
|
import com.example.purchase.infra.http.request.PurchaseRequest;
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import com.example.user.domain.UserRepository;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PurchaseServiceImpl implements PurchaseService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
private final EventPublisher eventPublisher;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public String purchase(PurchaseRequest request) {
|
||||||
|
// 1. request validation
|
||||||
|
request.validate();
|
||||||
|
|
||||||
|
// 2. 유저 존재 여부 확인
|
||||||
|
User foundUser = userRepository.findById(request.getUserId()).orElseThrow(NoSuchElementException::new);
|
||||||
|
|
||||||
|
// 3. 잔액 여유 확인
|
||||||
|
if (!foundUser.hasMoreThan(request.getAmount())) {
|
||||||
|
throw new IllegalArgumentException(); // TODO 커스텀 Exception으로 변경
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 캐시 차감
|
||||||
|
foundUser.useCash(request.getAmount());
|
||||||
|
userRepository.save(foundUser);
|
||||||
|
|
||||||
|
// 5. 구매 기록 추가
|
||||||
|
Purchase purchase = purchaseRepository.save(Purchase.of(foundUser, request.getType(), request.getAmount(), null, null));
|
||||||
|
|
||||||
|
// 6. 이벤트 발행
|
||||||
|
eventPublisher.publish(PurchaseCreated.builder()
|
||||||
|
.userId(foundUser.getUserId())
|
||||||
|
.purchaseId(purchase.getId())
|
||||||
|
.type(request.getType())
|
||||||
|
.amount(request.getAmount())
|
||||||
|
.createdAt(purchase.getCreatedAt())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return purchase.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EntityListeners;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Purchase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
private PurchaseType type;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
private ZonedDateTime updatedAt;
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZonedDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Purchase of(User user, PurchaseType type, BigDecimal amount, ZonedDateTime createdAt, ZonedDateTime updatedAt) {
|
||||||
|
return new Purchase(UUID.randomUUID().toString(), user, type, amount, createdAt, updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface PurchaseRepository {
|
||||||
|
|
||||||
|
Purchase save(Purchase purchase);
|
||||||
|
|
||||||
|
Optional<Purchase> getById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
public enum PurchaseType {
|
||||||
|
ITEM
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.purchase.domain.event;
|
||||||
|
|
||||||
|
import com.example.common.event.DomainEvent;
|
||||||
|
import com.example.purchase.domain.PurchaseType;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class PurchaseCreated implements DomainEvent, Serializable {
|
||||||
|
|
||||||
|
private final String userId;
|
||||||
|
|
||||||
|
private final String purchaseId;
|
||||||
|
|
||||||
|
private final PurchaseType type;
|
||||||
|
|
||||||
|
private final BigDecimal amount;
|
||||||
|
|
||||||
|
private final ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String topic() {
|
||||||
|
return "PurchaseCreated";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private PurchaseCreated(String userId, String purchaseId, PurchaseType type, BigDecimal amount, ZonedDateTime createdAt) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.purchaseId = purchaseId;
|
||||||
|
this.type = type;
|
||||||
|
this.amount = amount;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.purchase.infra.http;
|
||||||
|
|
||||||
|
import com.example.purchase.application.PurchaseService;
|
||||||
|
import com.example.purchase.infra.http.request.PurchaseRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/purchases")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PurchaseController {
|
||||||
|
|
||||||
|
private final PurchaseService purchaseService;
|
||||||
|
|
||||||
|
@PostMapping()
|
||||||
|
public ResponseEntity<String> purchase(@RequestBody PurchaseRequest request) {
|
||||||
|
return ResponseEntity.ok(purchaseService.purchase(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.purchase.infra.http.request;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.PurchaseType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
public class PurchaseRequest {
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
private PurchaseType type;
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private PurchaseRequest(String userId, PurchaseType type, BigDecimal amount) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.type = type;
|
||||||
|
this.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validate() {
|
||||||
|
if (userId == null) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
if (type == null) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.purchase.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
|
||||||
|
public interface PurchaseJpaRepository extends CrudRepository<Purchase, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.purchase.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PurchaseRepositoryImpl implements PurchaseRepository {
|
||||||
|
|
||||||
|
private final PurchaseJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Purchase save(Purchase purchase) {
|
||||||
|
return jpaRepository.save(purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Purchase> getById(String id) {
|
||||||
|
return jpaRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.user.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EntityListeners;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.ToString;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
@NoArgsConstructor
|
||||||
|
@ToString
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private BigDecimal balance;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void increaseBalance(BigDecimal amount) {
|
||||||
|
balance = balance.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasMoreThan(BigDecimal amount) {
|
||||||
|
return balance.compareTo(amount) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User(String userId, BigDecimal balance) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.balance = balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User newUser(String userId) {
|
||||||
|
return new User(userId, BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void useCash(BigDecimal amount) {
|
||||||
|
balance = balance.subtract(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.user.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface UserRepository {
|
||||||
|
|
||||||
|
User getByIdOrDefault(String id);
|
||||||
|
|
||||||
|
String save(User user);
|
||||||
|
|
||||||
|
Optional<User> findById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.user.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
|
||||||
|
public interface UserJpaRepositroy extends CrudRepository<User, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.example.user.infra.persistence;
|
||||||
|
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import com.example.user.domain.UserRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserRepositoryImpl implements UserRepository {
|
||||||
|
|
||||||
|
private final UserJpaRepositroy jpaRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String save(User user) {
|
||||||
|
return jpaRepository.save(user).getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getByIdOrDefault(String id) {
|
||||||
|
Optional<User> found = jpaRepository.findById(id);
|
||||||
|
return found.orElse(jpaRepository.save(User.newUser(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findById(String id) {
|
||||||
|
return jpaRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ spring:
|
|||||||
password: payment
|
password: payment
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: create-drop
|
||||||
show-sql: true
|
show-sql: true
|
||||||
database-platform: org.hibernate.dialect.MySQL8Dialect
|
database-platform: org.hibernate.dialect.MySQL8Dialect
|
||||||
|
kafka:
|
||||||
|
producer:
|
||||||
|
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.type: TRACE
|
||||||
@@ -1,18 +1,116 @@
|
|||||||
package com.example.payment;
|
package com.example.payment;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
|
||||||
|
import com.example.common.event.DomainEvent;
|
||||||
|
import com.example.common.event.EventPublisher;
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.payment.domain.PaymentRepository;
|
||||||
|
import com.example.payment.infra.http.request.PaymentCompleteRequest;
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import com.example.purchase.domain.PurchaseType;
|
||||||
|
import com.example.purchase.infra.http.request.PurchaseRequest;
|
||||||
|
import com.example.user.domain.User;
|
||||||
|
import com.example.user.domain.UserRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.kafka.core.KafkaTemplate;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
class PaymentApplicationTests {
|
class PaymentApplicationTests {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
KafkaTemplate<String, String> template;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockBean // 주석처리하고 코드에서도 해당 mock을 주석처리 할 경우 메시지 전송이 실제로 이루어짐
|
||||||
|
private EventPublisher eventPublisher;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PaymentRepository paymentRepository;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PurchaseRepository purchaseRepository;
|
||||||
|
|
||||||
|
private ObjectMapper mapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setup() {
|
||||||
|
this.mapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void paymentCompleteControllerTest() throws Exception {
|
||||||
template.send("topic", "hello this is new topic2!!!!!!");
|
|
||||||
|
String testUserId = "test-user-id";
|
||||||
|
|
||||||
|
PaymentCompleteRequest request = PaymentCompleteRequest.builder()
|
||||||
|
.userId(testUserId)
|
||||||
|
.amount(BigDecimal.valueOf(1000))
|
||||||
|
.payBy("card")
|
||||||
|
.pgId("example-pg")
|
||||||
|
.build();
|
||||||
|
User actualUser = User.newUser(testUserId);
|
||||||
|
|
||||||
|
Payment payment = Payment.of("example-pg", "card", actualUser, ZonedDateTime.now(), ZonedDateTime.now());
|
||||||
|
|
||||||
|
when(userRepository.getByIdOrDefault(anyString())).thenReturn(actualUser);
|
||||||
|
when(userRepository.save(any(User.class))).thenReturn("test-user-id");
|
||||||
|
when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
|
||||||
|
doNothing().when(eventPublisher).publish(any(DomainEvent.class));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/payments/complete")
|
||||||
|
.contentType("application/json")
|
||||||
|
.content(mapper.writeValueAsString(request)))
|
||||||
|
.andDo(print())
|
||||||
|
.andExpect(content().string(payment.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void purchaseControllerTest() throws Exception {
|
||||||
|
|
||||||
|
String testUserId = "test-user-id";
|
||||||
|
|
||||||
|
PurchaseRequest request = PurchaseRequest.builder()
|
||||||
|
.userId("test-user-id")
|
||||||
|
.type(PurchaseType.ITEM)
|
||||||
|
.amount(BigDecimal.valueOf(1000))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
User newUser = User.newUser(testUserId);
|
||||||
|
newUser.increaseBalance(BigDecimal.valueOf(10000));
|
||||||
|
Optional<User> actualUser = Optional.of(newUser);
|
||||||
|
|
||||||
|
Purchase purchase = Purchase.of(actualUser.get(), request.getType(), request.getAmount(), ZonedDateTime.now(), ZonedDateTime.now());
|
||||||
|
|
||||||
|
when(userRepository.findById(anyString())).thenReturn(actualUser);
|
||||||
|
when(userRepository.save(any(User.class))).thenReturn("test-user-id");
|
||||||
|
when(purchaseRepository.save(any(Purchase.class))).thenReturn(purchase);
|
||||||
|
doNothing().when(eventPublisher).publish(any(DomainEvent.class));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/purchases")
|
||||||
|
.contentType("application/json")
|
||||||
|
.content(mapper.writeValueAsString(request)))
|
||||||
|
.andDo(print())
|
||||||
|
.andExpect(content().string(purchase.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package java.com.example;
|
package com.example;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.payment.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
public class Payment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String paymentId;
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
private String payBy;
|
||||||
|
|
||||||
|
private String pgId;
|
||||||
|
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private Payment(String paymentId, String userId, BigDecimal amount, String payBy, String pgId, ZonedDateTime createdAt) {
|
||||||
|
this.paymentId = paymentId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.payBy = payBy;
|
||||||
|
this.pgId = pgId;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.payment.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PaymentRepository {
|
||||||
|
|
||||||
|
Payment save(Payment payment);
|
||||||
|
|
||||||
|
List<Payment> usersPayments(String userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.payment.domain.event;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
public class PaymentCreated {
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private String paymentId;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
private String payBy;
|
||||||
|
|
||||||
|
private String pgId;
|
||||||
|
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
public Payment toPayment() {
|
||||||
|
return Payment.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.paymentId(paymentId)
|
||||||
|
.amount(amount)
|
||||||
|
.payBy(payBy)
|
||||||
|
.pgId(pgId)
|
||||||
|
.createdAt(createdAt)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.payment.infra.http;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.payment.domain.PaymentRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/payments")
|
||||||
|
public class PaymentController {
|
||||||
|
|
||||||
|
private final PaymentRepository paymentRepository;
|
||||||
|
|
||||||
|
@GetMapping("/{userId}")
|
||||||
|
public List<Payment> userPayments(@PathVariable("userId") String userId) {
|
||||||
|
return paymentRepository.usersPayments(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.payment.infra.mysql;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface PaymentJpaRepository extends CrudRepository<Payment, String> {
|
||||||
|
|
||||||
|
@Query("select p from Payment p where p.userId = :userId")
|
||||||
|
List<Payment> findByUserId(@Param("userId") String userId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.payment.infra.mysql;
|
||||||
|
|
||||||
|
import com.example.payment.domain.Payment;
|
||||||
|
import com.example.payment.domain.PaymentRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentRepositoryImpl implements PaymentRepository {
|
||||||
|
|
||||||
|
private final PaymentJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Payment save(Payment payment) {
|
||||||
|
return jpaRepository.save(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Payment> usersPayments(String userId) {
|
||||||
|
return jpaRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
public class Purchase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String purchaseId;
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private PurchaseType type;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
private Purchase(String purchaseId, String userId, PurchaseType type, BigDecimal amount, ZonedDateTime createdAt) {
|
||||||
|
this.purchaseId = purchaseId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.type = type;
|
||||||
|
this.amount = amount;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PurchaseRepository {
|
||||||
|
|
||||||
|
Purchase save(Purchase purchase);
|
||||||
|
|
||||||
|
List<Purchase> usersPurchase(String userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.purchase.domain;
|
||||||
|
|
||||||
|
public enum PurchaseType {
|
||||||
|
ITEM
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.purchase.domain.event;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@ToString
|
||||||
|
public class PurchaseCreated {
|
||||||
|
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private String purchaseId;
|
||||||
|
|
||||||
|
private PurchaseType type;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
private ZonedDateTime createdAt;
|
||||||
|
|
||||||
|
public Purchase toPurchase() {
|
||||||
|
return Purchase.builder()
|
||||||
|
.purchaseId(purchaseId)
|
||||||
|
.userId(userId)
|
||||||
|
.type(type)
|
||||||
|
.amount(amount)
|
||||||
|
.createdAt(createdAt)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.purchase.infra.http;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/purchases")
|
||||||
|
public class PurchaseController {
|
||||||
|
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
|
||||||
|
@GetMapping("/{userId}")
|
||||||
|
public List<Purchase> userPurchases(@PathVariable("userId") String userId) {
|
||||||
|
return purchaseRepository.usersPurchase(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.purchase.infra.kafka;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import com.example.purchase.domain.event.PurchaseCreated;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.kafka.annotation.KafkaListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PurchaseCreatedListener {
|
||||||
|
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
|
||||||
|
@KafkaListener(topics = "PurchaseCreated", groupId = "group1")
|
||||||
|
public void consume(PurchaseCreated message) {
|
||||||
|
log.info("event received: {}", message);
|
||||||
|
Purchase purchase = message.toPurchase();
|
||||||
|
purchaseRepository.save(purchase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.purchase.infra.mysql;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface PurchaseJpaRepository extends CrudRepository<Purchase, String> {
|
||||||
|
|
||||||
|
@Query("select p from Purchase p where p.userId = :userId")
|
||||||
|
List<Purchase> findByUserId(@Param("userId") String userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.purchase.infra.mysql;
|
||||||
|
|
||||||
|
import com.example.purchase.domain.Purchase;
|
||||||
|
import com.example.purchase.domain.PurchaseRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PurchaseRepositoryImpl implements PurchaseRepository {
|
||||||
|
|
||||||
|
private final PurchaseJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Purchase save(Purchase purchase) {
|
||||||
|
return jpaRepository.save(purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Purchase> usersPurchase(String userId) {
|
||||||
|
return jpaRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
payment-query/src/main/resources/application.yaml
Normal file
23
payment-query/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server:
|
||||||
|
port: 8082
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: jdbc:mysql://localhost:3307/payment?serverTimezone=Asia/Seoul&useSSL=false&allowPublicKeyRetrieval=true
|
||||||
|
username: root
|
||||||
|
password: payment
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
show-sql: true
|
||||||
|
database-platform: org.hibernate.dialect.MySQL8Dialect
|
||||||
|
kafka:
|
||||||
|
consumer:
|
||||||
|
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||||
|
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||||
|
properties:
|
||||||
|
spring:
|
||||||
|
json:
|
||||||
|
trusted:
|
||||||
|
packages: '*'
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
server:
|
|
||||||
port: 8082
|
|
||||||
|
|
||||||
spring:
|
|
||||||
datasource:
|
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
|
||||||
url: jdbc:mysql://localhost:3307/payment?serverTimezone=Asia/Seoul&useSSL=false&allowPublicKeyRetrieval=true
|
|
||||||
username: root
|
|
||||||
password: payment
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: update
|
|
||||||
show-sql: true
|
|
||||||
database-platform: org.hibernate.dialect.MySQL8Dialect
|
|
||||||
Reference in New Issue
Block a user