diff --git a/.gitignore b/.gitignore index c2065bc26..ec2a4b47c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ out/ ### VS Code ### .vscode/ + + +### Custom ### +db_dev.mv.db +db_dev.trace.db +.env \ No newline at end of file diff --git a/build.gradle b/build.gradle index eadffe91a..2d19a6aa2 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' diff --git a/readme.md b/readme.md new file mode 100644 index 000000000..fc867645c --- /dev/null +++ b/readme.md @@ -0,0 +1,18 @@ +- # 토스 페이먼츠 +- [토스 페이먼츠, 내 개발정보](https://developers.tosspayments.com/my/api-logs) + - API 개별 연동 키 + - 테스트 클라이언트 키 + - 시크릿 키 + - API 로그 + - 테스트 결제내역 + +# 토스 페이먼츠 테스트 + +- [결제시도](https://codepen.io/jangka44/debug/yyJBXaM) + - [소스코드](https://codepen.io/jangka44/pen/yyJBXaM?editors=1000) +- [최종승인](https://codepen.io/jangka44/debug/GgqKEWV) + - 직접 접근 금지 + - [소스코드](https://codepen.io/jangka44/pen/GgqKEWV?editors=1000) +- [결제실패](https://codepen.io/jangka44/debug/xbOKrdJ) + - 직접 접근 금지 + - [소스코드](https://codepen.io/jangka44/pen/xbOKrdJ?editors=1000) \ No newline at end of file diff --git a/src/main/java/com/back/BackApplication.java b/src/main/java/com/back/BackApplication.java index e5e900c6b..9e1f63ab7 100644 --- a/src/main/java/com/back/BackApplication.java +++ b/src/main/java/com/back/BackApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class BackApplication { public static void main(String[] args) { diff --git a/src/main/java/com/back/boundedContext/cash/app/CashCompleteOrderPaymentUseCase.java b/src/main/java/com/back/boundedContext/cash/app/CashCompleteOrderPaymentUseCase.java new file mode 100644 index 000000000..498cf2816 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashCompleteOrderPaymentUseCase.java @@ -0,0 +1,69 @@ +package com.back.boundedContext.cash.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.cash.domain.CashLog; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.global.eventPublisher.EventPublisher; +import com.back.shared.cash.event.CashOrderPaymentFailedEvent; +import com.back.shared.cash.event.CashOrderPaymentSucceededEvent; +import com.back.shared.market.dto.OrderDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CashCompleteOrderPaymentUseCase { + private final CashSupport cashSupport; + private final EventPublisher eventPublisher; + + public void completeOrderPayment(OrderDto orderDto, long pgPaymentAmount) { + Wallet customerWallet = cashSupport.findWalletByHolderId(orderDto.getCustomerId()).get(); + Wallet holdingWallet = cashSupport.findHoldingWallet().get(); + + if (pgPaymentAmount > 0) { + customerWallet.credit( + pgPaymentAmount, + CashLog.EventType.충전__PG결제_토스페이먼츠, + orderDto.getModelTypeCode(), + orderDto.getId() + ); + } + + boolean canPay = customerWallet.getBalance() >= orderDto.getSalePrice(); + + if (canPay) { + customerWallet.debit( + orderDto.getSalePrice(), + CashLog.EventType.사용__주문결제, + orderDto.getModelTypeCode(), + orderDto.getId() + ); + + holdingWallet.credit( + orderDto.getSalePrice(), + CashLog.EventType.임시보관__주문결제, + orderDto.getModelTypeCode(), + orderDto.getId() + ); + + eventPublisher.publish( + new CashOrderPaymentSucceededEvent( + orderDto, + pgPaymentAmount + ) + + ); + } else { + eventPublisher.publish( + new CashOrderPaymentFailedEvent( + "400-1", + "충전은 완료했지만 %번 주문을 결제완료처리를 하기에는 예치금이 부족합니다.".formatted(orderDto.getId()), + orderDto, + pgPaymentAmount, + pgPaymentAmount - customerWallet.getBalance() + ) + ); + } + } +} diff --git a/src/main/java/com/back/boundedContext/cash/app/CashCompletePayoutUseCase.java b/src/main/java/com/back/boundedContext/cash/app/CashCompletePayoutUseCase.java new file mode 100644 index 000000000..8a0def81a --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashCompletePayoutUseCase.java @@ -0,0 +1,35 @@ +package com.back.boundedContext.cash.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.cash.domain.CashLog; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.shared.payout.dto.PayoutDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CashCompletePayoutUseCase { + private final CashSupport cashSupport; + + + public void completePayout(PayoutDto payout) { + Wallet holdingWallet = cashSupport.findHoldingWallet().get(); + Wallet payeeWallet = cashSupport.findWalletByHolderId(payout.getPayeeId()).get(); + + holdingWallet.debit( + payout.getAmount(), + payout.isPayeeSystem() ? CashLog.EventType.정산지급__상품판매_수수료 : CashLog.EventType.정산지급__상품판매_대금, + payout.getModelTypeCode(), + payout.getId() + ); + + payeeWallet.credit( + payout.getAmount(), + payout.isPayeeSystem() ? CashLog.EventType.정산수령__상품판매_수수료 : CashLog.EventType.정산수령__상품판매_대금, + payout.getModelTypeCode(), + payout.getId() + ); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/app/CashCreateWalletUseCase.java b/src/main/java/com/back/boundedContext/cash/app/CashCreateWalletUseCase.java new file mode 100644 index 000000000..f0b9361bd --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashCreateWalletUseCase.java @@ -0,0 +1,25 @@ +package com.back.boundedContext.cash.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.boundedContext.cash.out.CashMemberRepository; +import com.back.boundedContext.cash.out.WalletRepository; +import com.back.shared.cash.dto.CashMemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CashCreateWalletUseCase { + private final CashMemberRepository cashMemberRepository; + private final WalletRepository walletRepository; + + public Wallet createWallet(CashMemberDto member) { + CashMember _member = cashMemberRepository.getReferenceById(member.getId()); + Wallet wallet = new Wallet(_member); + + return walletRepository.save(wallet); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/app/CashFacade.java b/src/main/java/com/back/boundedContext/cash/app/CashFacade.java new file mode 100644 index 000000000..c854409c5 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashFacade.java @@ -0,0 +1,65 @@ +package com.back.boundedContext.cash.app; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.shared.cash.dto.CashMemberDto; +import com.back.shared.market.dto.OrderDto; +import com.back.shared.member.dto.MemberDto; +import com.back.shared.payout.dto.PayoutDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CashFacade { + private final CashSupport cashSupport; + private final CashSyncMemberUseCase cashSyncMemberUseCase; + private final CashCreateWalletUseCase cashCreateWalletUseCase; + private final CashCompleteOrderPaymentUseCase cashCompleteOrderPaymentUseCase; + private final CashCompletePayoutUseCase cashCompletePayoutUseCase; + + @Transactional + public CashMember syncMember(MemberDto memberDto) { + + return cashSyncMemberUseCase.syncMember(memberDto); + } + + @Transactional + public Wallet createWallet(CashMemberDto holder) { + + return cashCreateWalletUseCase.createWallet(holder); + } + + @Transactional(readOnly = true) + public Optional findMemberByUserName(String userName) { + + return cashSupport.findMemberByUserName(userName); + } + + @Transactional(readOnly = true) + public Optional findWalletByHolder(CashMember holder) { + + return cashSupport.findWalletByHolder(holder); + } + + @Transactional + public void completeOrderPayment(OrderDto orderDto, long pgPaymentAmount) { + cashCompleteOrderPaymentUseCase.completeOrderPayment(orderDto, pgPaymentAmount); + } + + @Transactional(readOnly = true) + public Optional findWalletByHolderId(int id) { + return cashSupport.findWalletByHolderId(id); + } + + //정산 집행 후 시스템,회원 지갑에 정산 내역 반영 + @Transactional + public void completePayout(PayoutDto payout) { + cashCompletePayoutUseCase.completePayout(payout); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/app/CashSupport.java b/src/main/java/com/back/boundedContext/cash/app/CashSupport.java new file mode 100644 index 000000000..ce1b9a4fb --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashSupport.java @@ -0,0 +1,36 @@ +package com.back.boundedContext.cash.app; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.domain.CashPolicy; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.boundedContext.cash.out.CashMemberRepository; +import com.back.boundedContext.cash.out.WalletRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CashSupport { + private final CashMemberRepository cashMemberRepository; + private final WalletRepository walletRepository; + + public Optional findMemberByUserName(String userName) { + return cashMemberRepository.findByUserName(userName); + } + + public Optional findWalletByHolder(CashMember holder) { + return walletRepository.findByHolder(holder); + } + + public Optional findWalletByHolderId(int holderId) { + return walletRepository.findByHolderId(holderId); + } + + public Optional findHoldingWallet() { + return walletRepository.findByHolderId(CashPolicy.HOLDING_MEMBER_ID); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/app/CashSyncMemberUseCase.java b/src/main/java/com/back/boundedContext/cash/app/CashSyncMemberUseCase.java new file mode 100644 index 000000000..af413f960 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/app/CashSyncMemberUseCase.java @@ -0,0 +1,45 @@ +package com.back.boundedContext.cash.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.out.CashMemberRepository; +import com.back.global.eventPublisher.EventPublisher; +import com.back.shared.cash.event.CashMemberCreateEvent; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CashSyncMemberUseCase { + private final CashMemberRepository cashMemberRepository; + private final EventPublisher eventPublisher; + + public CashMember syncMember(MemberDto memberDto) { + + boolean isNew = !cashMemberRepository.existsById(memberDto.getId()); + + CashMember _member = cashMemberRepository.save( + new CashMember( + memberDto.getId(), + memberDto.getCreateTime(), + memberDto.getModifyTime(), + memberDto.getUserName(), + "", + memberDto.getNickName(), + memberDto.getActivityScore() + ) + ); + + if (isNew) { + eventPublisher.publish( + new CashMemberCreateEvent( + _member.toDto() + ) + ); + } + + return _member; + } +} diff --git a/src/main/java/com/back/boundedContext/cash/domain/CashLog.java b/src/main/java/com/back/boundedContext/cash/domain/CashLog.java new file mode 100644 index 000000000..24881b8f7 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/domain/CashLog.java @@ -0,0 +1,49 @@ +package com.back.boundedContext.cash.domain; + +import com.back.global.jpa.entity.BaseIdAndTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "CASH_CASH_LOG") +@NoArgsConstructor +public class CashLog extends BaseIdAndTime { + public enum EventType { + 충전__무통장입금, + 충전__PG결제_토스페이먼츠, + 출금__통장입금, + 사용__주문결제, + 임시보관__주문결제, + 정산지급__상품판매_수수료, + 정산수령__상품판매_수수료, + 정산지급__상품판매_대금, + 정산수령__상품판매_대금, + } + + @Enumerated(EnumType.STRING) + private EventType eventType; + private String relTypeCode; + private int relId; + @ManyToOne(fetch = FetchType.LAZY) + private CashMember member; + @ManyToOne(fetch = FetchType.LAZY) + private Wallet wallet; + private long amount; + private long balance; + + public CashLog(EventType eventType, String relTypeCode, int relId, CashMember member, Wallet wallet, long amount, long balance) { + this.eventType = eventType; + this.relTypeCode = relTypeCode; + this.relId = relId; + this.member = member; + this.wallet = wallet; + this.amount = amount; + this.balance = balance; + } +} diff --git a/src/main/java/com/back/boundedContext/cash/domain/CashMember.java b/src/main/java/com/back/boundedContext/cash/domain/CashMember.java new file mode 100644 index 000000000..558be50ac --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/domain/CashMember.java @@ -0,0 +1,33 @@ +package com.back.boundedContext.cash.domain; + +import java.time.LocalDateTime; + +import com.back.shared.cash.dto.CashMemberDto; +import com.back.shared.member.domain.ReplicaMember; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "CASH_MEMBER") +@Getter +@NoArgsConstructor +public class CashMember extends ReplicaMember { + public CashMember(int id, LocalDateTime createTime, LocalDateTime modifyTime, String userName, String password, + String nickName, int activityScore) { + super(id, createTime, modifyTime, userName, password, nickName, activityScore); + } + + public CashMemberDto toDto() { + return new CashMemberDto( + getId(), + getCreateTime(), + getModifyTime(), + getUserName(), + getNickName(), + getActivityScore() + ); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/domain/CashPolicy.java b/src/main/java/com/back/boundedContext/cash/domain/CashPolicy.java new file mode 100644 index 000000000..7e990d833 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/domain/CashPolicy.java @@ -0,0 +1,14 @@ +package com.back.boundedContext.cash.domain; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CashPolicy { + public static int HOLDING_MEMBER_ID; + + @Value("${custom.global.holdingMemberId}") + public void setHoldingMemberId(int id) { + HOLDING_MEMBER_ID = id; + } +} diff --git a/src/main/java/com/back/boundedContext/cash/domain/Wallet.java b/src/main/java/com/back/boundedContext/cash/domain/Wallet.java new file mode 100644 index 000000000..6726965c4 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/domain/Wallet.java @@ -0,0 +1,96 @@ +package com.back.boundedContext.cash.domain; + +import java.util.ArrayList; +import java.util.List; + +import com.back.global.jpa.entity.BaseEntity; +import com.back.global.jpa.entity.BaseManualAndTime; +import com.back.shared.cash.dto.WalletDto; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "CASH_WALLET") +@NoArgsConstructor +@Getter +public class Wallet extends BaseManualAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private CashMember holder; + + @Getter + private long balance; + + @OneToMany(mappedBy = "wallet", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) + private List cashLogs = new ArrayList<>(); + + public Wallet(CashMember holder) { + super(holder.getId()); + this.holder = holder; + } + + public WalletDto toDto() { + return new WalletDto( + getId(), + getCreateTime(), + getModifyTime(), + holder.getId(), + holder.getUserName(), + balance + ); + } + + public boolean hasBalance() { + return balance > 0; + } + + public void credit(long amount, CashLog.EventType eventType, String relTypeCode, int relId) { + balance += amount; + + addCashLog(amount, eventType, relTypeCode, relId); + } + + public void credit(long amount, CashLog.EventType eventType, BaseEntity rel) { + credit(amount, eventType, rel.getModelTypeCode(), rel.getId()); + } + + public void credit(long amount, CashLog.EventType eventType) { + credit(amount, eventType, holder); + } + + public void debit(long amount, CashLog.EventType eventType, String relTypeCode, int relId) { + balance -= amount; + + addCashLog(amount, eventType, relTypeCode, relId); + } + + public void debit(long amount, CashLog.EventType eventType, BaseEntity rel) { + debit(amount, eventType, rel.getModelTypeCode(), rel.getId()); + } + + public void debit(long amount, CashLog.EventType eventType) { + debit(amount, eventType, holder); + } + + private CashLog addCashLog(long amount, CashLog.EventType eventType, String relTypeCode, int relId) { + CashLog cashLog = new CashLog( + eventType, + relTypeCode, + relId, + holder, + this, + amount, + balance + ); + + cashLogs.add(cashLog); + + return cashLog; + } +} diff --git a/src/main/java/com/back/boundedContext/cash/in/ApiV1WalletController.java b/src/main/java/com/back/boundedContext/cash/in/ApiV1WalletController.java new file mode 100644 index 000000000..6d5f0440f --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/in/ApiV1WalletController.java @@ -0,0 +1,31 @@ +package com.back.boundedContext.cash.in; + +import org.springframework.transaction.annotation.Transactional; +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; + +import com.back.boundedContext.cash.app.CashFacade; +import com.back.boundedContext.cash.domain.Wallet; +import com.back.shared.cash.dto.WalletDto; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/cash/wallets") +@RequiredArgsConstructor +public class ApiV1WalletController { + private final CashFacade cashFacade; + + @GetMapping("/by-holder/{holderId}") + @Transactional(readOnly = true) + public WalletDto getItemByHolder( + @PathVariable int holderId + ) { + return cashFacade + .findWalletByHolderId(holderId) + .map(Wallet::toDto) + .get(); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/in/CashDataInit.java b/src/main/java/com/back/boundedContext/cash/in/CashDataInit.java new file mode 100644 index 000000000..7016da06e --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/in/CashDataInit.java @@ -0,0 +1,59 @@ +package com.back.boundedContext.cash.in; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.cash.app.CashFacade; +import com.back.boundedContext.cash.domain.CashLog; +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.domain.Wallet; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class CashDataInit { + private final CashDataInit self; + private final CashFacade cashFacade; + + public CashDataInit( + @Lazy CashDataInit self, + CashFacade cashFacade + ) { + this.self = self; + this.cashFacade = cashFacade; + } + + @Bean + @Order(2) + public ApplicationRunner cashDataInitApplicationRunner() { + return args -> { + self.makeBaseCredits(); + }; + } + + @Transactional + public void makeBaseCredits() { + CashMember user1Member = cashFacade.findMemberByUserName("user1").get(); + CashMember user2Member = cashFacade.findMemberByUserName("user2").get(); + + Wallet user1Wallet = cashFacade.findWalletByHolder(user1Member).get(); + + if (user1Wallet.hasBalance()) return; + + user1Wallet.credit(150_000, CashLog.EventType.충전__무통장입금); + user1Wallet.credit(100_000, CashLog.EventType.충전__무통장입금); + user1Wallet.credit(50_000, CashLog.EventType.충전__무통장입금); + + Wallet user2Wallet = cashFacade.findWalletByHolder(user2Member).get(); + + if (user2Wallet.hasBalance()) return; + + user2Wallet.credit(150_000, CashLog.EventType.충전__무통장입금); + } + +} diff --git a/src/main/java/com/back/boundedContext/cash/in/CashEventListener.java b/src/main/java/com/back/boundedContext/cash/in/CashEventListener.java new file mode 100644 index 000000000..7cc38232c --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/in/CashEventListener.java @@ -0,0 +1,53 @@ +package com.back.boundedContext.cash.in; + +import static org.springframework.transaction.annotation.Propagation.*; +import static org.springframework.transaction.event.TransactionPhase.*; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.back.boundedContext.cash.app.CashFacade; +import com.back.shared.cash.event.CashMemberCreateEvent; +import com.back.shared.market.event.MarketOrderPaymentRequestedEvent; +import com.back.shared.member.event.MemberJoinedEvent; +import com.back.shared.member.event.MemberModifiedEvent; +import com.back.shared.payout.event.PayoutCompletedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CashEventListener { + private final CashFacade cashFacade; + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MemberJoinedEvent event) { + cashFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MemberModifiedEvent event) { + cashFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(CashMemberCreateEvent event) { + cashFacade.createWallet(event.getCashMemberDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MarketOrderPaymentRequestedEvent event) { + cashFacade.completeOrderPayment(event.getOrderDto(), event.getPgPaymentAmount()); + } + + @TransactionalEventListener + @Transactional(propagation = REQUIRES_NEW) + public void handle(PayoutCompletedEvent event) { + cashFacade.completePayout(event.getPayoutDto()); + } +} diff --git a/src/main/java/com/back/boundedContext/cash/out/CashMemberRepository.java b/src/main/java/com/back/boundedContext/cash/out/CashMemberRepository.java new file mode 100644 index 000000000..f857ac8bc --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/out/CashMemberRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.cash.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.cash.domain.CashMember; + +public interface CashMemberRepository extends JpaRepository { + Optional findByUserName(String username); +} diff --git a/src/main/java/com/back/boundedContext/cash/out/WalletRepository.java b/src/main/java/com/back/boundedContext/cash/out/WalletRepository.java new file mode 100644 index 000000000..636f69715 --- /dev/null +++ b/src/main/java/com/back/boundedContext/cash/out/WalletRepository.java @@ -0,0 +1,13 @@ +package com.back.boundedContext.cash.out; + +import java.util.Optional; + +import org.springframework.data.repository.CrudRepository; + +import com.back.boundedContext.cash.domain.CashMember; +import com.back.boundedContext.cash.domain.Wallet; + +public interface WalletRepository extends CrudRepository { + Optional findByHolder(CashMember holder); + Optional findByHolderId(int holderId); +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketCancelOrderRequestPaymentUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketCancelOrderRequestPaymentUseCase.java new file mode 100644 index 000000000..e27a82176 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketCancelOrderRequestPaymentUseCase.java @@ -0,0 +1,20 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.out.OrderRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketCancelOrderRequestPaymentUseCase { + private final OrderRepository orderRepository; + + public void cancelRequestPayment(int orderId) { + Order order = orderRepository.findById(orderId).get(); + + order.cancelRequestPayment(); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketCompleteOrderPaymentUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketCompleteOrderPaymentUseCase.java new file mode 100644 index 000000000..19df94991 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketCompleteOrderPaymentUseCase.java @@ -0,0 +1,20 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.out.OrderRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketCompleteOrderPaymentUseCase { + private final OrderRepository orderRepository; + + public void completePayment(int orderId) { + Order order = orderRepository.findById(orderId).get(); + + order.completePayment(); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketCreateCartUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketCreateCartUseCase.java new file mode 100644 index 000000000..fb441f6e2 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketCreateCartUseCase.java @@ -0,0 +1,33 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.out.CartRepository; +import com.back.boundedContext.market.out.MarketMemberRepository; +import com.back.global.RsData.RsData; +import com.back.shared.market.dto.MarketMemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketCreateCartUseCase { + private final MarketMemberRepository marketMemberRepository; + private final CartRepository cartRepository; + + public RsData createCart(MarketMemberDto buyer) { + MarketMember _buyer = marketMemberRepository.getReferenceById(buyer.getId()); + + Cart cart = new Cart(_buyer); + + cartRepository.save(cart); + + return new RsData<>( + "201-1", + "장바구니가 생성되었습니다.", + cart + ); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketCreateOrderUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketCreateOrderUseCase.java new file mode 100644 index 000000000..d4ade63c9 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketCreateOrderUseCase.java @@ -0,0 +1,30 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.out.OrderRepository; +import com.back.global.RsData.RsData; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketCreateOrderUseCase { + private final OrderRepository orderRepository; + + public RsData createOrder(Cart cart) { + Order _order = new Order(cart); + + Order order = orderRepository.save(_order); + + cart.clearItems(); + + return new RsData<>( + "201-1", + "%d번 주문이 생성되었습니다.".formatted(order.getId()), + order + ); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketCreateProductUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketCreateProductUseCase.java new file mode 100644 index 000000000..ea5ed35a9 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketCreateProductUseCase.java @@ -0,0 +1,37 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.domain.Product; +import com.back.boundedContext.market.out.ProductRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketCreateProductUseCase { + private final ProductRepository productRepository; + + public Product createProduct( + MarketMember seller, + String sourceTypeCode, + int sourceId, + String name, + String description, + long price, + long salePrice + ) { + Product product = new Product( + seller, + sourceTypeCode, + sourceId, + name, + description, + price, + salePrice + ); + + return productRepository.save(product); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketFacade.java b/src/main/java/com/back/boundedContext/market/app/MarketFacade.java new file mode 100644 index 000000000..613575746 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketFacade.java @@ -0,0 +1,108 @@ +package com.back.boundedContext.market.app; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.domain.Product; +import com.back.global.RsData.RsData; +import com.back.shared.market.dto.MarketMemberDto; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketFacade { + private final MarketSyncMemberUseCase marketSyncMemberUseCase; + private final MarketSupport marketSupport; + private final MarketCreateProductUseCase marketCreateProductUseCase; + private final MarketCreateCartUseCase marketCreateCartUseCase; + private final MarketCreateOrderUseCase marketCreateOrderUseCase; + private final MarketCompleteOrderPaymentUseCase marketCompleteOrderPaymentUseCase; + private final MarketCancelOrderRequestPaymentUseCase marketCancelOrderRequestPaymentUseCase; + + @Transactional + public MarketMember syncMember(MemberDto memberDto) { + return marketSyncMemberUseCase.syncMember(memberDto); + } + + @Transactional(readOnly = true) + public long productsCount() { + return marketSupport.countProducts(); + } + + public Product createProduct( + MarketMember seller, + String sourceTypeCode, + int sourceId, + String name, + String description, + long price, + long salePrice + ) { + return marketCreateProductUseCase.createProduct( + seller, + sourceTypeCode, + sourceId, + name, + description, + price, + salePrice + ); + } + + @Transactional(readOnly = true) + public Optional findMemberByUserName(String userName) { + return marketSupport.findMemberByUserName(userName); + } + + @Transactional + public RsData createCart(MarketMemberDto buyer) { + return marketCreateCartUseCase.createCart(buyer); + } + + @Transactional(readOnly = true) + public Optional findCartByBuyer(MarketMember buyer) { + return marketSupport.findCartByBuyer(buyer); + } + + @Transactional(readOnly = true) + public Optional findProductById(int id) { + return marketSupport.findProductById(id); + } + + @Transactional(readOnly = true) + public long ordersCount() { + return marketSupport.countOrders(); + } + + @Transactional + public RsData createOrder(Cart cart) { + return marketCreateOrderUseCase.createOrder(cart); + } + + @Transactional(readOnly = true) + public Optional findOrderById(int id) { + return marketSupport.findOrderById(id); + } + + @Transactional + public void requestPayment(Order order, long pgPaymentAmount) { + order.requestPayment(pgPaymentAmount); + } + + @Transactional + public void completeOrderPayment(int orderId) { + marketCompleteOrderPaymentUseCase.completePayment(orderId); + } + + @Transactional + public void cancelOrderRequestPayment(int orderId) { + marketCancelOrderRequestPaymentUseCase.cancelRequestPayment(orderId); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketSupport.java b/src/main/java/com/back/boundedContext/market/app/MarketSupport.java new file mode 100644 index 000000000..25e4662f4 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketSupport.java @@ -0,0 +1,49 @@ +package com.back.boundedContext.market.app; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.domain.Product; +import com.back.boundedContext.market.out.CartRepository; +import com.back.boundedContext.market.out.MarketMemberRepository; +import com.back.boundedContext.market.out.OrderRepository; +import com.back.boundedContext.market.out.ProductRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketSupport { + private final ProductRepository productRepository; + private final MarketMemberRepository marketMemberRepository; + private final CartRepository cartRepository; + private final OrderRepository orderRepository; + + public long countProducts() { + return productRepository.count(); + } + + public Optional findMemberByUserName(String userName) { + return marketMemberRepository.findByUserName(userName); + } + + public Optional findCartByBuyer(MarketMember buyer) { + return cartRepository.findByBuyer(buyer); + } + + public Optional findProductById(int id) { + return productRepository.findById(id); + } + + public long countOrders() { + return orderRepository.count(); + } + + public Optional findOrderById(int id) { + return orderRepository.findById(id); + } +} diff --git a/src/main/java/com/back/boundedContext/market/app/MarketSyncMemberUseCase.java b/src/main/java/com/back/boundedContext/market/app/MarketSyncMemberUseCase.java new file mode 100644 index 000000000..968505cd3 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/app/MarketSyncMemberUseCase.java @@ -0,0 +1,43 @@ +package com.back.boundedContext.market.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.out.MarketMemberRepository; +import com.back.global.eventPublisher.EventPublisher; +import com.back.shared.market.event.MarketMemberCreatedEvent; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MarketSyncMemberUseCase { + private final MarketMemberRepository marketMemberRepository; + private final EventPublisher eventPublisher; + + public MarketMember syncMember(MemberDto memberDto) { + boolean isNew = !marketMemberRepository.existsById(memberDto.getId()); + + MarketMember _member = marketMemberRepository.save( + new MarketMember( + memberDto.getId(), + memberDto.getCreateTime(), + memberDto.getModifyTime(), + memberDto.getUserName(), + "", + memberDto.getNickName(), + memberDto.getActivityScore()) + ); + + if (isNew) { + eventPublisher.publish( + new MarketMemberCreatedEvent( + _member.toDto() + ) + ); + } + + return _member; + } +} diff --git a/src/main/java/com/back/boundedContext/market/domain/Cart.java b/src/main/java/com/back/boundedContext/market/domain/Cart.java new file mode 100644 index 000000000..310eeb332 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/Cart.java @@ -0,0 +1,47 @@ +package com.back.boundedContext.market.domain; + +import java.util.ArrayList; +import java.util.List; + +import com.back.global.jpa.entity.BaseManualAndTime; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_CART") +@NoArgsConstructor +@Getter +public class Cart extends BaseManualAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private MarketMember buyer; + @OneToMany(mappedBy = "cart", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) + private List items = new ArrayList<>(); + + private int itemsCount; + + public Cart(MarketMember buyer) { + super(buyer.getId()); + this.buyer = buyer; + } + + public boolean hasItems() { + return itemsCount > 0; + } + + public void addItem(Product product) { + CartItem cartItem = new CartItem(this, product); + this.getItems().add(cartItem); + itemsCount++; + } + + public void clearItems() { + this.getItems().clear(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/boundedContext/market/domain/CartItem.java b/src/main/java/com/back/boundedContext/market/domain/CartItem.java new file mode 100644 index 000000000..1f0ae620a --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/CartItem.java @@ -0,0 +1,24 @@ +package com.back.boundedContext.market.domain; + +import com.back.global.jpa.entity.BaseIdAndTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_CART_ITEM") +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class CartItem extends BaseIdAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private Cart cart; + @ManyToOne(fetch = FetchType.LAZY) + private Product product; + +} diff --git a/src/main/java/com/back/boundedContext/market/domain/MarketMember.java b/src/main/java/com/back/boundedContext/market/domain/MarketMember.java new file mode 100644 index 000000000..ccd072c45 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/MarketMember.java @@ -0,0 +1,32 @@ +package com.back.boundedContext.market.domain; + +import java.time.LocalDateTime; + +import com.back.shared.market.dto.MarketMemberDto; +import com.back.shared.member.domain.ReplicaMember; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_MEMBER") +@Getter +@NoArgsConstructor +public class MarketMember extends ReplicaMember { + public MarketMember(int id, LocalDateTime createTime, LocalDateTime modifyTime, String userName, String password, String nickName, int activityScore) { + super(id, createTime, modifyTime, userName, password, nickName, activityScore); + } + + public MarketMemberDto toDto() { + return new MarketMemberDto( + getId(), + getCreateTime(), + getModifyTime(), + getUserName(), + getNickName(), + getActivityScore() + ); + } +} diff --git a/src/main/java/com/back/boundedContext/market/domain/MarketPolicy.java b/src/main/java/com/back/boundedContext/market/domain/MarketPolicy.java new file mode 100644 index 000000000..799857e22 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/MarketPolicy.java @@ -0,0 +1,22 @@ +package com.back.boundedContext.market.domain; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class MarketPolicy { + public static double PRODUCT_PAYOUT_RATE; + + @Value("${custom.market.product.payoutRate}") + public void setProductPayoutRate(double rate) { + PRODUCT_PAYOUT_RATE = rate; + } + + public static long calculatePayoutFee(long salePrice, double payoutRate) { + return salePrice - calculateSalePriceWithoutFee(salePrice, payoutRate); + } + + public static long calculateSalePriceWithoutFee(long salePrice, double payoutRate) { + return Math.round(salePrice * payoutRate / 100); + } +} diff --git a/src/main/java/com/back/boundedContext/market/domain/Order.java b/src/main/java/com/back/boundedContext/market/domain/Order.java new file mode 100644 index 000000000..203c9a555 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/Order.java @@ -0,0 +1,109 @@ +package com.back.boundedContext.market.domain; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.back.global.jpa.entity.BaseIdAndTime; +import com.back.shared.market.dto.OrderDto; +import com.back.shared.market.event.MarketOrderPaymentCompletedEvent; +import com.back.shared.market.event.MarketOrderPaymentRequestedEvent; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_ORDER") +@NoArgsConstructor +@Getter +public class Order extends BaseIdAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private MarketMember buyer; + private LocalDateTime cancelTime; + private LocalDateTime requestPaymentTime; + private LocalDateTime paymentTime; + private long price; + private long salePrice; + + @OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) + private List items = new ArrayList<>(); + + public Order(Cart cart) { + this.buyer = cart.getBuyer(); + + cart.getItems().forEach(item -> { + addItem(item.getProduct()); + }); + } + + public OrderDto toDto() { + return new OrderDto( + getId(), + getCreateTime(), + getModifyTime(), + buyer.getId(), + buyer.getNickName(), + price, + salePrice, + requestPaymentTime, + paymentTime + ); + } + + public void addItem(Product product) { + OrderItem orderItem = new OrderItem( + this, + product, + product.getName(), + product.getPrice(), + product.getSalePrice() + ); + + items.add(orderItem); + + price += product.getPrice(); + salePrice += product.getSalePrice(); + } + + public void completePayment() { + paymentTime = LocalDateTime.now(); + publishEvent( + new MarketOrderPaymentCompletedEvent( + toDto() + ) + ); + } + + public boolean isPaid() { + return paymentTime != null; + } + + public void requestPayment(long pgPaymentAmount) { + requestPaymentTime = LocalDateTime.now(); + + publishEvent( + new MarketOrderPaymentRequestedEvent( + toDto(), + pgPaymentAmount + ) + ); + } + + public void cancelRequestPayment() { + requestPaymentTime = null; + } + + public boolean isCanceled() { + return cancelTime != null; + } + + public boolean isPaymentInProgress() { + return requestPaymentTime != null && paymentTime == null && cancelTime == null; + } +} diff --git a/src/main/java/com/back/boundedContext/market/domain/OrderItem.java b/src/main/java/com/back/boundedContext/market/domain/OrderItem.java new file mode 100644 index 000000000..336e08b55 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/OrderItem.java @@ -0,0 +1,67 @@ +package com.back.boundedContext.market.domain; + +import com.back.global.jpa.entity.BaseIdAndTime; +import com.back.shared.market.dto.OrderItemDto; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_ORDER_ITEM") +@NoArgsConstructor +@Getter +public class OrderItem extends BaseIdAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private Order order; + + @ManyToOne(fetch = FetchType.LAZY) + private Product product; + + private String productName; + + private long price; + + private long salePrice; + + private double payoutRate = MarketPolicy.PRODUCT_PAYOUT_RATE; + + public OrderItem(Order order, Product product, String productName, long price, long salePrice) { + this.order = order; + this.product = product; + this.productName = productName; + this.price = price; + this.salePrice = salePrice; + } + + public OrderItemDto toDto() { + return new OrderItemDto( + getId(), + getCreateTime(), + getModifyTime(), + order.getId(), + order.getBuyer().getId(), + order.getBuyer().getNickName(), + product.getSeller().getId(), + product.getSeller().getNickName(), + product.getId(), + productName, + price, + salePrice, + payoutRate, + getPayoutFee(), + getSalePriceWithoutFee() + ); + } + + public long getPayoutFee() { + return MarketPolicy.calculatePayoutFee(getSalePrice(), getPayoutRate()); + } + + public long getSalePriceWithoutFee() { + return MarketPolicy.calculateSalePriceWithoutFee(getSalePrice(), getPayoutRate()); + } +} diff --git a/src/main/java/com/back/boundedContext/market/domain/Product.java b/src/main/java/com/back/boundedContext/market/domain/Product.java new file mode 100644 index 000000000..69375d9ed --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/domain/Product.java @@ -0,0 +1,27 @@ +package com.back.boundedContext.market.domain; + +import com.back.global.jpa.entity.BaseIdAndTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MARKET_PRODUCT") +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class Product extends BaseIdAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private MarketMember seller; + private String sourceTypeCode; + private int sourceId; + private String name; + private String description; + private long price; + private long salePrice; +} diff --git a/src/main/java/com/back/boundedContext/market/in/ApiV1OrderController.java b/src/main/java/com/back/boundedContext/market/in/ApiV1OrderController.java new file mode 100644 index 000000000..ccf5acbe1 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/in/ApiV1OrderController.java @@ -0,0 +1,105 @@ +package com.back.boundedContext.market.in; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import com.back.boundedContext.market.app.MarketFacade; +import com.back.boundedContext.market.domain.Order; +import com.back.boundedContext.market.domain.OrderItem; +import com.back.global.RsData.RsData; +import com.back.global.exception.DomainException; +import com.back.shared.cash.out.CashApiClient; +import com.back.shared.market.dto.OrderItemDto; +import com.back.shared.market.out.TossPaymentsService; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/market/orders") +@RequiredArgsConstructor +public class ApiV1OrderController { + private final MarketFacade marketFacade; + private final TossPaymentsService tossPaymentsService; + private final CashApiClient cashApiClient; + + public record ConfirmPaymentByTossPaymentReqBody( + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotNull int amount + ){ + } + + @CrossOrigin( + origins = { + "https://cdpn.io", + "https://codepen.io" + }, + allowedHeaders = "*", + methods = {RequestMethod.POST} + ) + @PostMapping("/{id}/payment/confirm/by/tossPayments") + @Transactional + public RsData confirmPaymentByTossPayments( + @PathVariable int id, + @Valid @RequestBody ConfirmPaymentByTossPaymentReqBody reqBody + ) { + Order order = marketFacade.findOrderById(id).get(); + + if (order.isCanceled()) { + throw new DomainException("400-1", "이미 취소된 주문입니다."); + } + + if (order.isPaymentInProgress()) { + throw new DomainException("400-2", "이미 결제 진행중인 주문입니다."); + } + + if (order.isPaid()) { + throw new DomainException("400-3", "이미 결제된 주문입니다."); + } + + long walletBalance = cashApiClient.getBalanceByHolderId(order.getBuyer().getId()); + + if (order.getSalePrice() > walletBalance + reqBody.amount) { + throw new DomainException("400-4", "결제를 완료하기에 결제 금액이 부족합니다."); + } + + if (order.getId() != Integer.parseInt(reqBody.orderId.split("-", 3)[1])) { + throw new DomainException("400-5", "주문번호가 일치하지 않습니다."); + } + + tossPaymentsService.confirmCardPayment( + reqBody.paymentKey(), + reqBody.orderId(), + reqBody.amount() + ); + + marketFacade.requestPayment(order, reqBody.amount()); + + return new RsData<>("202-1", "결제 프로세스가 시작되었습니다."); + } + + @GetMapping("/{id}/items") + @Transactional(readOnly = true) + public List getItems(@PathVariable int id) { + return marketFacade + .findOrderById(id) + .get() + .getItems() + .stream() + .map(OrderItem::toDto) + .toList(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/back/boundedContext/market/in/MarketDataInit.java b/src/main/java/com/back/boundedContext/market/in/MarketDataInit.java new file mode 100644 index 000000000..b08ef64ea --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/in/MarketDataInit.java @@ -0,0 +1,196 @@ +package com.back.boundedContext.market.in; + +import java.util.List; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.market.app.MarketFacade; +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.MarketMember; +import com.back.boundedContext.market.domain.Product; +import com.back.shared.post.dto.PostDto; +import com.back.shared.post.out.PostApiClient; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class MarketDataInit { + private final MarketDataInit self; + private final MarketFacade marketFacade; + private final PostApiClient postApiClient; + + public MarketDataInit( + @Lazy MarketDataInit self, + MarketFacade marketFacade, + PostApiClient postApiClient) { + this.self = self; + this.marketFacade = marketFacade; + this.postApiClient = postApiClient; + } + + @Bean + @Order(3) + public ApplicationRunner marketDataInitApplicationRunner() { + return args -> { + self.makeBaseProducts(); + self.makeBaseCartItems(); + self.makeBaseOrders(); + self.makeBasePaidOrders(); + }; + } + + @Transactional + public void makeBaseProducts() { + if (marketFacade.productsCount() > 0) return; + + List posts = postApiClient.getItems(); + + PostDto post1 = posts.get(5); + PostDto post2 = posts.get(4); + PostDto post3 = posts.get(3); + PostDto post4 = posts.get(2); + PostDto post5 = posts.get(1); + PostDto post6 = posts.get(0); + + MarketMember user1MarketMember = marketFacade.findMemberByUserName("user1").get(); + MarketMember user2MarketMember = marketFacade.findMemberByUserName("user2").get(); + MarketMember user3MarketMember = marketFacade.findMemberByUserName("user3").get(); + + Product product1 = marketFacade.createProduct( + user1MarketMember, + post1.getModelTypeCode(), + post1.getId(), + post1.getTitle(), + post1.getContent(), + 10_000, + 10_000 + ); + + Product product2 = marketFacade.createProduct( + user1MarketMember, + post2.getModelTypeCode(), + post2.getId(), + post2.getTitle(), + post2.getContent(), + 15_000, + 15_000 + ); + + Product product3 = marketFacade.createProduct( + user1MarketMember, + post3.getModelTypeCode(), + post3.getId(), + post3.getTitle(), + post3.getContent(), + 20_000, + 20_000 + ); + + Product product4 = marketFacade.createProduct( + user2MarketMember, + post4.getModelTypeCode(), + post4.getId(), + post4.getTitle(), + post4.getContent(), + 25_000, + 25_000 + ); + + Product product5 = marketFacade.createProduct( + user2MarketMember, + post5.getModelTypeCode(), + post5.getId(), + post5.getTitle(), + post5.getContent(), + 30_000, + 30_000 + ); + + Product product6 = marketFacade.createProduct( + user3MarketMember, + post6.getModelTypeCode(), + post6.getId(), + post6.getTitle(), + post6.getContent(), + 35_000, + 35_000 + ); + } + + @Transactional + public void makeBaseCartItems() { + MarketMember user1Member = marketFacade.findMemberByUserName("user1").get(); + MarketMember user2Member = marketFacade.findMemberByUserName("user2").get(); + MarketMember user3Member = marketFacade.findMemberByUserName("user3").get(); + + Cart cart1 = marketFacade.findCartByBuyer(user1Member).get(); + Cart cart2 = marketFacade.findCartByBuyer(user2Member).get(); + Cart cart3 = marketFacade.findCartByBuyer(user3Member).get(); + + Product product1 = marketFacade.findProductById(1).get(); + Product product2 = marketFacade.findProductById(2).get(); + Product product3 = marketFacade.findProductById(3).get(); + Product product4 = marketFacade.findProductById(4).get(); + Product product5 = marketFacade.findProductById(5).get(); + Product product6 = marketFacade.findProductById(6).get(); + + if (cart1.hasItems()) return; + + cart1.addItem(product1); + cart1.addItem(product2); + cart1.addItem(product3); + cart1.addItem(product4); + + cart2.addItem(product1); + cart2.addItem(product2); + cart2.addItem(product3); + + cart3.addItem(product1); + cart3.addItem(product2); + } + + @Transactional + public void makeBaseOrders() { + if (marketFacade.ordersCount() > 0) return; + + MarketMember user1Member = marketFacade.findMemberByUserName("user1").get(); + MarketMember user2Member = marketFacade.findMemberByUserName("user2").get(); + MarketMember user3Member = marketFacade.findMemberByUserName("user3").get(); + + Cart cart1 = marketFacade.findCartByBuyer(user1Member).get(); + Cart cart2 = marketFacade.findCartByBuyer(user2Member).get(); + Cart cart3 = marketFacade.findCartByBuyer(user3Member).get(); + + com.back.boundedContext.market.domain.Order order1 = marketFacade.createOrder(cart1).getData(); + com.back.boundedContext.market.domain.Order order2 = marketFacade.createOrder(cart2).getData(); + com.back.boundedContext.market.domain.Order order3 = marketFacade.createOrder(cart3).getData(); + + // 주문 생성 때문에 cart1이 비어있기 때문에 다시 아이템 추가 + Product product1 = marketFacade.findProductById(1).get(); + Product product2 = marketFacade.findProductById(2).get(); + Product product3 = marketFacade.findProductById(3).get(); + Product product4 = marketFacade.findProductById(4).get(); + Product product5 = marketFacade.findProductById(5).get(); + Product product6 = marketFacade.findProductById(6).get(); + + cart1.addItem(product1); + cart1.addItem(product2); + cart1.addItem(product3); + cart1.addItem(product4); + } + + @Transactional + public void makeBasePaidOrders() { + com.back.boundedContext.market.domain.Order order1 = marketFacade.findOrderById(1).get(); + + if (order1.isPaid()) return; + + marketFacade.requestPayment(order1, 0); + } +} diff --git a/src/main/java/com/back/boundedContext/market/in/MarketEventListener.java b/src/main/java/com/back/boundedContext/market/in/MarketEventListener.java new file mode 100644 index 000000000..5e306591e --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/in/MarketEventListener.java @@ -0,0 +1,52 @@ +package com.back.boundedContext.market.in; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.back.boundedContext.market.app.MarketFacade; +import com.back.shared.cash.event.CashOrderPaymentFailedEvent; +import com.back.shared.cash.event.CashOrderPaymentSucceededEvent; +import com.back.shared.market.event.MarketMemberCreatedEvent; +import com.back.shared.member.event.MemberJoinedEvent; +import com.back.shared.member.event.MemberModifiedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MarketEventListener { + private final MarketFacade marketFacade; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(MemberJoinedEvent event) { + marketFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(MemberModifiedEvent event) { + marketFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(MarketMemberCreatedEvent event) { + marketFacade.createCart(event.getMarketMemberDto()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(CashOrderPaymentSucceededEvent event) { + marketFacade.completeOrderPayment(event.getOrderDto().getId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(CashOrderPaymentFailedEvent event) { + marketFacade.cancelOrderRequestPayment(event.getOrderDto().getId()); + } +} diff --git a/src/main/java/com/back/boundedContext/market/out/CartRepository.java b/src/main/java/com/back/boundedContext/market/out/CartRepository.java new file mode 100644 index 000000000..d69ba2628 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/out/CartRepository.java @@ -0,0 +1,12 @@ +package com.back.boundedContext.market.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.market.domain.Cart; +import com.back.boundedContext.market.domain.MarketMember; + +public interface CartRepository extends JpaRepository { + Optional findByBuyer(MarketMember buyer); +} diff --git a/src/main/java/com/back/boundedContext/market/out/MarketMemberRepository.java b/src/main/java/com/back/boundedContext/market/out/MarketMemberRepository.java new file mode 100644 index 000000000..762ffd9d1 --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/out/MarketMemberRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.market.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.market.domain.MarketMember; + +public interface MarketMemberRepository extends JpaRepository { + Optional findByUserName(String userName); +} diff --git a/src/main/java/com/back/boundedContext/market/out/OrderRepository.java b/src/main/java/com/back/boundedContext/market/out/OrderRepository.java new file mode 100644 index 000000000..9eca9cc0a --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/out/OrderRepository.java @@ -0,0 +1,8 @@ +package com.back.boundedContext.market.out; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.market.domain.Order; + +public interface OrderRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/boundedContext/market/out/ProductRepository.java b/src/main/java/com/back/boundedContext/market/out/ProductRepository.java new file mode 100644 index 000000000..55c50afcd --- /dev/null +++ b/src/main/java/com/back/boundedContext/market/out/ProductRepository.java @@ -0,0 +1,8 @@ +package com.back.boundedContext.market.out; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.market.domain.Product; + +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/boundedContext/member/app/MemberFacade.java b/src/main/java/com/back/boundedContext/member/app/MemberFacade.java new file mode 100644 index 000000000..ebbd67245 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/app/MemberFacade.java @@ -0,0 +1,46 @@ +package com.back.boundedContext.member.app; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.member.domain.Member; +import com.back.global.RsData.RsData; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberJoinUseCase memberJoinUseCase; + private final MemberSupport memberSupport; + private final MemberGetRandomSecureTip memberGetRandomSecureTip; + + @Transactional(readOnly = true) + public long count() { + return memberSupport.count(); + } + + @Transactional + public RsData join(String userName, String password, String nickName) { + + return memberJoinUseCase.join(userName, password, nickName); + } + + @Transactional(readOnly = true) + public Optional findByUserName(String userName) { + + return memberSupport.findByUserName(userName); + } + + @Transactional(readOnly = true) + public Optional findById(int id) { + return memberSupport.findById(id); + } + + public String getRandomSecureTip() { + return memberGetRandomSecureTip.getRandomSecureTip(); + } +} diff --git a/src/main/java/com/back/boundedContext/member/app/MemberGetRandomSecureTip.java b/src/main/java/com/back/boundedContext/member/app/MemberGetRandomSecureTip.java new file mode 100644 index 000000000..130560311 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/app/MemberGetRandomSecureTip.java @@ -0,0 +1,18 @@ +package com.back.boundedContext.member.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.member.domain.MemberPolicy; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberGetRandomSecureTip { + private final MemberPolicy memberPolicy; + + public String getRandomSecureTip() { + return "비밍번호의 유효기간은 %d일 입니다." + .formatted(memberPolicy.getNeedToChangePasswordDays()); + } +} diff --git a/src/main/java/com/back/boundedContext/member/app/MemberJoinUseCase.java b/src/main/java/com/back/boundedContext/member/app/MemberJoinUseCase.java new file mode 100644 index 000000000..17a663f09 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/app/MemberJoinUseCase.java @@ -0,0 +1,31 @@ +package com.back.boundedContext.member.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.member.domain.Member; +import com.back.boundedContext.member.out.MemberRepository; +import com.back.global.RsData.RsData; +import com.back.global.eventPublisher.EventPublisher; +import com.back.global.exception.DomainException; +import com.back.shared.member.event.MemberJoinedEvent; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberJoinUseCase { + private final MemberRepository memberRepository; + private final EventPublisher eventPublisher; + + public RsData join(String userName, String password, String nickName) { + memberRepository.findByUserName(userName).ifPresent(m -> { + throw new DomainException("409-1", "이미 존재하는 username 입니다."); + }); + + Member member = memberRepository.save(new Member(userName, password, nickName)); + + eventPublisher.publish(new MemberJoinedEvent(member.toDto())); + + return new RsData<>("201-1", "%d번 회원이 생성되었습니다.".formatted(member.getId()), member); + } +} diff --git a/src/main/java/com/back/boundedContext/member/app/MemberSupport.java b/src/main/java/com/back/boundedContext/member/app/MemberSupport.java new file mode 100644 index 000000000..0025b5a65 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/app/MemberSupport.java @@ -0,0 +1,29 @@ +package com.back.boundedContext.member.app; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.member.domain.Member; +import com.back.boundedContext.member.out.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberSupport { + private final MemberRepository memberRepository; + + public long count() { + return memberRepository.count(); + } + + public Optional findByUserName(String username) { + return memberRepository.findByUserName(username); + } + + public Optional findById(int id) { + return memberRepository.findById(id); + } + +} diff --git a/src/main/java/com/back/boundedContext/member/domain/Member.java b/src/main/java/com/back/boundedContext/member/domain/Member.java new file mode 100644 index 000000000..b9700e261 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/domain/Member.java @@ -0,0 +1,44 @@ +package com.back.boundedContext.member.domain; + +import com.back.shared.member.domain.SourceMember; +import com.back.shared.member.dto.MemberDto; +import com.back.shared.member.event.MemberModifiedEvent; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MEMBER_MEMBER") +@Getter +@NoArgsConstructor +public class Member extends SourceMember { + + public Member(String userName, String password, String nickName) { + super(userName, password, nickName); + } + + public MemberDto toDto() { + return new MemberDto( + getId(), + getCreateTime(), + getModifyTime(), + getUserName(), + getNickName(), + getActivityScore() + ); + } + + public int increaseActivityScore(int amount) { + if (amount == 0) return getActivityScore(); + + setActivityScore(getActivityScore() + amount); + + publishEvent( + new MemberModifiedEvent(toDto()) + ); + + return getActivityScore(); + } +} diff --git a/src/main/java/com/back/boundedContext/member/domain/MemberPolicy.java b/src/main/java/com/back/boundedContext/member/domain/MemberPolicy.java new file mode 100644 index 000000000..448a39f71 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/domain/MemberPolicy.java @@ -0,0 +1,31 @@ +package com.back.boundedContext.member.domain; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class MemberPolicy { + private static int PASSWORD_CHANGE_DAYS; + + @Value("${custom.member.password.changeDays}") + public void setPasswordChangeDays(int days) { + PASSWORD_CHANGE_DAYS = days; + } + + public Duration getNeedToChangePasswordPeriod() { + return Duration.ofDays(PASSWORD_CHANGE_DAYS); + } + + public int getNeedToChangePasswordDays() { + return PASSWORD_CHANGE_DAYS; + } + + public boolean isNeedToChangePassword(LocalDateTime lastChangedAt) { + if (lastChangedAt == null) return true; + + return lastChangedAt.plusDays(PASSWORD_CHANGE_DAYS).isBefore(LocalDateTime.now()); + } +} diff --git a/src/main/java/com/back/boundedContext/member/in/ApiV1MemberController.java b/src/main/java/com/back/boundedContext/member/in/ApiV1MemberController.java new file mode 100644 index 000000000..348e89675 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/in/ApiV1MemberController.java @@ -0,0 +1,23 @@ +package com.back.boundedContext.member.in; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.boundedContext.member.app.MemberFacade; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/member/members") +@RequiredArgsConstructor +public class ApiV1MemberController { + private final MemberFacade memberFacade; + + @GetMapping("randomSecureTip") + @Transactional(readOnly = true) + public String getRandomSecureTip() { + return memberFacade.getRandomSecureTip(); + } +} diff --git a/src/main/java/com/back/boundedContext/member/in/MemberDataInit.java b/src/main/java/com/back/boundedContext/member/in/MemberDataInit.java new file mode 100644 index 000000000..ed3b43da1 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/in/MemberDataInit.java @@ -0,0 +1,47 @@ +package com.back.boundedContext.member.in; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.member.app.MemberFacade; +import com.back.boundedContext.member.domain.Member; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class MemberDataInit { + private final MemberDataInit self; + private final MemberFacade memberFacade; + + public MemberDataInit(@Lazy MemberDataInit self, MemberFacade memberFacade) { + this.self = self; + this.memberFacade = memberFacade; + } + + @Bean + @Order(1) + public ApplicationRunner memberDataInitApplicationRunner() { + return args -> { + self.makeBaseMembers(); + }; + } + + + @Transactional + public void makeBaseMembers() { + if (memberFacade.count() > 0) return; + + Member systemMember = memberFacade.join("system", "1234", "시스템").getData(); + Member holdingMember = memberFacade.join("holding", "1234", "홀딩").getData(); + Member adminMember = memberFacade.join("admin", "1234", "관리자").getData(); + Member user1Member = memberFacade.join("user1", "1234", "유저1").getData(); + Member user2Member = memberFacade.join("user2", "1234", "유저2").getData(); + Member user3Member = memberFacade.join("user3", "1234", "유저3").getData(); + } + +} diff --git a/src/main/java/com/back/boundedContext/member/in/MemberEventListener.java b/src/main/java/com/back/boundedContext/member/in/MemberEventListener.java new file mode 100644 index 000000000..1cb1b976d --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/in/MemberEventListener.java @@ -0,0 +1,36 @@ +package com.back.boundedContext.member.in; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.back.boundedContext.member.domain.Member; +import com.back.boundedContext.member.app.MemberFacade; +import com.back.shared.post.event.PostCommentCreatedEvent; +import com.back.shared.post.event.PostCreatedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MemberEventListener { + private final MemberFacade memberFacade; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(PostCreatedEvent event) { + Member member = memberFacade.findById(event.getPostDto().getAuthorId()).get(); + + member.increaseActivityScore(3); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(PostCommentCreatedEvent event) { + Member member = memberFacade.findById(event.getPostCommentDto().getAuthorId()).get(); + + member.increaseActivityScore(1); + } +} diff --git a/src/main/java/com/back/boundedContext/member/out/MemberRepository.java b/src/main/java/com/back/boundedContext/member/out/MemberRepository.java new file mode 100644 index 000000000..432fc5751 --- /dev/null +++ b/src/main/java/com/back/boundedContext/member/out/MemberRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.member.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.member.domain.Member; + +public interface MemberRepository extends JpaRepository { + Optional findByUserName(String userName); +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutAddPayoutCandidateItemUseCase.java b/src/main/java/com/back/boundedContext/payout/app/PayoutAddPayoutCandidateItemUseCase.java new file mode 100644 index 000000000..1c24d1e3a --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutAddPayoutCandidateItemUseCase.java @@ -0,0 +1,82 @@ +package com.back.boundedContext.payout.app; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.PayoutCandidateItem; +import com.back.boundedContext.payout.domain.PayoutEventType; +import com.back.boundedContext.payout.domain.PayoutMember; +import com.back.boundedContext.payout.out.PayoutCandidateItemRepository; +import com.back.shared.market.dto.OrderDto; +import com.back.shared.market.dto.OrderItemDto; +import com.back.shared.market.out.MarketApiClient; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PayoutAddPayoutCandidateItemUseCase { + private final MarketApiClient marketApiClient; + private final PayoutSupport payoutSupport; + private final PayoutCandidateItemRepository payoutCandidateItemRepository; + + public void addPayoutCandidateItems(OrderDto orderDto) { + marketApiClient.getOrderItems(orderDto.getId()) + .forEach(orderItemDto -> makePayoutCandidateItems(orderDto, orderItemDto)); + } + + private void makePayoutCandidateItems( + OrderDto orderDto, + OrderItemDto orderItemDto + ) { + PayoutMember system = payoutSupport.findSystemMember().get(); + PayoutMember buyer = payoutSupport.findMemberById(orderItemDto.getBuyerId()).get(); + PayoutMember seller = payoutSupport.findMemberById(orderItemDto.getSellerId()).get(); + + makePayoutCandidateItem( + PayoutEventType.정산__상품판매_수수료, + orderItemDto.getModelTypeCode(), + orderItemDto.getId(), + orderDto.getPaymentTime(), + buyer, + system, + orderItemDto.getPayoutFee() + ); + + makePayoutCandidateItem( + PayoutEventType.정산__상품판매_대금, + orderItemDto.getModelTypeCode(), + orderItemDto.getId(), + orderDto.getPaymentTime(), + buyer, + seller, + orderItemDto.getSalePriceWithoutFee() + ); + + } + + private void makePayoutCandidateItem( + PayoutEventType eventType, + String relTypeCode, + int relId, + LocalDateTime paymentTime, + PayoutMember payer, + PayoutMember payee, + long amount + ) { + PayoutCandidateItem payoutCandidateItem = new PayoutCandidateItem( + eventType, + relTypeCode, + relId, + paymentTime, + payer, + payee, + amount + ); + + payoutCandidateItemRepository.save(payoutCandidateItem); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutCollectPayoutItemsMoreUseCase.java b/src/main/java/com/back/boundedContext/payout/app/PayoutCollectPayoutItemsMoreUseCase.java new file mode 100644 index 000000000..8b31b8045 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutCollectPayoutItemsMoreUseCase.java @@ -0,0 +1,78 @@ +package com.back.boundedContext.payout.app; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.Payout; +import com.back.boundedContext.payout.domain.PayoutCandidateItem; +import com.back.boundedContext.payout.domain.PayoutItem; +import com.back.boundedContext.payout.domain.PayoutMember; +import com.back.boundedContext.payout.domain.PayoutPolicy; +import com.back.boundedContext.payout.out.PayoutCandidateItemRepository; +import com.back.boundedContext.payout.out.PayoutRepository; +import com.back.global.RsData.RsData; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PayoutCollectPayoutItemsMoreUseCase { + private final PayoutRepository payoutRepository; + private final PayoutCandidateItemRepository payoutCandidateItemRepository; + + public RsData collectPayoutItemsMore(int limit) { + List payoutReadyCandidateItems = findPayoutReadyCandidateItems(limit); + + if(payoutReadyCandidateItems.isEmpty()) { + return new RsData<>("200-1", "더 이상 정산에 추가할 항목이 없습니다.", 0); + } + + payoutReadyCandidateItems.stream() + .collect(Collectors.groupingBy(PayoutCandidateItem::getPayee)) + .forEach((payee, candidateItems) -> { + Payout payout = findActiveByPayee(payee).get(); + + candidateItems.forEach(candidateItem -> { + PayoutItem payoutItem = payout.addItem( + candidateItem.getEventType(), + candidateItem.getRelTypeCode(), + candidateItem.getRelId(), + candidateItem.getPaymentTime(), + candidateItem.getPayer(), + candidateItem.getPayee(), + candidateItem.getAmount() + ); + + candidateItem.setPayoutItem(payoutItem); + }); + }); + + return new RsData<>( + "201-1", + "%d건의 정산데이터가 생성되었습니다.".formatted(payoutReadyCandidateItems.size()), + payoutReadyCandidateItems.size() + ); + } + + private List findPayoutReadyCandidateItems(int limit) { + LocalDateTime daysAgo = LocalDateTime + .now() + .minusDays(PayoutPolicy.PAYOUT_READY_WAITING_DAYS) + .toLocalDate() + .atStartOfDay(); + + return payoutCandidateItemRepository.findByPayoutItemIsNullAndPaymentTimeBeforeOrderByPayeeAscIdAsc( + daysAgo, + PageRequest.of(0, limit) + ); + } + + private Optional findActiveByPayee(PayoutMember payee) { + return payoutRepository.findByPayeeAndPayoutDateIsNull(payee); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutCompletePayoutsMoreUseCase.java b/src/main/java/com/back/boundedContext/payout/app/PayoutCompletePayoutsMoreUseCase.java new file mode 100644 index 000000000..60a129886 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutCompletePayoutsMoreUseCase.java @@ -0,0 +1,40 @@ +package com.back.boundedContext.payout.app; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.Payout; +import com.back.boundedContext.payout.out.PayoutRepository; +import com.back.global.RsData.RsData; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PayoutCompletePayoutsMoreUseCase { + private final PayoutRepository payoutRepository; + + public RsData completePayoutsMore(int limit) { + + //정산 진행할 Payout 내역 불러오기 + List activePayouts = findActivePayouts(limit); + + if (activePayouts.isEmpty()) + return new RsData<>("200-1", "더 이상 정산할 정산내역이 없습니다.", 0); + + // 정산완료 처리 + activePayouts.forEach(Payout::completePayout); + + return new RsData<>( + "201-1", + "%d건의 정산이 처리되었습니다.".formatted(activePayouts.size()), + activePayouts.size() + ); + } + + private List findActivePayouts(int limit) { + return payoutRepository.findByPayoutDateIsNullAndAmountGreaterThanOrderByIdAsc(0, PageRequest.of(0, limit)); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutCreatePayoutUseCase.java b/src/main/java/com/back/boundedContext/payout/app/PayoutCreatePayoutUseCase.java new file mode 100644 index 000000000..fdcacba64 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutCreatePayoutUseCase.java @@ -0,0 +1,33 @@ +package com.back.boundedContext.payout.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.Payout; +import com.back.boundedContext.payout.domain.PayoutMember; +import com.back.boundedContext.payout.out.PayoutMemberRepository; +import com.back.boundedContext.payout.out.PayoutRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class PayoutCreatePayoutUseCase { + private final PayoutRepository payoutRepository; + private final PayoutMemberRepository payoutMemberRepository; + + + public Payout createPayout(int payeeId) { + PayoutMember _payee = payoutMemberRepository.getReferenceById(payeeId); + + Payout payout = payoutRepository.save( + new Payout( + _payee + ) + ); + + return payout; + } + +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutFacade.java b/src/main/java/com/back/boundedContext/payout/app/PayoutFacade.java new file mode 100644 index 000000000..aa2e87ffb --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutFacade.java @@ -0,0 +1,63 @@ +package com.back.boundedContext.payout.app; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.payout.domain.Payout; +import com.back.boundedContext.payout.domain.PayoutCandidateItem; +import com.back.global.RsData.RsData; +import com.back.shared.market.dto.OrderDto; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PayoutFacade { + private final PayoutSyncMemberUseCase payoutSyncMemberUseCase; + private final PayoutAddPayoutCandidateItemUseCase payoutAddPayoutCandidateItemUseCase; + private final PayoutCreatePayoutUseCase payoutCreatePayoutUseCase; + private final PayoutCollectPayoutItemsMoreUseCase payoutCollectPayoutItemsMoreUseCase; + private final PayoutSupport payoutSupport; + private final PayoutCompletePayoutsMoreUseCase payoutCompletePayoutsMoreUseCase; + + //회원 내역 동기화 + @Transactional + public void syncMember(MemberDto memberDto) { + payoutSyncMemberUseCase.syncMember(memberDto); + } + + //정산 목록을 담을 Payout 생성 + @Transactional + public Payout createPayout(int payeeId) { + return payoutCreatePayoutUseCase.createPayout(payeeId); + } + + //정산 대기 목록에 물품 올리기 + @Transactional + public void addPayoutCandidateItem(OrderDto orderDto) { + payoutAddPayoutCandidateItemUseCase.addPayoutCandidateItems(orderDto); + } + + //정산 집행 전 정산할 내역 불러오기 + @Transactional + public RsData collectPayoutItemsMore(int limit) { + return payoutCollectPayoutItemsMoreUseCase.collectPayoutItemsMore(limit); + } + + //정산 대기중인 상품 내역 불러오기 + @Transactional(readOnly = true) + public List findPayoutCandidateItems() { + return payoutSupport + .findPayoutCandidateItems(); + } + + //정산 집행 + @Transactional + public RsData completePayoutsMore(int limit) { + return payoutCompletePayoutsMoreUseCase.completePayoutsMore(limit); + } + +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutSupport.java b/src/main/java/com/back/boundedContext/payout/app/PayoutSupport.java new file mode 100644 index 000000000..dca168007 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutSupport.java @@ -0,0 +1,36 @@ +package com.back.boundedContext.payout.app; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.PayoutCandidateItem; +import com.back.boundedContext.payout.domain.PayoutMember; +import com.back.boundedContext.payout.out.PayoutCandidateItemRepository; +import com.back.boundedContext.payout.out.PayoutMemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PayoutSupport { + private final PayoutMemberRepository payoutMemberRepository; + private final PayoutCandidateItemRepository payoutCandidateItemRepository; + + public Optional findSystemMember() { + return payoutMemberRepository.findByUserName("system"); + } + + public Optional findHoldingMember() { + return payoutMemberRepository.findByUserName("holding"); + } + + public Optional findMemberById(int id) { + return payoutMemberRepository.findById(id); + } + + public List findPayoutCandidateItems() { + return payoutCandidateItemRepository.findAll(); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/app/PayoutSyncMemberUseCase.java b/src/main/java/com/back/boundedContext/payout/app/PayoutSyncMemberUseCase.java new file mode 100644 index 000000000..ac7897980 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/app/PayoutSyncMemberUseCase.java @@ -0,0 +1,42 @@ +package com.back.boundedContext.payout.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.payout.domain.PayoutMember; +import com.back.boundedContext.payout.out.PayoutMemberRepository; +import com.back.global.eventPublisher.EventPublisher; +import com.back.shared.member.dto.MemberDto; +import com.back.shared.payout.event.PayoutMemberCreatedEvent; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PayoutSyncMemberUseCase { + private final PayoutMemberRepository payoutMemberRepository; + private final EventPublisher eventPublisher; + + public PayoutMember syncMember(MemberDto memberDto) { + boolean isNew = !payoutMemberRepository.existsById(memberDto.getId()); + + PayoutMember _member = payoutMemberRepository.save( + new PayoutMember( + memberDto.getId(), + memberDto.getCreateTime(), + memberDto.getModifyTime(), + memberDto.getUserName(), + "", + memberDto.getNickName(), + memberDto.getActivityScore() + ) + ); + + if (isNew) { + eventPublisher.publish( + new PayoutMemberCreatedEvent(_member.toDto()) + ); + } + + return _member; + } +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/Payout.java b/src/main/java/com/back/boundedContext/payout/domain/Payout.java new file mode 100644 index 000000000..d20b5bc8e --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/Payout.java @@ -0,0 +1,76 @@ +package com.back.boundedContext.payout.domain; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.back.global.jpa.entity.BaseIdAndTime; +import com.back.shared.payout.dto.PayoutDto; +import com.back.shared.payout.event.PayoutCompletedEvent; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "PAYOUT_PAYOUT") +@NoArgsConstructor +@Getter +public class Payout extends BaseIdAndTime { + @ManyToOne(fetch = FetchType.LAZY) + private PayoutMember payee; + @Setter + private LocalDateTime payoutDate; + private long amount; + + @OneToMany(mappedBy = "payout", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) + private List items = new ArrayList<>(); + + public Payout(PayoutMember payee) { + this.payee = payee; + } + + public PayoutItem addItem(PayoutEventType eventType, String relTypeCode, int relId, + LocalDateTime payDate, PayoutMember payer, PayoutMember payee, long amount) { + PayoutItem payoutItem = new PayoutItem( + this, eventType, relTypeCode, relId, payDate, payer, payee, amount + ); + + items.add(payoutItem); + + this.amount += amount; + + return payoutItem; + } + + + public void completePayout() { + this.payoutDate = LocalDateTime.now(); + + publishEvent( + new PayoutCompletedEvent( + toDto() + ) + ); + } + + + public PayoutDto toDto() { + return new PayoutDto( + getId(), + getCreateTime(), + getModifyTime(), + payee.getId(), + payee.getNickName(), + payoutDate, + amount, + payee.isSystem() + ); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/PayoutCandidateItem.java b/src/main/java/com/back/boundedContext/payout/domain/PayoutCandidateItem.java new file mode 100644 index 000000000..8c3fc7ffc --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/PayoutCandidateItem.java @@ -0,0 +1,47 @@ +package com.back.boundedContext.payout.domain; + +import static jakarta.persistence.FetchType.*; + +import java.time.LocalDateTime; + +import com.back.global.jpa.entity.BaseIdAndTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "PAYOUT_PAYOUT_CANDIDATE_ITEM") +@NoArgsConstructor +@Getter +public class PayoutCandidateItem extends BaseIdAndTime { + @Enumerated(EnumType.STRING) + private PayoutEventType eventType; + String relTypeCode; + private int relId; + private LocalDateTime paymentTime; + @ManyToOne(fetch = LAZY) + private PayoutMember payer; + @ManyToOne(fetch = LAZY) + private PayoutMember payee; + private long amount; + @OneToOne(fetch = LAZY) + @Setter + private PayoutItem payoutItem; + + public PayoutCandidateItem(PayoutEventType eventType, String relTypeCode, int relId, LocalDateTime paymentTime, PayoutMember payer, PayoutMember payee, long amount) { + this.eventType = eventType; + this.relTypeCode = relTypeCode; + this.relId = relId; + this.paymentTime = paymentTime; + this.payer = payer; + this.payee = payee; + this.amount = amount; + } +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/PayoutEventType.java b/src/main/java/com/back/boundedContext/payout/domain/PayoutEventType.java new file mode 100644 index 000000000..254b4e8c1 --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/PayoutEventType.java @@ -0,0 +1,6 @@ +package com.back.boundedContext.payout.domain; + +public enum PayoutEventType { + 정산__상품판매_수수료, + 정산__상품판매_대금 +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/PayoutItem.java b/src/main/java/com/back/boundedContext/payout/domain/PayoutItem.java new file mode 100644 index 000000000..05b980b5f --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/PayoutItem.java @@ -0,0 +1,44 @@ +package com.back.boundedContext.payout.domain; + +import static jakarta.persistence.FetchType.*; + +import java.time.LocalDateTime; + +import com.back.global.jpa.entity.BaseIdAndTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "PAYOUT_PAYOUT_ITEM") +@NoArgsConstructor +public class PayoutItem extends BaseIdAndTime { + @ManyToOne(fetch = LAZY) + private Payout payout; + @Enumerated(EnumType.STRING) + private PayoutEventType eventType; + String relTypeCode; + private int relId; + private LocalDateTime paymentTime; + @ManyToOne(fetch = LAZY) + private PayoutMember payer; + @ManyToOne(fetch = LAZY) + private PayoutMember payee; + private long amount; + + public PayoutItem(Payout payout, PayoutEventType eventType, String relTypeCode, int relId, LocalDateTime payTime, PayoutMember payer, PayoutMember payee, long amount) { + this.payout = payout; + this.eventType = eventType; + this.relTypeCode = relTypeCode; + this.relId = relId; + this.paymentTime = payTime; + this.payer = payer; + this.payee = payee; + this.amount = amount; + } + +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/PayoutMember.java b/src/main/java/com/back/boundedContext/payout/domain/PayoutMember.java new file mode 100644 index 000000000..fd0fa63ee --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/PayoutMember.java @@ -0,0 +1,33 @@ +package com.back.boundedContext.payout.domain; + +import java.time.LocalDateTime; + +import com.back.shared.member.domain.ReplicaMember; +import com.back.shared.payout.dto.PayoutMemberDto; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "PAYOUT_MEMBER") +@Getter +@NoArgsConstructor +public class PayoutMember extends ReplicaMember { + public PayoutMember(int id, LocalDateTime createTime, LocalDateTime modifyTime, + String username, String password, String nickname, int activityScore) { + super(id, createTime, modifyTime, username, password, nickname, activityScore); + } + + public PayoutMemberDto toDto() { + return new PayoutMemberDto( + getId(), + getCreateTime(), + getModifyTime(), + getUserName(), + getNickName(), + getActivityScore() + ); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/domain/PayoutPolicy.java b/src/main/java/com/back/boundedContext/payout/domain/PayoutPolicy.java new file mode 100644 index 000000000..3ece5140d --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/domain/PayoutPolicy.java @@ -0,0 +1,14 @@ +package com.back.boundedContext.payout.domain; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PayoutPolicy { + public static int PAYOUT_READY_WAITING_DAYS; + + @Value("${custom.payout.readWaitingDays}") + public void setPayoutReadyWaitingDays(int payoutReadyWaitingDays) { + PAYOUT_READY_WAITING_DAYS = payoutReadyWaitingDays; + } +} diff --git a/src/main/java/com/back/boundedContext/payout/in/PayoutCollectItemsAndCompletePayoutsBatchJobConfig.java b/src/main/java/com/back/boundedContext/payout/in/PayoutCollectItemsAndCompletePayoutsBatchJobConfig.java new file mode 100644 index 000000000..b0050073a --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/in/PayoutCollectItemsAndCompletePayoutsBatchJobConfig.java @@ -0,0 +1,74 @@ +package com.back.boundedContext.payout.in; + +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.back.boundedContext.payout.app.PayoutFacade; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class PayoutCollectItemsAndCompletePayoutsBatchJobConfig { + private static final int CHUNK_SIZE = 10; + + private final PayoutFacade payoutFacade; + + public PayoutCollectItemsAndCompletePayoutsBatchJobConfig(PayoutFacade payoutFacade) { + this.payoutFacade = payoutFacade; + } + + @Bean + public Job payoutCollectItemsAndCompletePayoutsJob( + JobRepository jobRepository, + Step payoutCollectItemsStep, + Step payoutCompletePayouts + ) { + return new JobBuilder("payoutCollectItemsJob", jobRepository) + .start(payoutCollectItemsStep) + .next(payoutCompletePayouts) + .build(); + } + + //정산할 내역 정산 대기 목록에서 가져오기 스텝 + @Bean + public Step payoutCollectItemsStep(JobRepository jobRepository) { + return new StepBuilder("payoutCollectItemsStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + int processedCount = payoutFacade.collectPayoutItemsMore(CHUNK_SIZE).getData(); + + if (processedCount == 0) { + return RepeatStatus.FINISHED; + } + + contribution.incrementWriteCount(processedCount); + + return RepeatStatus.CONTINUABLE; + }) + .build(); + } + + //정산 집행 스텝 + @Bean + public Step payoutCompletePayouts(JobRepository jobRepository) { + return new StepBuilder("payoutCompletePayouts", jobRepository) + .tasklet((contribution, chunkContext) -> { + int processedCount = payoutFacade.completePayoutsMore(CHUNK_SIZE).getData(); + + if (processedCount == 0) { + return RepeatStatus.FINISHED; + } + + contribution.incrementWriteCount(processedCount); + + return RepeatStatus.CONTINUABLE; + }) + .build(); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/in/PayoutDataInit.java b/src/main/java/com/back/boundedContext/payout/in/PayoutDataInit.java new file mode 100644 index 000000000..0cff4ee8c --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/in/PayoutDataInit.java @@ -0,0 +1,102 @@ +package com.back.boundedContext.payout.in; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.parameters.InvalidJobParametersException; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.launch.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.launch.JobRestartException; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.payout.app.PayoutFacade; +import com.back.boundedContext.payout.domain.PayoutPolicy; +import com.back.standard.ut.Util; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class PayoutDataInit { + private final PayoutDataInit self; + private final PayoutFacade payoutFacade; + private final JobOperator jobOperator; + private final Job payoutCollectItemsAndCompletePayoutsJob; + + public PayoutDataInit( + @Lazy PayoutDataInit self, + PayoutFacade payoutFacade, + JobOperator jobOperator, + Job payoutCollectItemsAndCompletePayoutsJob + ) { + this.self = self; + this.payoutFacade = payoutFacade; + this.jobOperator = jobOperator; + this.payoutCollectItemsAndCompletePayoutsJob = payoutCollectItemsAndCompletePayoutsJob; + } + + @Bean + @Order(4) + public ApplicationRunner payoutDataInitApplicationRunner() { + return args -> { + self.forceMakePayoutReadyCandidatesItems(); + self.collectPayoutItemsMore(); + self.runCollectItemsAndCompletePayoutsBatchJob(); + self.completePayoutsMore(); + }; + } + + @Transactional + public void forceMakePayoutReadyCandidatesItems() { + payoutFacade.findPayoutCandidateItems().forEach(item -> { + Util.reflection.setField( + item, + "paymentTime", + LocalDateTime.now().minusDays(PayoutPolicy.PAYOUT_READY_WAITING_DAYS + 1) + ); + }); + } + + @Transactional + public void collectPayoutItemsMore() { + payoutFacade.collectPayoutItemsMore(2); + } + + @Transactional + public void completePayoutsMore() { + payoutFacade.completePayoutsMore(1); + } + + public void runCollectItemsAndCompletePayoutsBatchJob() { + JobParameters jobParameters = new JobParametersBuilder() + .addString( + "runDate", + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ) + .toJobParameters(); + + try { + JobExecution execution = jobOperator.start(payoutCollectItemsAndCompletePayoutsJob, jobParameters); + } catch (JobInstanceAlreadyCompleteException e) { + log.error("Job instance already complete", e); + } catch (JobExecutionAlreadyRunningException e) { + log.error("Job execution already running", e); + } catch (InvalidJobParametersException e) { + log.error("Invalid job parameters", e); + } catch (JobRestartException e) { + log.error("job restart exception", e); + } + } + +} diff --git a/src/main/java/com/back/boundedContext/payout/in/PayoutEventListener.java b/src/main/java/com/back/boundedContext/payout/in/PayoutEventListener.java new file mode 100644 index 000000000..58d128e7c --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/in/PayoutEventListener.java @@ -0,0 +1,53 @@ +package com.back.boundedContext.payout.in; + +import static org.springframework.transaction.annotation.Propagation.*; +import static org.springframework.transaction.event.TransactionPhase.*; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.back.boundedContext.payout.app.PayoutFacade; +import com.back.shared.market.event.MarketOrderPaymentCompletedEvent; +import com.back.shared.member.event.MemberJoinedEvent; +import com.back.shared.member.event.MemberModifiedEvent; +import com.back.shared.payout.event.PayoutCompletedEvent; +import com.back.shared.payout.event.PayoutMemberCreatedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PayoutEventListener { + private final PayoutFacade payoutFacade; + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MemberJoinedEvent event) { + payoutFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MemberModifiedEvent event) { + payoutFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(PayoutMemberCreatedEvent event) { + payoutFacade.createPayout(event.getPayoutMemberDto().getId()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(MarketOrderPaymentCompletedEvent event) { + payoutFacade.addPayoutCandidateItem(event.getOrderDto()); + } + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handle(PayoutCompletedEvent event) { + payoutFacade.createPayout(event.getPayoutDto().getPayeeId()); + } +} diff --git a/src/main/java/com/back/boundedContext/payout/in/PayoutScheduler.java b/src/main/java/com/back/boundedContext/payout/in/PayoutScheduler.java new file mode 100644 index 000000000..fd19741ba --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/in/PayoutScheduler.java @@ -0,0 +1,61 @@ +package com.back.boundedContext.payout.in; + +import java.time.LocalDateTime; + +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.parameters.InvalidJobParametersException; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.launch.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.launch.JobRestartException; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Profile("prod") +@Component +@RequiredArgsConstructor +public class PayoutScheduler { + private final JobOperator jobOperator; + private final Job payoutCollectItemsAndCompletePayoutsJob; + + // 매일 01:00 (KST) + @Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul") + public void runAt01() throws JobInstanceAlreadyCompleteException, InvalidJobParametersException, + JobExecutionAlreadyRunningException, JobRestartException { + runCollectItemsAndCompletePayoutsBatchJob(); + } + + // 매일 04:00 (KST) + @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") + public void runAt04() throws JobInstanceAlreadyCompleteException, InvalidJobParametersException, + JobExecutionAlreadyRunningException, JobRestartException { + runCollectItemsAndCompletePayoutsBatchJob(); + } + + // 매일 22:00 (KST) + @Scheduled(cron = "0 0 22 * * *", zone = "Asia/Seoul") + public void runAt22() throws JobInstanceAlreadyCompleteException, InvalidJobParametersException, + JobExecutionAlreadyRunningException, JobRestartException { + runCollectItemsAndCompletePayoutsBatchJob(); + } + + private void runCollectItemsAndCompletePayoutsBatchJob() throws JobInstanceAlreadyCompleteException, + InvalidJobParametersException, JobExecutionAlreadyRunningException, JobRestartException { + + JobParameters jobParameters = new JobParametersBuilder() + .addString( + "runDateTime", + LocalDateTime.now().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + .toJobParameters(); + + JobExecution execution = jobOperator.start(payoutCollectItemsAndCompletePayoutsJob, jobParameters); + } + +} diff --git a/src/main/java/com/back/boundedContext/payout/out/PayoutCandidateItemRepository.java b/src/main/java/com/back/boundedContext/payout/out/PayoutCandidateItemRepository.java new file mode 100644 index 000000000..1420bc91a --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/out/PayoutCandidateItemRepository.java @@ -0,0 +1,14 @@ +package com.back.boundedContext.payout.out; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.payout.domain.PayoutCandidateItem; + +public interface PayoutCandidateItemRepository extends JpaRepository { + List findByPayoutItemIsNullAndPaymentTimeBeforeOrderByPayeeAscIdAsc(LocalDateTime paymentTime, + Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/back/boundedContext/payout/out/PayoutMemberRepository.java b/src/main/java/com/back/boundedContext/payout/out/PayoutMemberRepository.java new file mode 100644 index 000000000..bcea3183c --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/out/PayoutMemberRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.payout.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.payout.domain.PayoutMember; + +public interface PayoutMemberRepository extends JpaRepository { + Optional findByUserName(String userName); +} diff --git a/src/main/java/com/back/boundedContext/payout/out/PayoutRepository.java b/src/main/java/com/back/boundedContext/payout/out/PayoutRepository.java new file mode 100644 index 000000000..ebe347e2d --- /dev/null +++ b/src/main/java/com/back/boundedContext/payout/out/PayoutRepository.java @@ -0,0 +1,16 @@ +package com.back.boundedContext.payout.out; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.payout.domain.Payout; +import com.back.boundedContext.payout.domain.PayoutMember; + +public interface PayoutRepository extends JpaRepository { + Optional findByPayeeAndPayoutDateIsNull(PayoutMember payee); + + List findByPayoutDateIsNullAndAmountGreaterThanOrderByIdAsc(long amount, Pageable pageable); +} diff --git a/src/main/java/com/back/boundedContext/post/app/PostFacade.java b/src/main/java/com/back/boundedContext/post/app/PostFacade.java new file mode 100644 index 000000000..9f8c9884e --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/app/PostFacade.java @@ -0,0 +1,53 @@ +package com.back.boundedContext.post.app; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.post.domain.Post; +import com.back.boundedContext.post.domain.PostMember; +import com.back.global.RsData.RsData; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PostFacade { + private final PostSupport postSupport; + private final PostSyncMemberUseCase postSyncMemberUseCase; + private final PostWriteUseCase postWriteUseCase; + + @Transactional(readOnly = true) + public long count() { + return postSupport.count(); + } + + @Transactional + public RsData write(PostMember author, String title, String content) { + + return postWriteUseCase.write(author, title, content); + } + + @Transactional(readOnly = true) + public Optional findById(int id) { + return postSupport.findById(id); + } + + @Transactional + public PostMember syncMember(MemberDto memberDto) { + return postSyncMemberUseCase.syncMember(memberDto); + } + + @Transactional(readOnly = true) + public Optional findPostMemberByUserName(String userName) { + return postSupport.findMemberByUserName(userName); + } + + @Transactional(readOnly = true) + public List findByOrderByIdDesc() { + return postSupport.findByOrderByIdDesc(); + } +} diff --git a/src/main/java/com/back/boundedContext/post/app/PostSupport.java b/src/main/java/com/back/boundedContext/post/app/PostSupport.java new file mode 100644 index 000000000..71b36c9da --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/app/PostSupport.java @@ -0,0 +1,40 @@ +package com.back.boundedContext.post.app; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.back.boundedContext.post.domain.Post; +import com.back.boundedContext.post.domain.PostMember; +import com.back.boundedContext.post.out.PostMemberRepository; +import com.back.boundedContext.post.out.PostRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostSupport { + private final PostRepository postRepository; + private final PostMemberRepository postMemberRepository; + + public long count() { + return postRepository.count(); + } + + public Optional findById(int id) { + return postRepository.findById(id); + } + + public Optional findMemberByUserName(String username) { + return postMemberRepository.findByUserName(username); + } + + public List findAll() { + return postRepository.findAll(); + } + + public List findByOrderByIdDesc() { + return postRepository.findByOrderByIdDesc(); + } +} diff --git a/src/main/java/com/back/boundedContext/post/app/PostSyncMemberUseCase.java b/src/main/java/com/back/boundedContext/post/app/PostSyncMemberUseCase.java new file mode 100644 index 000000000..649d9fa39 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/app/PostSyncMemberUseCase.java @@ -0,0 +1,29 @@ +package com.back.boundedContext.post.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.post.domain.PostMember; +import com.back.boundedContext.post.out.PostMemberRepository; +import com.back.shared.member.dto.MemberDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PostSyncMemberUseCase { + private final PostMemberRepository postMemberRepository; + + public PostMember syncMember(MemberDto memberDto) { + PostMember postMember = new PostMember( + memberDto.getId(), + memberDto.getCreateTime(), + memberDto.getModifyTime(), + memberDto.getUserName(), + "", + memberDto.getNickName(), + memberDto.getActivityScore() + ); + + return postMemberRepository.save(postMember); + } +} diff --git a/src/main/java/com/back/boundedContext/post/app/PostWriteUseCase.java b/src/main/java/com/back/boundedContext/post/app/PostWriteUseCase.java new file mode 100644 index 000000000..31a175a58 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/app/PostWriteUseCase.java @@ -0,0 +1,39 @@ +package com.back.boundedContext.post.app; + +import org.springframework.stereotype.Service; + +import com.back.boundedContext.post.domain.Post; +import com.back.boundedContext.post.domain.PostMember; +import com.back.boundedContext.post.out.PostRepository; +import com.back.global.RsData.RsData; +import com.back.global.eventPublisher.EventPublisher; +import com.back.shared.member.out.MemberApiClient; +import com.back.shared.post.event.PostCreatedEvent; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PostWriteUseCase { + + private final PostRepository postRepository; + private final EventPublisher eventPublisher; + private final MemberApiClient memberApiClient; + + public RsData write(PostMember author, String title, String content) { + Post post = postRepository.save(new Post(author, title, content)); + + eventPublisher.publish( + new PostCreatedEvent( + post.toDto() + ) + ); + + String randomSecureTip = memberApiClient.getRandomSecureTip(); + + return new RsData<>("201-1", + "%d번 글이 생성되었습니다. 보안 팁 : %s" + .formatted(post.getId(), randomSecureTip), + post); + } +} diff --git a/src/main/java/com/back/boundedContext/post/domain/Post.java b/src/main/java/com/back/boundedContext/post/domain/Post.java new file mode 100644 index 000000000..ded207342 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/domain/Post.java @@ -0,0 +1,67 @@ +package com.back.boundedContext.post.domain; + +import java.util.ArrayList; +import java.util.List; + +import com.back.global.jpa.entity.BaseIdAndTime; +import com.back.shared.post.dto.PostDto; +import com.back.shared.post.event.PostCommentCreatedEvent; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "POST_POST") +@NoArgsConstructor +@Getter +public class Post extends BaseIdAndTime { + + @ManyToOne(fetch = FetchType.LAZY) + private PostMember author; + private String title; + @Column(columnDefinition = "LONGTEXT") + private String content; + @OneToMany(mappedBy = "post", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) + private List comments = new ArrayList<>(); + + public Post(PostMember author, String title, String content) { + this.author = author; + this.title = title; + this.content = content; + } + + public PostDto toDto() { + return new PostDto( + getId(), + getCreateTime(), + getModifyTime(), + author.getId(), + author.getNickName(), + title, + content + ); + } + + public PostComment addComment(PostMember author, String content) { + PostComment postComment = new PostComment(this, author, content); + + comments.add(postComment); + + publishEvent(new PostCommentCreatedEvent(postComment.toDto())); + + return postComment; + } + + public boolean hasComments() { + return !comments.isEmpty(); + } + + +} diff --git a/src/main/java/com/back/boundedContext/post/domain/PostComment.java b/src/main/java/com/back/boundedContext/post/domain/PostComment.java new file mode 100644 index 000000000..34eed3237 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/domain/PostComment.java @@ -0,0 +1,46 @@ +package com.back.boundedContext.post.domain; + +import com.back.global.jpa.entity.BaseIdAndTime; +import com.back.shared.post.dto.PostCommentDto; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "POST_POST_COMMENT") +@NoArgsConstructor +@Getter +public class PostComment extends BaseIdAndTime { + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + @ManyToOne(fetch = FetchType.LAZY) + private PostMember author; + @Column(columnDefinition = "TEXT") + private String content; + + public PostComment(Post post, PostMember author, String content) { + this.post = post; + this.author = author; + this.content = content; + } + + public PostCommentDto toDto() { + return new PostCommentDto( + getId(), + getCreateTime(), + getModifyTime(), + post.getId(), + author.getId(), + author.getNickName(), + content + ); + } + + +} diff --git a/src/main/java/com/back/boundedContext/post/domain/PostMember.java b/src/main/java/com/back/boundedContext/post/domain/PostMember.java new file mode 100644 index 000000000..be96fab69 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/domain/PostMember.java @@ -0,0 +1,20 @@ +package com.back.boundedContext.post.domain; + +import java.time.LocalDateTime; + +import com.back.shared.member.domain.ReplicaMember; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "POST_MEMBER") +@Getter +@NoArgsConstructor +public class PostMember extends ReplicaMember { + public PostMember(int id, LocalDateTime createTime, LocalDateTime modifyTime, String userName, String password, String nickName, int activityScore) { + super(id, createTime, modifyTime, userName, password, nickName, activityScore); + } +} diff --git a/src/main/java/com/back/boundedContext/post/in/ApiV1PostController.java b/src/main/java/com/back/boundedContext/post/in/ApiV1PostController.java new file mode 100644 index 000000000..0a418adec --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/in/ApiV1PostController.java @@ -0,0 +1,43 @@ +package com.back.boundedContext.post.in; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; +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; + +import com.back.boundedContext.post.app.PostFacade; +import com.back.boundedContext.post.domain.Post; +import com.back.shared.post.dto.PostDto; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/post/posts") +@RequiredArgsConstructor +public class ApiV1PostController { + private final PostFacade postFacade; + + @GetMapping + @Transactional(readOnly = true) + public List getItems() { + return postFacade + .findByOrderByIdDesc() + .stream() + .map(Post::toDto) + .toList(); + } + + @GetMapping("/{id}") + @Transactional(readOnly = true) + public PostDto getItem( + @PathVariable int id + ) { + return postFacade + .findById(id) + .map(Post::toDto) + .get(); + } +} diff --git a/src/main/java/com/back/boundedContext/post/in/PostDataInit.java b/src/main/java/com/back/boundedContext/post/in/PostDataInit.java new file mode 100644 index 000000000..cc3cbaad8 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/in/PostDataInit.java @@ -0,0 +1,81 @@ +package com.back.boundedContext.post.in; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +import com.back.boundedContext.post.app.PostFacade; +import com.back.boundedContext.post.domain.Post; +import com.back.boundedContext.post.domain.PostMember; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class PostDataInit { + private final PostDataInit self; + private final PostFacade postFacade; + + public PostDataInit(@Lazy PostDataInit self,PostFacade postFacade) { + this.self = self; + this.postFacade = postFacade; + } + + @Bean + @Order(2) + public ApplicationRunner postDataInitApplicationRunner() { + return args -> { + self.makeBasePosts(); + self.makeBasePostComments(); + }; + } + + @Transactional + public void makeBasePosts() { + if (postFacade.count() > 0) return; + + + PostMember user1Member = postFacade.findPostMemberByUserName("user1").get(); + PostMember user2Member = postFacade.findPostMemberByUserName("user2").get(); + PostMember user3Member = postFacade.findPostMemberByUserName("user3").get(); + + Post post1 = postFacade.write(user3Member, "제목1", "내용1").getData(); + Post post2 = postFacade.write(user3Member, "제목2", "내용2").getData(); + Post post3 = postFacade.write(user3Member, "제목3", "내용3").getData(); + Post post4 = postFacade.write(user2Member, "제목4", "내용4").getData(); + Post post5 = postFacade.write(user2Member, "제목5", "내용5").getData(); + Post post6 = postFacade.write(user3Member, "제목6", "내용6").getData(); + } + + @Transactional + public void makeBasePostComments() { + Post post1 = postFacade.findById(1).get(); + Post post2 = postFacade.findById(2).get(); + Post post3 = postFacade.findById(3).get(); + Post post4 = postFacade.findById(4).get(); + Post post5 = postFacade.findById(5).get(); + Post post6 = postFacade.findById(6).get(); + + PostMember user1Member = postFacade.findPostMemberByUserName("user1").get(); + PostMember user2Member = postFacade.findPostMemberByUserName("user2").get(); + PostMember user3Member = postFacade.findPostMemberByUserName("user3").get(); + + if (post1.hasComments()) return; + + post1.addComment(user1Member, "댓글1"); + post1.addComment(user2Member, "댓글2"); + post1.addComment(user3Member, "댓글3"); + + post2.addComment(user2Member, "댓글4"); + post2.addComment(user2Member, "댓글5"); + + post3.addComment(user3Member, "댓글6"); + post3.addComment(user3Member, "댓글7"); + + post4.addComment(user1Member, "댓글8"); + } + +} diff --git a/src/main/java/com/back/boundedContext/post/in/PostEventListener.java b/src/main/java/com/back/boundedContext/post/in/PostEventListener.java new file mode 100644 index 000000000..08e0af4dc --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/in/PostEventListener.java @@ -0,0 +1,31 @@ +package com.back.boundedContext.post.in; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.back.boundedContext.post.app.PostFacade; +import com.back.shared.member.event.MemberJoinedEvent; +import com.back.shared.member.event.MemberModifiedEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostEventListener { + private final PostFacade postFacade; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(MemberJoinedEvent event) { + postFacade.syncMember(event.getMemberDto()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(MemberModifiedEvent event) { + postFacade.syncMember(event.getMemberDto()); + } +} diff --git a/src/main/java/com/back/boundedContext/post/out/PostMemberRepository.java b/src/main/java/com/back/boundedContext/post/out/PostMemberRepository.java new file mode 100644 index 000000000..35e538135 --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/out/PostMemberRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.post.out; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.post.domain.PostMember; + +public interface PostMemberRepository extends JpaRepository { + Optional findByUserName(String userName); +} diff --git a/src/main/java/com/back/boundedContext/post/out/PostRepository.java b/src/main/java/com/back/boundedContext/post/out/PostRepository.java new file mode 100644 index 000000000..50271574f --- /dev/null +++ b/src/main/java/com/back/boundedContext/post/out/PostRepository.java @@ -0,0 +1,11 @@ +package com.back.boundedContext.post.out; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.boundedContext.post.domain.Post; + +public interface PostRepository extends JpaRepository { + List findByOrderByIdDesc(); +} diff --git a/src/main/java/com/back/global/RsData/RsData.java b/src/main/java/com/back/global/RsData/RsData.java new file mode 100644 index 000000000..140468349 --- /dev/null +++ b/src/main/java/com/back/global/RsData/RsData.java @@ -0,0 +1,18 @@ +package com.back.global.RsData; + +import com.back.standard.ResultType.ResultType; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class RsData implements ResultType { + private final String resultCode; + private final String msg; + private final T data; + + public RsData(String resultCode, String msg) { + this(resultCode, msg, null); + } +} diff --git a/src/main/java/com/back/global/batch/BatchConfig.java b/src/main/java/com/back/global/batch/BatchConfig.java new file mode 100644 index 000000000..b846d3fae --- /dev/null +++ b/src/main/java/com/back/global/batch/BatchConfig.java @@ -0,0 +1,32 @@ +package com.back.global.batch; + +import javax.sql.DataSource; + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.configuration.annotation.EnableJdbcJobRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.DataSourceInitializer; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +@Configuration +@EnableBatchProcessing +@EnableJdbcJobRepository +public class BatchConfig { + + + @Bean + @Profile("!prod") + public DataSourceInitializer notProdDataSourceInitializer(DataSource dataSource) { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); + populator.addScript(new ClassPathResource("/org/springframework/batch/core/schema-h2.sql")); + populator.setContinueOnError(true); + + DataSourceInitializer initializer = new DataSourceInitializer(); + initializer.setDataSource(dataSource); + initializer.setDatabasePopulator(populator); + return initializer; + } +} diff --git a/src/main/java/com/back/global/eventPublisher/EventPublisher.java b/src/main/java/com/back/global/eventPublisher/EventPublisher.java new file mode 100644 index 000000000..446774943 --- /dev/null +++ b/src/main/java/com/back/global/eventPublisher/EventPublisher.java @@ -0,0 +1,16 @@ +package com.back.global.eventPublisher; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class EventPublisher { + private final ApplicationEventPublisher applicationEventPublisher; + + public void publish(Object event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/src/main/java/com/back/global/exception/DomainException.java b/src/main/java/com/back/global/exception/DomainException.java new file mode 100644 index 000000000..6721f581a --- /dev/null +++ b/src/main/java/com/back/global/exception/DomainException.java @@ -0,0 +1,15 @@ +package com.back.global.exception; + +import lombok.Getter; + +@Getter +public class DomainException extends RuntimeException { + private final String resultCode; + private final String msg; + + public DomainException(String resultCode, String msg) { + super(resultCode + " : " + msg); + this.resultCode = resultCode; + this.msg = msg; + } +} diff --git a/src/main/java/com/back/global/global/GlobalConfig.java b/src/main/java/com/back/global/global/GlobalConfig.java new file mode 100644 index 000000000..59e44e654 --- /dev/null +++ b/src/main/java/com/back/global/global/GlobalConfig.java @@ -0,0 +1,20 @@ +package com.back.global.global; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; + +import com.back.global.eventPublisher.EventPublisher; + +import lombok.Getter; + +@Configuration +public class GlobalConfig { + + @Getter + private static EventPublisher eventPublisher; + + @Autowired + public void setEventPublisher(EventPublisher eventPublisher) { + GlobalConfig.eventPublisher = eventPublisher; + } +} diff --git a/src/main/java/com/back/global/jpa/entity/BaseEntity.java b/src/main/java/com/back/global/jpa/entity/BaseEntity.java new file mode 100644 index 000000000..ef7de5656 --- /dev/null +++ b/src/main/java/com/back/global/jpa/entity/BaseEntity.java @@ -0,0 +1,30 @@ +package com.back.global.jpa.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.back.global.global.GlobalConfig; +import com.back.standard.modelType.CanGetModelTypeCode; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity implements CanGetModelTypeCode { + protected void publishEvent(Object event) { + GlobalConfig.getEventPublisher().publish(event); + } + + public abstract int getId(); + public abstract LocalDateTime getCreateTime(); + public abstract LocalDateTime getModifyTime(); + + @Override + public String getModelTypeCode() { + return this.getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/back/global/jpa/entity/BaseIdAndTime.java b/src/main/java/com/back/global/jpa/entity/BaseIdAndTime.java new file mode 100644 index 000000000..cc1f10ac0 --- /dev/null +++ b/src/main/java/com/back/global/jpa/entity/BaseIdAndTime.java @@ -0,0 +1,30 @@ +package com.back.global.jpa.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseIdAndTime extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @CreatedDate + private LocalDateTime createTime; + + @LastModifiedDate + private LocalDateTime modifyTime; +} diff --git a/src/main/java/com/back/global/jpa/entity/BaseManualAndTime.java b/src/main/java/com/back/global/jpa/entity/BaseManualAndTime.java new file mode 100644 index 000000000..7a3ebaf07 --- /dev/null +++ b/src/main/java/com/back/global/jpa/entity/BaseManualAndTime.java @@ -0,0 +1,31 @@ +package com.back.global.jpa.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +@Getter +public abstract class BaseManualAndTime extends BaseEntity { + @Id + private int id; + @CreatedDate + private LocalDateTime createTime; + @LastModifiedDate + private LocalDateTime modifyTime; + + + public BaseManualAndTime(int id) { + this.id = id; + } +} diff --git a/src/main/java/com/back/shared/cash/dto/CashMemberDto.java b/src/main/java/com/back/shared/cash/dto/CashMemberDto.java new file mode 100644 index 000000000..692c2a6fa --- /dev/null +++ b/src/main/java/com/back/shared/cash/dto/CashMemberDto.java @@ -0,0 +1,17 @@ +package com.back.shared.cash.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CashMemberDto { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final String userName; + private final String nickName; + private int activityScore; +} diff --git a/src/main/java/com/back/shared/cash/dto/WalletDto.java b/src/main/java/com/back/shared/cash/dto/WalletDto.java new file mode 100644 index 000000000..24dc19280 --- /dev/null +++ b/src/main/java/com/back/shared/cash/dto/WalletDto.java @@ -0,0 +1,17 @@ +package com.back.shared.cash.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class WalletDto { + private final int id; + private final LocalDateTime createDate; + private final LocalDateTime modifyDate; + private final int holderId; + private final String holderName; + private final long balance; +} diff --git a/src/main/java/com/back/shared/cash/event/CashMemberCreateEvent.java b/src/main/java/com/back/shared/cash/event/CashMemberCreateEvent.java new file mode 100644 index 000000000..089b74534 --- /dev/null +++ b/src/main/java/com/back/shared/cash/event/CashMemberCreateEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.cash.event; + +import com.back.shared.cash.dto.CashMemberDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CashMemberCreateEvent { + private final CashMemberDto cashMemberDto; +} diff --git a/src/main/java/com/back/shared/cash/event/CashOrderPaymentFailedEvent.java b/src/main/java/com/back/shared/cash/event/CashOrderPaymentFailedEvent.java new file mode 100644 index 000000000..34f295337 --- /dev/null +++ b/src/main/java/com/back/shared/cash/event/CashOrderPaymentFailedEvent.java @@ -0,0 +1,17 @@ +package com.back.shared.cash.event; + +import com.back.shared.market.dto.OrderDto; +import com.back.standard.ResultType.ResultType; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CashOrderPaymentFailedEvent implements ResultType { + private final String resultCode; + private final String msg; + private final OrderDto orderDto; + private final long pgPaymentAmount; + private final long shortfallAmount; +} diff --git a/src/main/java/com/back/shared/cash/event/CashOrderPaymentSucceededEvent.java b/src/main/java/com/back/shared/cash/event/CashOrderPaymentSucceededEvent.java new file mode 100644 index 000000000..7e9ba4550 --- /dev/null +++ b/src/main/java/com/back/shared/cash/event/CashOrderPaymentSucceededEvent.java @@ -0,0 +1,13 @@ +package com.back.shared.cash.event; + +import com.back.shared.market.dto.OrderDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CashOrderPaymentSucceededEvent { + private final OrderDto orderDto; + private final long pgPaymentAmount; +} diff --git a/src/main/java/com/back/shared/cash/out/CashApiClient.java b/src/main/java/com/back/shared/cash/out/CashApiClient.java new file mode 100644 index 000000000..f44d2f1ef --- /dev/null +++ b/src/main/java/com/back/shared/cash/out/CashApiClient.java @@ -0,0 +1,32 @@ +package com.back.shared.cash.out; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.back.shared.cash.dto.WalletDto; + +@Service +public class CashApiClient { + private final RestClient restClient; + + public CashApiClient(@Value("${custom.global.internalBaseUrl}")String internalBaseUrl) { + this.restClient = RestClient.builder() + .baseUrl(internalBaseUrl + "/api/v1/cash") + .build(); + } + + public WalletDto getItemByHolderId(int holderId) { + return restClient.get() + .uri("/wallets/by-holder/" + holderId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } + + public long getBalanceByHolderId(int holderId) { + WalletDto walletDto = getItemByHolderId(holderId); + return walletDto.getBalance(); + } +} diff --git a/src/main/java/com/back/shared/market/dto/MarketMemberDto.java b/src/main/java/com/back/shared/market/dto/MarketMemberDto.java new file mode 100644 index 000000000..8cb308e62 --- /dev/null +++ b/src/main/java/com/back/shared/market/dto/MarketMemberDto.java @@ -0,0 +1,17 @@ +package com.back.shared.market.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class MarketMemberDto { + private final int id; + private final LocalDateTime crateTime; + private final LocalDateTime modifyTime; + private final String userName; + private final String nickName; + private final int activityScore; +} diff --git a/src/main/java/com/back/shared/market/dto/OrderDto.java b/src/main/java/com/back/shared/market/dto/OrderDto.java new file mode 100644 index 000000000..409ce9a9b --- /dev/null +++ b/src/main/java/com/back/shared/market/dto/OrderDto.java @@ -0,0 +1,27 @@ +package com.back.shared.market.dto; + +import java.time.LocalDateTime; + +import com.back.standard.modelType.CanGetModelTypeCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class OrderDto implements CanGetModelTypeCode { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final int customerId; + private final String customerName; + private final long price; + private final long salePrice; + private final LocalDateTime requestPaymentTime; + private final LocalDateTime paymentTime; + + @Override + public String getModelTypeCode() { + return "Order"; + } +} diff --git a/src/main/java/com/back/shared/market/dto/OrderItemDto.java b/src/main/java/com/back/shared/market/dto/OrderItemDto.java new file mode 100644 index 000000000..681a9255b --- /dev/null +++ b/src/main/java/com/back/shared/market/dto/OrderItemDto.java @@ -0,0 +1,33 @@ +package com.back.shared.market.dto; + +import java.time.LocalDateTime; + +import com.back.standard.modelType.CanGetModelTypeCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class OrderItemDto implements CanGetModelTypeCode { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final int orderId; + private final int buyerId; + private final String buyerName; + private final int sellerId; + private final String sellerName; + private final int productId; + private final String productName; + private final long price; + private final long salePrice; + private final double payoutRate; + private final long payoutFee; + private final long salePriceWithoutFee; + + @Override + public String getModelTypeCode() { + return "OrderItem"; + } +} diff --git a/src/main/java/com/back/shared/market/event/MarketMemberCreatedEvent.java b/src/main/java/com/back/shared/market/event/MarketMemberCreatedEvent.java new file mode 100644 index 000000000..7156fbcb6 --- /dev/null +++ b/src/main/java/com/back/shared/market/event/MarketMemberCreatedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.market.event; + +import com.back.shared.market.dto.MarketMemberDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MarketMemberCreatedEvent { + private final MarketMemberDto marketMemberDto; +} diff --git a/src/main/java/com/back/shared/market/event/MarketOrderPaymentCompletedEvent.java b/src/main/java/com/back/shared/market/event/MarketOrderPaymentCompletedEvent.java new file mode 100644 index 000000000..9708198a7 --- /dev/null +++ b/src/main/java/com/back/shared/market/event/MarketOrderPaymentCompletedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.market.event; + +import com.back.shared.market.dto.OrderDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class MarketOrderPaymentCompletedEvent { + private OrderDto orderDto; +} diff --git a/src/main/java/com/back/shared/market/event/MarketOrderPaymentRequestedEvent.java b/src/main/java/com/back/shared/market/event/MarketOrderPaymentRequestedEvent.java new file mode 100644 index 000000000..02257511f --- /dev/null +++ b/src/main/java/com/back/shared/market/event/MarketOrderPaymentRequestedEvent.java @@ -0,0 +1,13 @@ +package com.back.shared.market.event; + +import com.back.shared.market.dto.OrderDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class MarketOrderPaymentRequestedEvent { + private OrderDto orderDto; + private long pgPaymentAmount; +} diff --git a/src/main/java/com/back/shared/market/out/MarketApiClient.java b/src/main/java/com/back/shared/market/out/MarketApiClient.java new file mode 100644 index 000000000..ce60f2860 --- /dev/null +++ b/src/main/java/com/back/shared/market/out/MarketApiClient.java @@ -0,0 +1,30 @@ +package com.back.shared.market.out; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.back.shared.market.dto.OrderItemDto; + +@Service +public class MarketApiClient { + private final RestClient restClient; + + public MarketApiClient(@Value("${custom.global.internalBaseUrl}") String internalBaseUrl) { + this.restClient = RestClient.builder() + .baseUrl(internalBaseUrl + "/api/v1/market") + .build(); + } + + public List getOrderItems(int id) { + return restClient + .get() + .uri("/orders/%d/items".formatted(id)) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } +} diff --git a/src/main/java/com/back/shared/market/out/TossPaymentsService.java b/src/main/java/com/back/shared/market/out/TossPaymentsService.java new file mode 100644 index 000000000..ed51d2717 --- /dev/null +++ b/src/main/java/com/back/shared/market/out/TossPaymentsService.java @@ -0,0 +1,123 @@ +package com.back.shared.market.out; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import com.back.global.exception.DomainException; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +@Service +public class TossPaymentsService { + + private static final String TOSS_BASE_URL = "https://api.tosspayments.com"; + private static final String CONFIRM_PATH = "/v1/payments/confirm"; + + private final RestClient tossRestClient; + private final ObjectMapper objectMapper; + + @Value("${custom.market.toss.payments.secretKey:}") + private String tossSecretKey; + + public TossPaymentsService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.tossRestClient = RestClient.builder() + .baseUrl(TOSS_BASE_URL) + .build(); + } + + public Map confirmCardPayment(String paymentKey, String orderId, long amount) { + TossPaymentsConfirmRequest requestBody = new TossPaymentsConfirmRequest( + paymentKey, + orderId, + amount + ); + + try { + ResponseEntity responseEntity = createConfirmRequest(requestBody) + .retrieve() + .toEntity(Map.class); + + int httpStatus = responseEntity.getStatusCode().value(); + Map responseBody = responseEntity.getBody(); + + if (httpStatus != 200) { + throw createDomainExceptionFromNon200(httpStatus, responseBody); + } + + if (responseBody == null) { + throw new DomainException("400-EMPTY_RESPONSE", "토스 결제 승인 응답 바디가 비었습니다."); + } + + @SuppressWarnings("unchecked") + Map casted = (Map) responseBody; + return casted; + + } catch (RestClientResponseException e) { + throw createDomainExceptionFromHttpError(e); + } catch (DomainException e) { + throw e; + } catch (Exception e) { + throw new DomainException("400-TOSS_CALL_EXCEPTION", "토스 결제 승인 호출 중 예외: " + e.getMessage()); + } + } + + private RestClient.RequestHeadersSpec createConfirmRequest(TossPaymentsConfirmRequest requestBody) { + return tossRestClient.post() + .uri(CONFIRM_PATH) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .headers(headers -> headers.setBasicAuth(tossSecretKey, "")) + .body(requestBody); + } + + private DomainException createDomainExceptionFromNon200(int httpStatus, Map responseBody) { + if (responseBody == null) { + return new DomainException("400-HTTP_" + httpStatus, "토스 결제 승인 실패(응답 바디 없음), HTTP " + httpStatus); + } + + @SuppressWarnings("unchecked") + Map body = (Map) responseBody; + + String tossCode = extractStringOrDefault(body, "code", "HTTP_" + httpStatus); + String tossMessage = extractStringOrDefault(body, "message", "토스 결제 승인 실패, HTTP " + httpStatus); + + return new DomainException("400-" + tossCode, tossMessage); + } + + private DomainException createDomainExceptionFromHttpError(RestClientResponseException e) { + int httpStatus = e.getStatusCode().value(); + String rawBody = e.getResponseBodyAsString(StandardCharsets.UTF_8); + + if (rawBody == null || rawBody.isBlank()) { + return new DomainException("400-HTTP_" + httpStatus, "토스 결제 승인 실패(빈 바디), HTTP " + httpStatus); + } + + try { + Map errorBody = objectMapper.readValue(rawBody, new TypeReference<>() { + }); + String tossCode = extractStringOrDefault(errorBody, "code", "HTTP_" + httpStatus); + String tossMessage = extractStringOrDefault(errorBody, "message", "토스 결제 승인 실패, HTTP " + httpStatus); + return new DomainException("400-" + tossCode, tossMessage); + } catch (Exception parseFail) { + return new DomainException("400-HTTP_" + httpStatus, "토스 결제 승인 실패, HTTP " + httpStatus + " / body=" + rawBody); + } + } + + private String extractStringOrDefault(Map map, String key, String defaultValue) { + Object value = map.get(key); + if (value instanceof String s && !s.isBlank()) return s; + return defaultValue; + } + + public record TossPaymentsConfirmRequest(String paymentKey, String orderId, long amount) { + } +} diff --git a/src/main/java/com/back/shared/member/domain/BaseMember.java b/src/main/java/com/back/shared/member/domain/BaseMember.java new file mode 100644 index 000000000..c2674081c --- /dev/null +++ b/src/main/java/com/back/shared/member/domain/BaseMember.java @@ -0,0 +1,32 @@ +package com.back.shared.member.domain; + +import com.back.global.jpa.entity.BaseEntity; + +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter(value = AccessLevel.PROTECTED) +@NoArgsConstructor +public abstract class BaseMember extends BaseEntity { + + private String userName; + private String password; + private String nickName; + private int activityScore; + + public BaseMember(String userName, String password, String nickName, int activityScore) { + this.userName = userName; + this.password = password; + this.nickName = nickName; + this.activityScore = activityScore; + } + + public boolean isSystem() { + return "system".equals(userName); + } +} diff --git a/src/main/java/com/back/shared/member/domain/ReplicaMember.java b/src/main/java/com/back/shared/member/domain/ReplicaMember.java new file mode 100644 index 000000000..76ee7c3eb --- /dev/null +++ b/src/main/java/com/back/shared/member/domain/ReplicaMember.java @@ -0,0 +1,27 @@ +package com.back.shared.member.domain; + +import java.time.LocalDateTime; + +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +public abstract class ReplicaMember extends BaseMember{ + @Id + private int id; + private LocalDateTime createTime; + private LocalDateTime modifyTime; + + public ReplicaMember(int id, LocalDateTime createTime, LocalDateTime modifyTime, String userName, String password, String nickName, int activityScore) { + super(userName, password, nickName, activityScore); + this.id = id; + this.createTime = createTime; + this.modifyTime = modifyTime; + } +} diff --git a/src/main/java/com/back/shared/member/domain/SourceMember.java b/src/main/java/com/back/shared/member/domain/SourceMember.java new file mode 100644 index 000000000..431828e35 --- /dev/null +++ b/src/main/java/com/back/shared/member/domain/SourceMember.java @@ -0,0 +1,33 @@ +package com.back.shared.member.domain; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor +public abstract class SourceMember extends BaseMember { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + @CreatedDate + private LocalDateTime createTime; + @LastModifiedDate + private LocalDateTime modifyTime; + + public SourceMember(String userName, String password, String nickName) { + super(userName, password, nickName, 0); + } +} diff --git a/src/main/java/com/back/shared/member/dto/MemberDto.java b/src/main/java/com/back/shared/member/dto/MemberDto.java new file mode 100644 index 000000000..acf9a093e --- /dev/null +++ b/src/main/java/com/back/shared/member/dto/MemberDto.java @@ -0,0 +1,17 @@ +package com.back.shared.member.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class MemberDto { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final String userName; + private final String nickName; + private final int activityScore; +} diff --git a/src/main/java/com/back/shared/member/event/MemberJoinedEvent.java b/src/main/java/com/back/shared/member/event/MemberJoinedEvent.java new file mode 100644 index 000000000..9a2fc55e9 --- /dev/null +++ b/src/main/java/com/back/shared/member/event/MemberJoinedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.member.event; + +import com.back.shared.member.dto.MemberDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MemberJoinedEvent { + private final MemberDto memberDto; +} diff --git a/src/main/java/com/back/shared/member/event/MemberModifiedEvent.java b/src/main/java/com/back/shared/member/event/MemberModifiedEvent.java new file mode 100644 index 000000000..4e129e2b1 --- /dev/null +++ b/src/main/java/com/back/shared/member/event/MemberModifiedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.member.event; + +import com.back.shared.member.dto.MemberDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MemberModifiedEvent { + private final MemberDto memberDto; +} diff --git a/src/main/java/com/back/shared/member/out/MemberApiClient.java b/src/main/java/com/back/shared/member/out/MemberApiClient.java new file mode 100644 index 000000000..35ed72518 --- /dev/null +++ b/src/main/java/com/back/shared/member/out/MemberApiClient.java @@ -0,0 +1,23 @@ +package com.back.shared.member.out; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class MemberApiClient { + private final RestClient restClient; + + public MemberApiClient(@Value("${custom.global.internalBaseUrl}")String internalBaseUrl) { + this.restClient = RestClient.builder() + .baseUrl(internalBaseUrl + "/api/v1/member") + .build(); + } + + public String getRandomSecureTip() { + return restClient.get() + .uri("/members/randomSecureTip") + .retrieve() + .body(String.class); + } +} diff --git a/src/main/java/com/back/shared/payout/dto/PayoutDto.java b/src/main/java/com/back/shared/payout/dto/PayoutDto.java new file mode 100644 index 000000000..848226711 --- /dev/null +++ b/src/main/java/com/back/shared/payout/dto/PayoutDto.java @@ -0,0 +1,26 @@ +package com.back.shared.payout.dto; + +import java.time.LocalDateTime; + +import com.back.standard.modelType.CanGetModelTypeCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PayoutDto implements CanGetModelTypeCode { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private int payeeId; + private String payeeName; + private LocalDateTime payoutTime; + private long amount; + private boolean isPayeeSystem; + + @Override + public String getModelTypeCode() { + return "Payout"; + } +} diff --git a/src/main/java/com/back/shared/payout/dto/PayoutMemberDto.java b/src/main/java/com/back/shared/payout/dto/PayoutMemberDto.java new file mode 100644 index 000000000..ee720178e --- /dev/null +++ b/src/main/java/com/back/shared/payout/dto/PayoutMemberDto.java @@ -0,0 +1,17 @@ +package com.back.shared.payout.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PayoutMemberDto { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final String username; + private final String nickname; + private final int activityScore; +} diff --git a/src/main/java/com/back/shared/payout/event/PayoutCompletedEvent.java b/src/main/java/com/back/shared/payout/event/PayoutCompletedEvent.java new file mode 100644 index 000000000..ba7069987 --- /dev/null +++ b/src/main/java/com/back/shared/payout/event/PayoutCompletedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.payout.event; + +import com.back.shared.payout.dto.PayoutDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PayoutCompletedEvent { + private final PayoutDto payoutDto; +} diff --git a/src/main/java/com/back/shared/payout/event/PayoutMemberCreatedEvent.java b/src/main/java/com/back/shared/payout/event/PayoutMemberCreatedEvent.java new file mode 100644 index 000000000..4b23e0cb0 --- /dev/null +++ b/src/main/java/com/back/shared/payout/event/PayoutMemberCreatedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.payout.event; + +import com.back.shared.payout.dto.PayoutMemberDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PayoutMemberCreatedEvent { + private final PayoutMemberDto payoutMemberDto; +} diff --git a/src/main/java/com/back/shared/post/dto/PostCommentDto.java b/src/main/java/com/back/shared/post/dto/PostCommentDto.java new file mode 100644 index 000000000..ec5784080 --- /dev/null +++ b/src/main/java/com/back/shared/post/dto/PostCommentDto.java @@ -0,0 +1,18 @@ +package com.back.shared.post.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PostCommentDto { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final int postId; + private final int authorId; + private final String authorName; + private final String content; +} diff --git a/src/main/java/com/back/shared/post/dto/PostDto.java b/src/main/java/com/back/shared/post/dto/PostDto.java new file mode 100644 index 000000000..7e09da63b --- /dev/null +++ b/src/main/java/com/back/shared/post/dto/PostDto.java @@ -0,0 +1,25 @@ +package com.back.shared.post.dto; + +import java.time.LocalDateTime; + +import com.back.standard.modelType.CanGetModelTypeCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PostDto implements CanGetModelTypeCode { + private final int id; + private final LocalDateTime createTime; + private final LocalDateTime modifyTime; + private final int authorId; + private final String authorName; + private final String title; + private final String content; + + @Override + public String getModelTypeCode() { + return "Post"; + } +} diff --git a/src/main/java/com/back/shared/post/event/PostCommentCreatedEvent.java b/src/main/java/com/back/shared/post/event/PostCommentCreatedEvent.java new file mode 100644 index 000000000..43d83c04a --- /dev/null +++ b/src/main/java/com/back/shared/post/event/PostCommentCreatedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.post.event; + +import com.back.shared.post.dto.PostCommentDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PostCommentCreatedEvent { + private PostCommentDto postCommentDto; +} diff --git a/src/main/java/com/back/shared/post/event/PostCreatedEvent.java b/src/main/java/com/back/shared/post/event/PostCreatedEvent.java new file mode 100644 index 000000000..0d1555cc6 --- /dev/null +++ b/src/main/java/com/back/shared/post/event/PostCreatedEvent.java @@ -0,0 +1,12 @@ +package com.back.shared.post.event; + +import com.back.shared.post.dto.PostDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PostCreatedEvent { + private PostDto postDto; +} diff --git a/src/main/java/com/back/shared/post/out/PostApiClient.java b/src/main/java/com/back/shared/post/out/PostApiClient.java new file mode 100644 index 000000000..51ba42afc --- /dev/null +++ b/src/main/java/com/back/shared/post/out/PostApiClient.java @@ -0,0 +1,37 @@ +package com.back.shared.post.out; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.back.shared.post.dto.PostDto; + +@Service +public class PostApiClient { + private final RestClient restClient; + + public PostApiClient(@Value("${custom.global.internalBaseUrl}") String internalBaseUrl) { + this.restClient = RestClient.builder() + .baseUrl(internalBaseUrl + "/api/v1/post") + .build(); + } + + public List getItems() { + return restClient.get() + .uri("/posts") + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } + + public PostDto getItem(int id) { + return restClient.get() + .uri("posts/%d".formatted(id)) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } +} diff --git a/src/main/java/com/back/standard/ResultType/ResultType.java b/src/main/java/com/back/standard/ResultType/ResultType.java new file mode 100644 index 000000000..ed3753dff --- /dev/null +++ b/src/main/java/com/back/standard/ResultType/ResultType.java @@ -0,0 +1,11 @@ +package com.back.standard.ResultType; + +public interface ResultType { + String getResultCode(); + + String getMsg(); + + default T getData() { + return null; + } +} diff --git a/src/main/java/com/back/standard/modelType/CanGetModelTypeCode.java b/src/main/java/com/back/standard/modelType/CanGetModelTypeCode.java new file mode 100644 index 000000000..7cf7c9509 --- /dev/null +++ b/src/main/java/com/back/standard/modelType/CanGetModelTypeCode.java @@ -0,0 +1,5 @@ +package com.back.standard.modelType; + +public interface CanGetModelTypeCode { + String getModelTypeCode(); +} diff --git a/src/main/java/com/back/standard/ut/Util.java b/src/main/java/com/back/standard/ut/Util.java new file mode 100644 index 000000000..7be64716a --- /dev/null +++ b/src/main/java/com/back/standard/ut/Util.java @@ -0,0 +1,15 @@ +package com.back.standard.ut; + +public class Util { + public static class reflection { + public static void setField(Object obj, String fieldName, Object value) { + try { + var field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 000000000..31dea49eb --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: jdbc:h2:./db_dev;MODE=MySQL + username: sa + password: + driver-class-name: org.h2.Driver \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 24229f339..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=back diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..574c06702 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,51 @@ +server: + port: 8080 +spring: + application: + name: back + config: + import: optional:file:.env[.properties] + profiles: + active: dev + output: + ansi: + enabled: always + jackson: + serialization: + fail-on-empty-beans: false + jpa: + hibernate: + ddl-auto: update + open-in-view: false + show-sql: true + properties: + hibernate: + format_sql: true + highlight_sql: true + use_sql_comments: true + default_batch_fetch_size: 100 + batch: + job: + enabled: false +logging: + level: + com.back: DEBUG + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.orm.jdbc.extract: TRACE + org.springframework.transaction.interceptor: TRACE + +custom: + global: + internalBaseUrl: http://localhost:${server.port:8080} + holdingMemberId: 2 + payout: + readWaitingDays: 14 + member: + password: + changeDays: 90 + market: + toss: + payments: + secretKey: ${TOSS_PAYMENTS_SECRET_KEY:} + product: + payoutRate: 90 \ No newline at end of file