Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/opixxx step4 #32

Open
wants to merge 28 commits into
base: base/opixxx
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
461be44
feat: Transactional(입출금 내역 테이블) 기본 셋팅
opixxx Feb 13, 2025
cd342bc
feat: 기존 송금 구조 변경
opixxx Feb 14, 2025
c40cd41
feat: 기존 송금 구조 변경
opixxx Feb 14, 2025
243ad91
fix: 틀린 변수 명 수정
opixxx Feb 14, 2025
506c3dc
refactor: 사용안하는 클래스 삭제, 메서드 명 변경
opixxx Feb 14, 2025
5036ef9
feat: 변경된 송금 구조에 따른 테스트 코드 변경
opixxx Feb 14, 2025
d8f0cb7
feat: TransactionalRepository 테스크 코드 작성
opixxx Feb 14, 2025
1e6cf0f
feat: 송금 기능 추가 구현
opixxx Feb 16, 2025
2f4f4aa
chore: 클래스명 변경 Transactionl -> Transaction
opixxx Feb 16, 2025
d259307
chore: 클래스명 변경 Transactional -> Transaction
opixxx Feb 16, 2025
9ca52d6
chore: 클래스명 변경 Transactional -> Transaction
opixxx Feb 16, 2025
7b55be5
chore: 클래스명 변경 Transactional -> Transaction
opixxx Feb 16, 2025
51cb60c
chore: 클래스명 변경 Transactional -> Transaction
opixxx Feb 16, 2025
06af3cb
feat: 72시간이 지난 송금 내역을 취소하는 기능 구현
opixxx Feb 16, 2025
6abbf27
chore: 패키지명 변경 transactional -> transaction
opixxx Feb 16, 2025
f834f34
chore: JavaMail 의존성 및 환경변수 설정
opixxx Feb 16, 2025
6e95840
test: 비즈니스 로직 변경으로 인한 테스트 코드 변경
opixxx Feb 17, 2025
e027e21
test: 비즈니스 로직 변경으로 인한 테스트 코드 변경
opixxx Feb 17, 2025
250cb36
test: 송금 취소, 기한 마감으로 인한 송금 취소 메서드 테스트 코드 작성
opixxx Feb 17, 2025
3fe7731
chore: 메서드 설명 주석 추가
opixxx Feb 17, 2025
ff68ee6
feat: 송금 마감 알림 전송 기능 구현
opixxx Feb 17, 2025
c45c670
chore: 이메일 환경 변수 테스트 용 설정
opixxx Feb 17, 2025
55350fc
chore: 메서드 명 변경
opixxx Feb 17, 2025
ca49cd3
test: depositByMember() 테스트 코드 작성
opixxx Feb 17, 2025
b1047d3
chore: 메서드 명 변경
opixxx Feb 17, 2025
9326c08
chore: code smell 제거
opixxx Feb 17, 2025
f936b3c
chore: code smell 제거
opixxx Feb 17, 2025
4415d67
chore: code smell 제거
opixxx Feb 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.security:spring-security-crypto:5.6.3'

//java mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

//apache_commons
implementation 'org.apache.commons:commons-lang3:3.13.0'
implementation 'org.apache.commons:commons-collections4:4.4'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package org.c4marathon.assignment.account.dto;

import org.c4marathon.assignment.transaction.domain.TransactionType;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;

