[220402] 나머지 개발 완료

- purchase 도메인 command, query 개발
- payment 도메인 command, query 개발
- readme update
This commit is contained in:
appleg
2022-04-02 16:53:23 +09:00
parent 202d9c5d0d
commit 646cbbd941
50 changed files with 1178 additions and 55 deletions

View File

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

View File

@@ -12,7 +12,7 @@ services:
- "3306:3306"
volumes:
- ./db:/var/lib/mysql
# platform: linux/amd64 # for m1 mac
# platform: linux/amd64 # for m1 mac
mysql-query:
container_name: query-mysql
image: mysql:8.0.21
@@ -22,10 +22,10 @@ services:
- MYSQL_ROOT_PASSWORD=payment
- TZ=UTC
ports:
- "3307:3307"
- "3307:3306"
volumes:
- ./db:/var/lib/mysql
# platform: linux/amd64 # for m1 mac
- ./db2:/var/lib/mysql
# platform: linux/amd64 # for m1 mac
zookeeper:
container_name: payment-zookeeper
image: wurstmeister/zookeeper

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
package com.example.common.event;
public interface DomainEvent {
String topic();
}

View File

@@ -0,0 +1,6 @@
package com.example.common.event;
public interface EventPublisher {
void publish(DomainEvent event);
}

View File

@@ -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));
}
}

View File

@@ -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 {
}

View File

@@ -0,0 +1,8 @@
package com.example.payment.application;
import com.example.payment.infra.http.request.PaymentCompleteRequest;
public interface PaymentService {
String completePayment(PaymentCompleteRequest request);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}
}

View File

@@ -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> {
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
package com.example.purchase.application;
import com.example.purchase.infra.http.request.PurchaseRequest;
public interface PurchaseService {
String purchase(PurchaseRequest request);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
package com.example.purchase.domain;
public enum PurchaseType {
ITEM
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}
}

View File

@@ -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> {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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);
}
}

View File

@@ -9,6 +9,15 @@ spring:
password: payment
jpa:
hibernate:
ddl-auto: update
ddl-auto: create-drop
show-sql: true
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

View File

@@ -1,18 +1,116 @@
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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
@AutoConfigureMockMvc
class PaymentApplicationTests {
@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
void contextLoads() {
template.send("topic", "hello this is new topic2!!!!!!");
void paymentCompleteControllerTest() throws Exception {
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()));
}
}

View File

@@ -1,4 +1,4 @@
package java.com.example;
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
package com.example.purchase.domain;
public enum PurchaseType {
ITEM
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View 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: '*'

View File

@@ -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