-
Notifications
You must be signed in to change notification settings - Fork 1
#26 송금 요청 수락/거절 기능 #27
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
Changes from 14 commits
e70f015
8467123
aead5dd
97b2b9f
ad217c2
d77b07f
290c6ce
6cca070
2a2b503
05a9827
ef8b7e0
b0ffa3c
8d29bfa
a742064
91b0959
0ec5e1e
93aa86d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| package com.donggi.sendzy.account.application; | ||
|
|
||
| import com.donggi.sendzy.account.domain.Account; | ||
| import com.donggi.sendzy.account.domain.AccountRepository; | ||
| import com.donggi.sendzy.account.exception.AccountNotFoundException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
| import java.util.stream.Collectors; | ||
| import java.util.stream.Stream; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Service | ||
| public class AccountLockingService { | ||
|
|
||
| private final AccountRepository accountRepository; | ||
|
|
||
| /** | ||
| * 두 개의 계좌를 계좌 ID 기준으로 오름차순 정렬하여 락을 획득합니다. | ||
| * 데드락을 방지하기 위해 항상 일정한 순서로 락을 획득합니다. | ||
| * @param accountId1 첫 번째 계좌 ID | ||
| * @param accountId2 두 번째 계좌 ID | ||
| * @return ID 오름차순으로 정렬된 두 계좌 목록 | ||
| */ | ||
| @Transactional(readOnly = true) | ||
| public List<Account> getAccountsWithLockOrdered(final long accountId1, final long accountId2) { | ||
| List<Long> sortedIds = getSortedIds(accountId1, accountId2); | ||
| return sortedIds.stream() | ||
| .map(accountId -> accountRepository.findByMemberIdForUpdate(accountId) | ||
| .orElseThrow(() -> new AccountNotFoundException(accountId))) | ||
| .collect(Collectors.toList()); | ||
| } | ||
|
|
||
| /** | ||
| * 회원 ID로 계좌를 조회하고 해당 계좌의 락을 획득합니다. | ||
| * @param senderId 송신자 ID | ||
| * @return 조회된 계좌 | ||
| */ | ||
| @Transactional(readOnly = true) | ||
| public Account getByMemberIdForUpdate(final long senderId) { | ||
| return accountRepository.findByMemberIdForUpdate(senderId) | ||
| .orElseThrow(() -> new AccountNotFoundException(senderId)); | ||
| } | ||
|
|
||
| private List<Long> getSortedIds(final long senderId, final long receiverId) { | ||
| return Stream.of(senderId, receiverId) | ||
| .sorted() | ||
| .toList(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,91 @@ | ||||||||||||||
| package com.donggi.sendzy.remittance.application; | ||||||||||||||
|
|
||||||||||||||
| import com.donggi.sendzy.account.application.AccountLockingService; | ||||||||||||||
| import com.donggi.sendzy.account.domain.AccountService; | ||||||||||||||
| import com.donggi.sendzy.remittance.domain.RemittanceRequest; | ||||||||||||||
| import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus; | ||||||||||||||
| import com.donggi.sendzy.remittance.domain.RemittanceStatusHistory; | ||||||||||||||
| import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService; | ||||||||||||||
| import com.donggi.sendzy.remittance.domain.service.RemittanceStatusHistoryService; | ||||||||||||||
| import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException; | ||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||
| import org.springframework.security.access.AccessDeniedException; | ||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||||
|
|
||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||
| @Service | ||||||||||||||
| public class RemittanceRequestProcessor { | ||||||||||||||
|
|
||||||||||||||
| private final RemittanceRequestService remittanceRequestService; | ||||||||||||||
| private final AccountLockingService accountLockingService; | ||||||||||||||
| private final RemittanceStatusHistoryService remittanceStatusHistoryService; | ||||||||||||||
| private final AccountService accountService; | ||||||||||||||
|
|
||||||||||||||
| @Transactional | ||||||||||||||
| public void handleAcceptance(final long requestId, final long receiverId) { | ||||||||||||||
| // 송금 요청 조회 및 상태 확인 (PENDING 여부) | ||||||||||||||
| final var remittanceRequest = remittanceRequestService.getByIdForUpdate(requestId); | ||||||||||||||
| validateReceiverAuthorityAndStatus(remittanceRequest, receiverId); | ||||||||||||||
|
|
||||||||||||||
| // 송금자/수신자 계좌 락 + 조회 (ID 오름차순 → 데드락 방지) | ||||||||||||||
| final var accounts = accountLockingService.getAccountsWithLockOrdered(remittanceRequest.getSenderId(), remittanceRequest.getReceiverId()); | ||||||||||||||
| final var senderAccount = remittanceRequest.getSenderId().equals(accounts.get(0).getMemberId()) ? accounts.get(0) : accounts.get(1); | ||||||||||||||
| final var receiverAccount = remittanceRequest.getReceiverId().equals(accounts.get(0).getMemberId()) ? accounts.get(0) : accounts.get(1); | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 코드는 좀 더 객체지향적인 방식으로 변경하면 안정성있는 코드로 변경할 수 있습니다. 이런 경우 컬랙션을 직접 다루지 말고 클래스로 다루는것이 좀 더 안전한 코드 작성 방식입니다.
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여러 개의 잠금 계좌 객체를 다뤄야 하는 상황에서, LockedAccounts 내에서 송금자와 수신자 계좌를 찾는 로직을 캡슐화할 수 있어 좋은 구조인 것 같습니다. 좋은 인사이트 감사합니다 😊 |
||||||||||||||
|
|
||||||||||||||
| // 이체 처리 | ||||||||||||||
| accountService.transfer(senderAccount, receiverAccount, remittanceRequest.getAmount()); | ||||||||||||||
|
|
||||||||||||||
| // 송금 요청 상태 변경 → ACCEPTED | ||||||||||||||
| remittanceRequestService.accept(remittanceRequest); | ||||||||||||||
|
|
||||||||||||||
| // 상태 변경 히스토리 저장 | ||||||||||||||
| recordStatus(remittanceRequest, RemittanceRequestStatus.ACCEPTED); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| @Transactional | ||||||||||||||
| public void handleRejection(final long requestId, final long receiverId) { | ||||||||||||||
| // 송금 요청 조회 (락 획득) | ||||||||||||||
| final var remittanceRequest = remittanceRequestService.getByIdForUpdate(requestId); | ||||||||||||||
|
|
||||||||||||||
| // 수신자 권한 확인 | ||||||||||||||
| validateReceiverAuthorityAndStatus(remittanceRequest, receiverId); | ||||||||||||||
|
|
||||||||||||||
| // 송금자 계좌 롤백 처리 | ||||||||||||||
| final var senderAccount = accountLockingService.getByMemberIdForUpdate(remittanceRequest.getSenderId()); | ||||||||||||||
| senderAccount.cancelWithdraw(remittanceRequest.getAmount()); | ||||||||||||||
| accountService.update(senderAccount); | ||||||||||||||
|
|
||||||||||||||
| // 송금 요청 상태 변경 → REJECTED | ||||||||||||||
| remittanceRequestService.reject(remittanceRequest); | ||||||||||||||
|
|
||||||||||||||
| // 상태 변경 히스토리 저장 | ||||||||||||||
| recordStatus(remittanceRequest, RemittanceRequestStatus.REJECTED); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private void validateReceiverAuthorityAndStatus(final RemittanceRequest remittanceRequest, final long receiverId) { | ||||||||||||||
| if (!remittanceRequest.getReceiverId().equals(receiverId)) { | ||||||||||||||
| throw new AccessDeniedException("해당 송금 요청의 수신자만 처리할 수 있습니다."); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (!remittanceRequest.isPending()) { | ||||||||||||||
| throw new InvalidRemittanceRequestStatusException(remittanceRequest.getStatus()); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private void recordStatus(RemittanceRequest request, RemittanceRequestStatus status) { | ||||||||||||||
| remittanceStatusHistoryService.recordStatusHistory( | ||||||||||||||
| new RemittanceStatusHistory( | ||||||||||||||
| request.getId(), | ||||||||||||||
| request.getSenderId(), | ||||||||||||||
| request.getReceiverId(), | ||||||||||||||
| request.getAmount(), | ||||||||||||||
| status | ||||||||||||||
| ) | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| public void handleExpiration() { | ||||||||||||||
| // 요청 만료 | ||||||||||||||
| } | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아직 완성되지 않은 메서드는 제거해주세요. 완성된 이후에 올려주시길 부탁드립니다~
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제거하였습니다! |
||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.donggi.sendzy.remittance.controller; | ||
|
|
||
| import com.donggi.sendzy.common.security.CustomUserDetails; | ||
| import com.donggi.sendzy.remittance.application.RemittanceRequestProcessor; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @RestController | ||
| @RequestMapping("/v1/remittance") | ||
| public class RemittanceRequestRestController { | ||
|
|
||
| private final RemittanceRequestProcessor remittanceRequestProcessor; | ||
|
|
||
| /** | ||
| * 송금 요청 수락 | ||
| * @param requestId 송금 요청 ID | ||
| * @param userDetails 로그인 정보 | ||
| */ | ||
| @PostMapping("/{requestId}/accept") | ||
| public void accept(@PathVariable("requestId") final long requestId, @AuthenticationPrincipal final CustomUserDetails userDetails) { | ||
| final var receiverId = userDetails.getMemberId(); | ||
| remittanceRequestProcessor.handleAcceptance(requestId, receiverId); | ||
| } | ||
|
|
||
| /** | ||
| * 송금 요청 거절 | ||
| * @param requestId 송금 요청 ID | ||
| * @param userDetails 로그인 정보 | ||
| */ | ||
| @PostMapping("/{requestId}/reject") | ||
| public void reject(@PathVariable("requestId") final long requestId, @AuthenticationPrincipal final CustomUserDetails userDetails) { | ||
| final var receiverId = userDetails.getMemberId(); | ||
| remittanceRequestProcessor.handleRejection(requestId, receiverId); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
X Lock의 획득을 위한 메서드인데 readOnly 옵션을 주는게 적절할까요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
X Lock을 사용하는 쿼리는 데이터를 수정하기 위한 목적의 조회이므로 readOnly 옵션 제거하였습니다!