public record WithdrawRequest(

@NotNull
Long receiverAccountId,
@NotNull
Long receiverAccountId,

@PositiveOrZero
long money,

@NotNull
TransactionType type

@PositiveOrZero
long money
) {
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package org.c4marathon.assignment.account.presentation;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.RequiredArgsConstructor;
import org.c4marathon.assignment.account.dto.SendToSavingAccountRequest;
import org.c4marathon.assignment.account.dto.WithdrawRequest;
import org.c4marathon.assignment.account.service.AccountService;
import org.c4marathon.assignment.account.service.DepositService;
import org.c4marathon.assignment.global.annotation.Login;
import org.c4marathon.assignment.global.session.SessionMemberInfo;
import org.springframework.http.HttpStatus;
Expand All @@ -20,35 +23,54 @@
public class AccountController {

private final AccountService accountService;
private final DepositService depositService;

@PostMapping("/charge")
public ResponseEntity<Void> charge(
@Login SessionMemberInfo loginMember,
@RequestParam @PositiveOrZero(message = "음수는 송금할 수 없습니다.") long money
@Login SessionMemberInfo loginMember,
@RequestParam @PositiveOrZero(message = "음수는 송금할 수 없습니다.") long money
) {
accountService.chargeMoney(loginMember.accountId(), money);
return ResponseEntity.ok().build();
}

@PostMapping("/send/saving-account")
public ResponseEntity<Void> sendToSavingAccount(
@Login SessionMemberInfo loginMember,
@Valid @RequestBody SendToSavingAccountRequest request
@Login SessionMemberInfo loginMember,
@Valid @RequestBody SendToSavingAccountRequest request
) {
accountService.sendToSavingAccount(
loginMember.accountId(),
request.savingAccountId(),
request.money()
loginMember.accountId(),
request.savingAccountId(),
request.money()
);
return ResponseEntity.ok().build();
}

@PostMapping("/withdraw")
public ResponseEntity<Void> withdraw(
@Login SessionMemberInfo loginMember,
@Valid @RequestBody WithdrawRequest request
@Login SessionMemberInfo loginMember,
@Valid @RequestBody WithdrawRequest request
) {
accountService.withdraw(loginMember.accountId(), request);
return ResponseEntity.status(HttpStatus.OK).build();
}

@PostMapping("/deposit")
public ResponseEntity<Void> deposit(
@Login SessionMemberInfo loginMember,
@RequestParam @NotNull @Positive Long transactionalId
) {
depositService.depositByReceiver(loginMember.accountId(), transactionalId);
return ResponseEntity.ok().build();
}

@PostMapping("/cancel/withdraw")
public ResponseEntity<Void> cancelWithdraw(
@Login SessionMemberInfo loginMember,
@RequestParam @NotNull @Positive Long transactionalId
) {
accountService.cancelWithdraw(loginMember.accountId(), transactionalId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package org.c4marathon.assignment.account.service;

import static org.c4marathon.assignment.global.util.Const.*;
import static org.c4marathon.assignment.transaction.domain.TransactionStatus.*;
import static org.c4marathon.assignment.transaction.domain.TransactionStatus.PENDING_DEPOSIT;
import static org.c4marathon.assignment.transaction.domain.TransactionType.*;

import java.util.UUID;
import java.time.LocalDateTime;

import org.c4marathon.assignment.account.domain.Account;
import org.c4marathon.assignment.account.domain.SavingAccount;
Expand All @@ -11,10 +14,15 @@
import org.c4marathon.assignment.account.dto.WithdrawRequest;
import org.c4marathon.assignment.account.exception.DailyChargeLimitExceededException;
import org.c4marathon.assignment.account.exception.NotFoundAccountException;
import org.c4marathon.assignment.global.event.withdraw.WithdrawCompletedEvent;
import org.c4marathon.assignment.global.event.transactional.TransactionCreateEvent;
import org.c4marathon.assignment.member.domain.Member;
import org.c4marathon.assignment.member.domain.repository.MemberRepository;
import org.c4marathon.assignment.member.exception.NotFoundMemberException;
import org.c4marathon.assignment.transaction.domain.Transaction;
import org.c4marathon.assignment.transaction.domain.repository.TransactionRepository;
import org.c4marathon.assignment.transaction.exception.InvalidTransactionStatusException;
import org.c4marathon.assignment.transaction.exception.NotFoundTransactionException;
import org.c4marathon.assignment.transaction.exception.UnauthorizedTransactionException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
Expand All @@ -27,7 +35,9 @@
public class AccountService {
private final AccountRepository accountRepository;
private final MemberRepository memberRepository;
private final TransactionRepository transactionRepository;
private final SavingAccountRepository savingAccountRepository;

private final ApplicationEventPublisher eventPublisher;

@Transactional
Expand Down Expand Up @@ -82,7 +92,8 @@ public void sendToSavingAccount(Long accountId, Long savingAccountId, long money
}

/**
* 출금 시 Redis List 에 출금 기록을 저장
* 송금 시 송금 내역을 저장하는 이벤트 발행 후 커밋
*
* @param senderAccountId
* @param request
*/
Expand All @@ -98,18 +109,71 @@ public void withdraw(Long senderAccountId, WithdrawRequest request) {
senderAccount.withdraw(request.money());
accountRepository.save(senderAccount);

String transactionId = UUID.randomUUID().toString();
if (request.type().equals(IMMEDIATE_TRANSFER)) {
eventPublisher.publishEvent(
new TransactionCreateEvent(
senderAccountId,
request.receiverAccountId(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

존재하지 않는 receiverAccountId에 대한 유효성 검증 로직이 따로 없는 것 같아서 고려해보면 좋을 것 같습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네. 한 번 유효성 검증을 통해 넣어야겠네요. step5 에 추가해놓겠습니다!

request.money(),
request.type(),
WITHDRAW,
LocalDateTime.now()
)
);
} else if (request.type().equals(PENDING_TRANSFER)) {
eventPublisher.publishEvent(
new TransactionCreateEvent(
senderAccountId,
request.receiverAccountId(),
request.money(),
request.type(),
PENDING_DEPOSIT,
LocalDateTime.now()
)
);
}
}

/**
* 송금 취소 기능(사용자가 직접 취소 요청)
* 취소하려는 송금 내역을 가져와 검증 후 송금을 취소함
*
* @param senderAccountId
* @param transactionalId
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void cancelWithdraw(Long senderAccountId, Long transactionalId) {
Transaction transaction = transactionRepository.findTransactionalByTransactionIdWithLock(transactionalId)
.orElseThrow(NotFoundTransactionException::new);

validationTransactional(senderAccountId, transaction);

eventPublisher.publishEvent(
new WithdrawCompletedEvent(
transactionId,
senderAccountId,
request.receiverAccountId(),
request.money()
)
);
Account senderAccount = accountRepository.findByIdWithLock(senderAccountId)
.orElseThrow(NotFoundAccountException::new);

senderAccount.deposit(transaction.getAmount());
transaction.updateStatus(CANCEL);
}

/**
* 72시간이 지난 송금 내역을 취소하는 비즈니스 로직
*
* @param transaction
*/
public void cancelWithdrawByExpirationTime(Transaction transaction) {
Account senderAccount = accountRepository.findByIdWithLock(transaction.getSenderAccountId())
.orElseThrow(NotFoundAccountException::new);

senderAccount.deposit(transaction.getAmount());
transaction.updateStatus(CANCEL);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스케줄러에 의해 72시간이 지난 송금 내역이 취소되기 전, 동시에 사용자가 송금을 받는다면 어떻게 되나요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동시성 문제를 고려해서 현재 입출금 관련해서 Transaction 테이블을 조회해올때 DB단에 락을 걸어 조회합니다

}

/**
* 입금 재시도가 실패하면 송금 롤백을 하는 로직
*
* @param senderAccountId
* @param money
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void rollbackWithdraw(Long senderAccountId, long money) {
Account senderAccount = accountRepository.findByIdWithLock(senderAccountId)
Expand All @@ -121,6 +185,7 @@ public void rollbackWithdraw(Long senderAccountId, long money) {

/**
* 송금할 때 메인 계좌에 잔액이 부족할 때 10,000원 단위로 충전하는 로직
*
* @param money
* @param senderAccount
*/
Expand All @@ -136,4 +201,14 @@ private void autoCharge(long money, Account senderAccount) {
senderAccount.deposit(chargeMoney);
}

private static void validationTransactional(Long senderAccountId, Transaction transaction) {
if (!transaction.getSenderAccountId().equals(senderAccountId)) {
throw new UnauthorizedTransactionException();
}

if (!transaction.getStatus().equals(PENDING_DEPOSIT)) {
throw new InvalidTransactionStatusException();
}
}

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package org.c4marathon.assignment.account.service;

import static org.c4marathon.assignment.transaction.domain.TransactionStatus.*;

import java.time.LocalDateTime;

import org.c4marathon.assignment.account.domain.Account;
import org.c4marathon.assignment.account.domain.repository.AccountRepository;
import org.c4marathon.assignment.account.exception.NotFoundAccountException;
import org.c4marathon.assignment.global.event.deposit.DepositCompletedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.c4marathon.assignment.transaction.domain.Transaction;
import org.c4marathon.assignment.transaction.domain.repository.TransactionRepository;
import org.c4marathon.assignment.transaction.exception.InvalidTransactionStatusException;
import org.c4marathon.assignment.transaction.exception.NotFoundTransactionException;
import org.c4marathon.assignment.transaction.exception.UnauthorizedTransactionException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -17,46 +24,74 @@
@RequiredArgsConstructor
public class DepositService {
private final AccountRepository accountRepository;
private final ApplicationEventPublisher eventPublisher;
private final TransactionRepository transactionRepository;

/**
* 입금을 시도하는 로직.
* 입금이 정상적으로 커밋 완료 시 -> 이벤트 발행을 하며 Redis에 저장된 송금 기록을 삭제
* 입금 중 예외가 발생 시 -> AOP를 통해 예외를 감지하여 Redis에 실패 송금 기록을 저장
* @param deposit
* 송금 내역 데이터를 조회해서 출금 로직 실행
* status = WITHDRAW인 송금 내역을 조회해서 입금 처리를 함
* 입금 성공 : 송금 내역 status를 SUCCESS_DEPOSIT, 입금 한 시간을 업데이트 한다.
* 입금 실패 : AOP를 통해 송금 내역 status를 FAILED_DEPOSIT로 변경한다.
* @param transactional
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void successDeposit(String deposit) {
processDeposit(deposit);
public void successDeposit(Transaction transactional) {
processDeposit(transactional);
}

/**
* 실패한 입금을 재시도하는 로직
* 재입금이 정상적으로 커밋 완료 시 -> 이벤트 발행을 하며 Redis에 저장된 송금 기록을 삭제
* 재입금 중 예외(실패) 시 -> AOP를 통해 예외를 감지하여 송금 롤백 시도
* @param failedDeposit
* 송금 내역 데이터를 조회해서 출금 로직 실행
* status = FAILED_DEPOSIT인 송금 내역을 조회해서 입금 재시도를 함
* 입금 성공 : 송금 내역 status를 SUCCESS_DEPOSIT, 입금 한 시간을 업데이트 한다.
* 입금 재시도 실패 : AOP를 통해 송금 내역 status를 CANCEL로 변경 후 송금 취소한다.
* @param transactional
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void failedDeposit(String failedDeposit) {
processDeposit(failedDeposit);
public void failedDeposit(Transaction transactional) {
processDeposit(transactional);
}

private void processDeposit(String deposit) {
String[] parts = deposit.split(":");
String transactionId = parts[0];
Long senderAccountId = Long.valueOf(parts[1]);
Long receiverAccountId = Long.valueOf(parts[2]);
long money = Long.parseLong(parts[3]);
/**
* 금액을 받는 사용자가 직접 확인 후 금액을 받는 비즈니스 로직
* @param receiverAccountId
* @param transactionalId
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void depositByReceiver(Long receiverAccountId, Long transactionalId) {
Transaction transaction = transactionRepository.findTransactionalByTransactionIdWithLock(transactionalId)
.orElseThrow(NotFoundTransactionException::new);

validationTransaction(receiverAccountId, transaction);

Account receiverAccount = accountRepository.findByIdWithLock(receiverAccountId)
.orElseThrow(NotFoundAccountException::new);

receiverAccount.deposit(money);
receiverAccount.deposit(transaction.getAmount());
transaction.updateStatus(SUCCESS_DEPOSIT);
}

private void processDeposit(Transaction transactional) {
Long receiverAccountId = transactional.getReceiverAccountId();
long amount = transactional.getAmount();

Account receiverAccount = accountRepository.findByIdWithLock(receiverAccountId)
.orElseThrow(NotFoundAccountException::new);

receiverAccount.deposit(amount);
accountRepository.save(receiverAccount);

eventPublisher.publishEvent(new DepositCompletedEvent(deposit));
transactional.setReceiverTime(LocalDateTime.now());
transactional.updateStatus(SUCCESS_DEPOSIT);
transactionRepository.save(transactional);

}

private static void validationTransaction(Long receiverAccountId, Transaction transaction) {
if (!transaction.getReceiverAccountId().equals(receiverAccountId)) {
throw new UnauthorizedTransactionException();
}

log.debug("입금 성공 : transactionId : {}, senderAccountId : {}, receiverAccountId : {}, money : {}",
transactionId, senderAccountId, receiverAccountId, money);
if (!transaction.getStatus().equals(PENDING_DEPOSIT)) {
throw new InvalidTransactionStatusException();
}
}
}
Loading