From 63a5c841b0a4f7573e552ce3a6d19565f425e8f0 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:35:05 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(event):=20DomainEvent=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20DomainEventRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coinflow/event/domain/DomainEvent.java | 56 +++++++++++++++++++ .../event/domain/DomainEventType.java | 10 ++++ .../repository/DomainEventRepository.java | 7 +++ 3 files changed, 73 insertions(+) create mode 100644 src/main/java/com/coinflow/event/domain/DomainEvent.java create mode 100644 src/main/java/com/coinflow/event/domain/DomainEventType.java create mode 100644 src/main/java/com/coinflow/event/repository/DomainEventRepository.java diff --git a/src/main/java/com/coinflow/event/domain/DomainEvent.java b/src/main/java/com/coinflow/event/domain/DomainEvent.java new file mode 100644 index 0000000..ae435d6 --- /dev/null +++ b/src/main/java/com/coinflow/event/domain/DomainEvent.java @@ -0,0 +1,56 @@ +package com.coinflow.event.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor +@Table(name = "domain_events") +public class DomainEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private DomainEventType eventType; + + private String aggregateType; + private Long aggregateId; + + private Long marketId; + private String marketSymbol; + + private String payload; + + private boolean published; + private LocalDateTime publishedAt; + private int publishAttempts; + + private LocalDateTime createdAt; + + public static DomainEvent create( + DomainEventType eventType, + String aggregateType, + Long aggregateId, + Long marketId, + String marketSymbol, + String payload + ) { + DomainEvent event = new DomainEvent(); + event.eventType = eventType; + event.aggregateType = aggregateType; + event.aggregateId = aggregateId; + event.marketId = marketId; + event.marketSymbol = marketSymbol; + event.payload = payload; + event.published = false; + event.publishAttempts = 0; + event.createdAt = LocalDateTime.now(); + return event; + } +} diff --git a/src/main/java/com/coinflow/event/domain/DomainEventType.java b/src/main/java/com/coinflow/event/domain/DomainEventType.java new file mode 100644 index 0000000..00e2fb5 --- /dev/null +++ b/src/main/java/com/coinflow/event/domain/DomainEventType.java @@ -0,0 +1,10 @@ +package com.coinflow.event.domain; + +public enum DomainEventType { + ORDER_ACCEPTED, + ORDER_PARTIALLY_FILLED, + ORDER_FILLED, + ORDER_CANCELED, + TRADE_CREATED, + SETTLEMENT_COMPLETED +} diff --git a/src/main/java/com/coinflow/event/repository/DomainEventRepository.java b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java new file mode 100644 index 0000000..846c21b --- /dev/null +++ b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java @@ -0,0 +1,7 @@ +package com.coinflow.event.repository; + +import com.coinflow.event.domain.DomainEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DomainEventRepository extends JpaRepository { +} From d5e4a54b64a80fc7bc0ba9371c9f5fa2f27220a9 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:35:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(event):=20DomainEventRecorder=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/service/DomainEventRecorder.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/main/java/com/coinflow/event/service/DomainEventRecorder.java diff --git a/src/main/java/com/coinflow/event/service/DomainEventRecorder.java b/src/main/java/com/coinflow/event/service/DomainEventRecorder.java new file mode 100644 index 0000000..2087b21 --- /dev/null +++ b/src/main/java/com/coinflow/event/service/DomainEventRecorder.java @@ -0,0 +1,102 @@ +package com.coinflow.event.service; + +import com.coinflow.event.domain.DomainEvent; +import com.coinflow.event.domain.DomainEventType; +import com.coinflow.event.repository.DomainEventRepository; +import com.coinflow.order.domain.Order; +import com.coinflow.trade.domain.Trade; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class DomainEventRecorder { + + private final DomainEventRepository domainEventRepository; + private final ObjectMapper objectMapper; + + public void recordOrderAccepted(Order order) { + save(DomainEventType.ORDER_ACCEPTED, "ORDER", order.getId(), + order.getMarketId(), order.getMarketSymbol(), + Map.of( + "orderId", order.getId(), + "userId", order.getUserId(), + "market", order.getMarketSymbol(), + "side", order.getSide().name(), + "price", order.getPrice(), + "quantity", order.getOriginalQuantity() + )); + } + + public void recordOrderFillEvent(Order order, Long tradeId) { + DomainEventType type = (order.getRemainingQuantity().signum() == 0) + ? DomainEventType.ORDER_FILLED + : DomainEventType.ORDER_PARTIALLY_FILLED; + + save(type, "ORDER", order.getId(), + order.getMarketId(), order.getMarketSymbol(), + Map.of( + "orderId", order.getId(), + "tradeId", tradeId, + "executedQuantity", order.getExecutedQuantity(), + "remainingQuantity", order.getRemainingQuantity(), + "executedQuoteAmount", order.getExecutedQuoteAmount() + )); + } + + public void recordOrderCanceled(Order order, String releasedAsset, String releasedAmount) { + save(DomainEventType.ORDER_CANCELED, "ORDER", order.getId(), + order.getMarketId(), order.getMarketSymbol(), + Map.of( + "orderId", order.getId(), + "userId", order.getUserId(), + "releasedAsset", releasedAsset, + "releasedAmount", releasedAmount + )); + } + + public void recordTradeCreated(Trade trade) { + save(DomainEventType.TRADE_CREATED, "TRADE", trade.getId(), + trade.getMarketId(), trade.getMarketSymbol(), + Map.of( + "tradeId", trade.getId(), + "market", trade.getMarketSymbol(), + "buyOrderId", trade.getBuyOrderId(), + "sellOrderId", trade.getSellOrderId(), + "makerOrderId", trade.getMakerOrderId(), + "takerOrderId", trade.getTakerOrderId(), + "price", trade.getPrice(), + "quantity", trade.getQuantity(), + "quoteAmount", trade.getQuoteAmount() + )); + } + + public void recordSettlementCompleted(Trade trade) { + save(DomainEventType.SETTLEMENT_COMPLETED, "TRADE", trade.getId(), + trade.getMarketId(), trade.getMarketSymbol(), + Map.of( + "tradeId", trade.getId(), + "buyOrderId", trade.getBuyOrderId(), + "sellOrderId", trade.getSellOrderId(), + "price", trade.getPrice(), + "quantity", trade.getQuantity(), + "quoteAmount", trade.getQuoteAmount() + )); + } + + private void save(DomainEventType type, String aggregateType, Long aggregateId, + Long marketId, String marketSymbol, Map payload) { + try { + String json = objectMapper.writeValueAsString(payload); + domainEventRepository.save( + DomainEvent.create(type, aggregateType, aggregateId, marketId, marketSymbol, json) + ); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize domain event payload: " + type, e); + } + } +} From 8761d50e5b386f739191fbfebd774d6c806dcc2e Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:35:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(order):=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=A1=9D=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(ORDER=5FACCEPTED,=20ORDER=5FFILLED/PARTIALLY=5FFIL?= =?UTF-8?q?LED,=20ORDER=5FCANCELED,=20TRADE=5FCREATED,=20SETTLEMENT=5FCOMP?= =?UTF-8?q?LETED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/coinflow/order/service/OrderService.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 4b75a22..3c075a1 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -14,6 +14,7 @@ import com.coinflow.order.dto.CreateOrderResponse; import com.coinflow.order.dto.OrderDetailResponse; import com.coinflow.order.dto.OrderSummaryResponse; +import com.coinflow.event.service.DomainEventRecorder; import com.coinflow.order.matching.MatchResult; import com.coinflow.order.matching.MatchingEngine; import com.coinflow.order.repository.OrderRepository; @@ -50,6 +51,7 @@ public class OrderService { private final TradeRepository tradeRepository; private final WalletLedgerRepository walletLedgerRepository; private final MatchingEngine matchingEngine; + private final DomainEventRecorder eventRecorder; private final TransactionTemplate transactionTemplate; private final Map marketLocks = new ConcurrentHashMap<>(); @@ -62,6 +64,7 @@ public OrderService( TradeRepository tradeRepository, WalletLedgerRepository walletLedgerRepository, MatchingEngine matchingEngine, + DomainEventRecorder eventRecorder, PlatformTransactionManager transactionManager ) { this.marketRepository = marketRepository; @@ -71,6 +74,7 @@ public OrderService( this.tradeRepository = tradeRepository; this.walletLedgerRepository = walletLedgerRepository; this.matchingEngine = matchingEngine; + this.eventRecorder = eventRecorder; this.transactionTemplate = new TransactionTemplate(transactionManager); } @@ -147,6 +151,7 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re sequence, request.clientOrderId() ); orderRepository.save(order); + eventRecorder.recordOrderAccepted(order); // ORDER_LOCK ledger walletLedgerRepository.save(WalletLedger.create( @@ -195,6 +200,7 @@ public CancelOrderResponse cancelOrder(Long currentUserId, Long orderId) { lockedOrder.cancel(); matchingEngine.cancelOrder(lockedOrder.getMarketSymbol(), lockedOrder); + eventRecorder.recordOrderCanceled(lockedOrder, lockedOrder.getLockedAsset(), releaseAmount.toPlainString()); return CancelOrderResponse.of(lockedOrder, lockedOrder.getLockedAsset(), releaseAmount.toPlainString()); }); @@ -265,6 +271,10 @@ private List settle(Market market, Order taker, List matchRe Long sellOrderId = result.sellOrderId(); Long tradeId = trade.getId(); + eventRecorder.recordOrderFillEvent(maker, tradeId); + eventRecorder.recordOrderFillEvent(taker, tradeId); + eventRecorder.recordTradeCreated(trade); + walletLedgerRepository.save(WalletLedger.create( buyerQuoteWallet, LedgerType.TRADE_BUY_QUOTE_SETTLE, buyerRefund, buyerReleased.negate(), @@ -286,6 +296,7 @@ private List settle(Market market, Order taker, List matchRe sellOrderId, tradeId )); + eventRecorder.recordSettlementCompleted(trade); trades.add(trade); } From e0f3075bbf5670e9f41d891e02395ec0cdd5d8e6 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:38:34 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(event):=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DomainEventRepository.java | 5 + .../coinflow/integration/DomainEventTest.java | 194 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/test/java/com/coinflow/integration/DomainEventTest.java diff --git a/src/main/java/com/coinflow/event/repository/DomainEventRepository.java b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java index 846c21b..4183ef6 100644 --- a/src/main/java/com/coinflow/event/repository/DomainEventRepository.java +++ b/src/main/java/com/coinflow/event/repository/DomainEventRepository.java @@ -1,7 +1,12 @@ package com.coinflow.event.repository; import com.coinflow.event.domain.DomainEvent; +import com.coinflow.event.domain.DomainEventType; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface DomainEventRepository extends JpaRepository { + List findAllByAggregateTypeAndAggregateId(String aggregateType, Long aggregateId); + List findAllByEventType(DomainEventType eventType); } diff --git a/src/test/java/com/coinflow/integration/DomainEventTest.java b/src/test/java/com/coinflow/integration/DomainEventTest.java new file mode 100644 index 0000000..f2d5b2d --- /dev/null +++ b/src/test/java/com/coinflow/integration/DomainEventTest.java @@ -0,0 +1,194 @@ +package com.coinflow.integration; + +import com.coinflow.auth.repository.UserRepository; +import com.coinflow.event.domain.DomainEventType; +import com.coinflow.event.repository.DomainEventRepository; +import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.support.TestcontainersConfig; +import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("rawtypes") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfig.class) +class DomainEventTest { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private UserRepository userRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private DomainEventRepository domainEventRepository; + @Autowired private MatchingEngine matchingEngine; + + @BeforeEach + void setUp() { + matchingEngine.clearAll(); + } + + @Test + void 주문_생성_시_ORDER_ACCEPTED_이벤트_저장() { + String token = signupAndLogin("evt001@example.com"); + depositKrw("evt001@example.com", new BigDecimal("10000000")); + + var response = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) response.getBody().get("orderId")).longValue(); + + var events = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", orderId); + assertThat(events).hasSize(1); + assertThat(events.get(0).getEventType()).isEqualTo(DomainEventType.ORDER_ACCEPTED); + assertThat(events.get(0).getPayload()).contains("orderId"); + assertThat(events.get(0).isPublished()).isFalse(); + } + + @Test + void 전량_체결_시_ORDER_FILLED_TRADE_CREATED_SETTLEMENT_COMPLETED_이벤트_저장() { + String buyerToken = signupAndLogin("evt002-buyer@example.com"); + String sellerToken = signupAndLogin("evt002-seller@example.com"); + depositKrw("evt002-buyer@example.com", new BigDecimal("10000000")); + depositBtc("evt002-seller@example.com", new BigDecimal("0.001")); + + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); + + var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + Long sellOrderId = ((Number) sellResponse.getBody().get("orderId")).longValue(); + + // 매수 주문: ORDER_ACCEPTED + ORDER_FILLED + var buyEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", buyOrderId); + var buyEventTypes = buyEvents.stream().map(e -> e.getEventType()).toList(); + assertThat(buyEventTypes).containsExactlyInAnyOrder( + DomainEventType.ORDER_ACCEPTED, + DomainEventType.ORDER_FILLED + ); + + // 매도 주문: ORDER_ACCEPTED + ORDER_FILLED + var sellEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", sellOrderId); + var sellEventTypes = sellEvents.stream().map(e -> e.getEventType()).toList(); + assertThat(sellEventTypes).containsExactlyInAnyOrder( + DomainEventType.ORDER_ACCEPTED, + DomainEventType.ORDER_FILLED + ); + + // TRADE: TRADE_CREATED + SETTLEMENT_COMPLETED + var trades = (List) sellResponse.getBody().get("trades"); + Long tradeId = ((Number) ((Map) trades.get(0)).get("tradeId")).longValue(); + var tradeEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("TRADE", tradeId); + var tradeEventTypes = tradeEvents.stream().map(e -> e.getEventType()).toList(); + assertThat(tradeEventTypes).containsExactlyInAnyOrder( + DomainEventType.TRADE_CREATED, + DomainEventType.SETTLEMENT_COMPLETED + ); + } + + @Test + void 부분_체결_시_ORDER_PARTIALLY_FILLED_이벤트_저장() { + String buyerToken = signupAndLogin("evt003-buyer@example.com"); + String sellerToken = signupAndLogin("evt003-seller@example.com"); + depositKrw("evt003-buyer@example.com", new BigDecimal("100000000")); + depositBtc("evt003-seller@example.com", new BigDecimal("0.001")); + + // BUY 0.001, SELL 0.0001 → 부분 체결 + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.001", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); + + createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + + var buyEvents = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", buyOrderId); + var buyEventTypes = buyEvents.stream().map(e -> e.getEventType()).toList(); + + assertThat(buyEventTypes).contains(DomainEventType.ORDER_ACCEPTED); + assertThat(buyEventTypes).contains(DomainEventType.ORDER_PARTIALLY_FILLED); + assertThat(buyEventTypes).doesNotContain(DomainEventType.ORDER_FILLED); + } + + @Test + void 주문_취소_시_ORDER_CANCELED_이벤트_저장() { + String token = signupAndLogin("evt004@example.com"); + depositKrw("evt004@example.com", new BigDecimal("10000000")); + + var createResponse = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) createResponse.getBody().get("orderId")).longValue(); + + cancelOrder(token, orderId); + + var events = domainEventRepository.findAllByAggregateTypeAndAggregateId("ORDER", orderId); + var eventTypes = events.stream().map(e -> e.getEventType()).toList(); + assertThat(eventTypes).containsExactlyInAnyOrder( + DomainEventType.ORDER_ACCEPTED, + DomainEventType.ORDER_CANCELED + ); + + var cancelEvent = events.stream() + .filter(e -> e.getEventType() == DomainEventType.ORDER_CANCELED) + .findFirst().orElseThrow(); + assertThat(cancelEvent.getPayload()).contains("releasedAsset"); + assertThat(cancelEvent.getPayload()).contains("KRW"); + } + + // ── helpers ─────────────────────────────────────────────────────── + + private String signupAndLogin(String email) { + restTemplate.postForEntity( + "/api/v1/auth/signup", + Map.of("email", email, "password", "password1234", "nickname", "tester"), + Map.class + ); + var loginResponse = restTemplate.postForEntity( + "/api/v1/auth/login", + Map.of("email", email, "password", "password1234"), + Map.class + ); + return (String) loginResponse.getBody().get("accessToken"); + } + + private void depositKrw(String email, BigDecimal amount) { + deposit(email, "KRW", amount); + } + + private void depositBtc(String email, BigDecimal amount) { + deposit(email, "BTC", amount); + } + + private void deposit(String email, String asset, BigDecimal amount) { + var user = userRepository.findByEmail(email).orElseThrow(); + Wallet wallet = walletRepository.findAllByUserId(user.getId()).stream() + .filter(w -> w.getAsset().equals(asset)) + .findFirst().orElseThrow(); + wallet.deposit(amount); + walletRepository.save(wallet); + } + + private ResponseEntity createOrder(String token, String market, String side, String type, + String timeInForce, String price, String quantity, + String clientOrderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + var body = new java.util.HashMap(); + body.put("market", market); + body.put("side", side); + body.put("type", type); + body.put("timeInForce", timeInForce); + body.put("price", price); + body.put("quantity", quantity); + if (clientOrderId != null) body.put("clientOrderId", clientOrderId); + return restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + } + + private ResponseEntity cancelOrder(String token, Long orderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + return restTemplate.exchange("/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, new HttpEntity<>(headers), Map.class); + } +}