From 7d7fd857aac228147f25cae69b3de1cb8c0784b9 Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:13:07 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=EC=A0=95=EC=82=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settlement/entity/SettlementStatus.java | 1 + .../settlement/entity/UserSettlement.java | 4 + .../settlement/service/SettlementService.java | 100 +++++++----------- .../entity/WalletTransactionStatus.java | 1 + .../domain/wallet/service/WalletService.java | 71 +++++++++++++ .../onlyone/global/exception/ErrorCode.java | 1 + 6 files changed, 118 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/settlement/entity/SettlementStatus.java b/src/main/java/com/example/onlyone/domain/settlement/entity/SettlementStatus.java index 3159e058..38120049 100644 --- a/src/main/java/com/example/onlyone/domain/settlement/entity/SettlementStatus.java +++ b/src/main/java/com/example/onlyone/domain/settlement/entity/SettlementStatus.java @@ -3,5 +3,6 @@ public enum SettlementStatus { REQUESTED, COMPLETED, + PENDING, FAILED } diff --git a/src/main/java/com/example/onlyone/domain/settlement/entity/UserSettlement.java b/src/main/java/com/example/onlyone/domain/settlement/entity/UserSettlement.java index 2ec78bef..a22b81d6 100644 --- a/src/main/java/com/example/onlyone/domain/settlement/entity/UserSettlement.java +++ b/src/main/java/com/example/onlyone/domain/settlement/entity/UserSettlement.java @@ -43,4 +43,8 @@ public void updateSettlement(SettlementStatus settlementStatus, LocalDateTime co this.settlementStatus = settlementStatus; this.completedTime = completedTime; } + + public void updateStatus(SettlementStatus settlementStatus) { + this.settlementStatus = settlementStatus; + } } \ No newline at end of file diff --git a/src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java b/src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java index 7421f2f8..65188ae0 100644 --- a/src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java +++ b/src/main/java/com/example/onlyone/domain/settlement/service/SettlementService.java @@ -19,6 +19,7 @@ import com.example.onlyone.domain.wallet.entity.*; import com.example.onlyone.domain.wallet.repository.WalletRepository; import com.example.onlyone.domain.wallet.repository.WalletTransactionRepository; +import com.example.onlyone.domain.wallet.service.WalletService; import com.example.onlyone.global.exception.CustomException; import com.example.onlyone.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -47,6 +49,7 @@ public class SettlementService { private final WalletTransactionRepository walletTransactionRepository; private final TransferRepository transferRepository; private final NotificationService notificationService; + private final WalletService walletService; /* 정산 Status를 REQUESTED -> COMPLETED로 스케줄링 (낙관적 락 적용)*/ @@ -95,7 +98,7 @@ public void createSettlement(Long clubId, Long scheduleId) { return; } schedule.updateStatus(ScheduleStatus.SETTLING); - int totalAmount = userCount * schedule.getCost(); + int totalAmount = (userCount - 1) * schedule.getCost(); Settlement settlement = Settlement.builder() .schedule(schedule) .sum(totalAmount) @@ -113,13 +116,13 @@ public void createSettlement(Long clubId, Long scheduleId) { .build()) .toList(); userSettlementRepository.saveAll(userSettlements); - // [TODO] Notification 생성 및 유저에게 알림 전송 } /* 참여자의 정산 수행 */ + @Transactional(rollbackFor = Exception.class) public void updateUserSettlement(Long clubId, Long scheduleId) { User user = userService.getCurrentUser(); - // 유효성 검증 + // 검증 로직 clubRepository.findById(clubId) .orElseThrow(() -> new CustomException(ErrorCode.CLUB_NOT_FOUND)); Schedule schedule = scheduleRepository.findById(scheduleId) @@ -128,78 +131,55 @@ public void updateUserSettlement(Long clubId, Long scheduleId) { .orElseThrow(() -> new CustomException(ErrorCode.USER_SETTLEMENT_NOT_FOUND)); userScheduleRepository.findByUserAndSchedule(user, schedule) .orElseThrow(() -> new CustomException(ErrorCode.USER_SCHEDULE_NOT_FOUND)); - if (userSettlement.getSettlement().getTotalStatus() != TotalStatus.REQUESTED || - userSettlement.getSettlementStatus() != SettlementStatus.REQUESTED) { + if (userSettlement.getSettlement().getTotalStatus() == TotalStatus.COMPLETED || + userSettlement.getSettlementStatus() == SettlementStatus.COMPLETED) { throw new CustomException(ErrorCode.ALREADY_SETTLED_USER); } User leader = userScheduleRepository.findLeaderByScheduleAndScheduleRole(schedule, ScheduleRole.LEADER) .orElseThrow(() -> new CustomException(ErrorCode.LEADER_NOT_FOUND)); - // Wallet 조회 (비관적 락 적용) + // 비관적 락으로 Wallet 조회 Wallet wallet = walletRepository.findByUser(user) .orElseThrow(() -> new CustomException(ErrorCode.WALLET_NOT_FOUND)); Wallet leaderWallet = walletRepository.findByUser(leader) .orElseThrow(() -> new CustomException(ErrorCode.WALLET_NOT_FOUND)); - int amount = schedule.getCost(); + // 잔액 부족 확인 + if (wallet.getBalance() < amount) { + throw new CustomException(ErrorCode.WALLET_BALANCE_NOT_ENOUGH); + } try { - // 잔액 부족 확인 - if (wallet.getBalance() < amount) { - throw new CustomException(ErrorCode.WALLET_BALANCE_NOT_ENOUGH); - } - // 포인트 이동 - wallet.updateBalance(wallet.getBalance() - amount); - leaderWallet.updateBalance(leaderWallet.getBalance() + amount); - // 리더-멤버의 WalletTransaction, Transfer 기록 - saveWalletTransactions(wallet, leaderWallet, amount, WalletTransactionStatus.COMPLETED, userSettlement); - // 정산 상태 변경 - userSettlement.updateSettlement(SettlementStatus.COMPLETED, LocalDateTime.now()); + // 1. 잔액 변경 전 상태 저장 + int beforeBalance = wallet.getBalance(); + int leaderBeforeBalance = leaderWallet.getBalance(); + // 2. 실제 잔액 변경 + wallet.updateBalance(beforeBalance - amount); + leaderWallet.updateBalance(leaderBeforeBalance + amount); + // 3. 변경된 잔액으로 WalletTransaction 생성 + walletService.createSuccessfulWalletTransactions( + wallet.getWalletId(), leaderWallet.getWalletId(), amount, + userSettlement + ); + // 4. UserSettlement 상태 변경 + userSettlement.updateSettlement(SettlementStatus.COMPLETED, LocalDateTime.now()); // PENDING -> COMPLETED + // 5. 모든 변경사항 저장 + walletRepository.save(wallet); + walletRepository.save(leaderWallet); userSettlementRepository.save(userSettlement); - // 정산 완료 알림 - notificationService.createNotification(user, com.example.onlyone.domain.notification.entity.Type.SETTLEMENT, new String[]{String.valueOf(amount)}); + // 6. 알림 + notificationService.createNotification(user, + com.example.onlyone.domain.notification.entity.Type.SETTLEMENT, + new String[]{String.valueOf(amount)}); } catch (Exception e) { - // 리더-멤버의 WalletTransaction 기록 - saveWalletTransactions(wallet, leaderWallet, amount, WalletTransactionStatus.FAILED, userSettlement); - throw e; + log.error("정산 처리 실패: userId={}, scheduleId={}, amount={}", user.getUserId(), scheduleId, amount, e); + // 실패 기록 + walletService.createFailedWalletTransactions(wallet.getWalletId(), leaderWallet.getWalletId(), amount, userSettlement); + // UserSettlement 상태를 FAILED로 변경 + userSettlement.updateSettlement(SettlementStatus.FAILED, LocalDateTime.now()); + userSettlementRepository.save(userSettlement); + throw new CustomException(ErrorCode.SETTLEMENT_PROCESS_FAILED); } } - /* WalletTransaction 저장 로직 */ - private void saveWalletTransactions(Wallet wallet, Wallet leaderWallet, int amount, - WalletTransactionStatus status, UserSettlement userSettlement) { - // WalletTransaction 저장 - WalletTransaction walletTransaction = WalletTransaction.builder() - .type(Type.OUTGOING) - .amount(amount) - .balance(wallet.getBalance()) - .walletTransactionStatus(status) - .wallet(wallet) - .targetWallet(leaderWallet) - .build(); - walletTransactionRepository.save(walletTransaction); - WalletTransaction leaderWalletTransaction = WalletTransaction.builder() - .type(Type.INCOMING) - .amount(amount) - .balance(leaderWallet.getBalance()) - .walletTransactionStatus(status) - .wallet(leaderWallet) - .targetWallet(wallet) - .build(); - walletTransactionRepository.save(leaderWalletTransaction); - // Transfer 저장 - Transfer transfer = Transfer.builder() - .userSettlement(userSettlement) - .walletTransaction(walletTransaction) - .build(); - transferRepository.save(transfer); - walletTransaction.updateTransfer(transfer); - Transfer leaderTransfer = Transfer.builder() - .userSettlement(userSettlement) - .walletTransaction(leaderWalletTransaction) - .build(); - transferRepository.save(leaderTransfer); - leaderWalletTransaction.updateTransfer(leaderTransfer); - } - /* 스케줄 참여자 정산 목록 조회 */ @Transactional(readOnly = true) public SettlementResponseDto getSettlementList(Long clubId, Long scheduleId, Pageable pageable) { diff --git a/src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java b/src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java index 2f3644ec..ae57ac38 100644 --- a/src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java +++ b/src/main/java/com/example/onlyone/domain/wallet/entity/WalletTransactionStatus.java @@ -2,5 +2,6 @@ public enum WalletTransactionStatus { COMPLETED, + PENDING, FAILED } diff --git a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java index 599fb2cf..3d35d25a 100644 --- a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java +++ b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java @@ -3,6 +3,8 @@ import com.example.onlyone.domain.club.repository.ClubRepository; import com.example.onlyone.domain.payment.entity.Payment; import com.example.onlyone.domain.schedule.entity.Schedule; +import com.example.onlyone.domain.settlement.entity.SettlementStatus; +import com.example.onlyone.domain.settlement.entity.UserSettlement; import com.example.onlyone.domain.settlement.repository.TransferRepository; import com.example.onlyone.domain.user.entity.User; import com.example.onlyone.domain.user.service.UserService; @@ -19,6 +21,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -72,4 +75,72 @@ public UserWalletTransactionDto convertToDto(WalletTransaction walletTransaction return UserWalletTransactionDto.from(walletTransaction, title, mainImage); } } + + public void createSuccessfulWalletTransactions(Long walletId, Long leaderWalletId, int amount, + UserSettlement userSettlement) { + Wallet wallet = walletRepository.findById(walletId).orElseThrow(); + Wallet leaderWallet = walletRepository.findById(leaderWalletId).orElseThrow(); + // 출금 트랜잭션 + WalletTransaction walletTransaction = WalletTransaction.builder() + .type(Type.OUTGOING) + .amount(amount) + .balance(wallet.getBalance()) + .walletTransactionStatus(WalletTransactionStatus.COMPLETED) + .wallet(wallet) + .targetWallet(leaderWallet) + .build(); + // 입금 트랜잭션 + WalletTransaction leaderWalletTransaction = WalletTransaction.builder() + .type(Type.INCOMING) + .amount(amount) + .balance(leaderWallet.getBalance()) + .walletTransactionStatus(WalletTransactionStatus.COMPLETED) + .wallet(leaderWallet) + .targetWallet(wallet) + .build(); + walletTransactionRepository.save(walletTransaction); + walletTransactionRepository.save(leaderWalletTransaction); + // Transfer 생성 및 연결 + createAndSaveTransfers(userSettlement, walletTransaction, leaderWalletTransaction); + // COMPLETED 마킹 필요 + userSettlement.updateStatus(SettlementStatus.COMPLETED); + } + + public void createAndSaveTransfers(UserSettlement userSettlement, WalletTransaction walletTransaction, WalletTransaction leaderWalletTransaction) { + // Transfer 저장 + Transfer transfer = Transfer.builder() + .userSettlement(userSettlement) + .walletTransaction(walletTransaction) + .build(); + transferRepository.save(transfer); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void createFailedWalletTransactions(Long walletId, Long leaderWalletId, int amount, + UserSettlement userSettlement) { + Wallet wallet = walletRepository.findById(walletId).orElseThrow(); + Wallet leaderWallet = walletRepository.findById(leaderWalletId).orElseThrow(); + // 실패한 트랜잭션 + WalletTransaction failedOutgoing = WalletTransaction.builder() + .type(Type.OUTGOING) + .amount(amount) + .balance(wallet.getBalance()) // 잔액 변경 없음 + .walletTransactionStatus(WalletTransactionStatus.FAILED) + .wallet(wallet) + .targetWallet(leaderWallet) + .build(); + WalletTransaction failedIncoming = WalletTransaction.builder() + .type(Type.INCOMING) + .amount(amount) + .balance(leaderWallet.getBalance()) // 잔액 변경 없음 + .walletTransactionStatus(WalletTransactionStatus.FAILED) + .wallet(leaderWallet) + .targetWallet(wallet) + .build(); + walletTransactionRepository.save(failedOutgoing); + walletTransactionRepository.save(failedIncoming); + createAndSaveTransfers(userSettlement, failedOutgoing, failedIncoming); + // FAILED 마킹 필요 + userSettlement.updateStatus(SettlementStatus.FAILED); + } } diff --git a/src/main/java/com/example/onlyone/global/exception/ErrorCode.java b/src/main/java/com/example/onlyone/global/exception/ErrorCode.java index c69ddcf5..52a9c4ae 100644 --- a/src/main/java/com/example/onlyone/global/exception/ErrorCode.java +++ b/src/main/java/com/example/onlyone/global/exception/ErrorCode.java @@ -68,6 +68,7 @@ public enum ErrorCode { SETTLEMENT_NOT_FOUND(404, "SETTLEMENT_404_1", "정산을 찾을 수 없습니다."), USER_SETTLEMENT_NOT_FOUND(404, "SETTLEMENT_404_2", "정산 참여자를 찾을 수 없습니다."), ALREADY_SETTLED_USER(409, "SETTLEMENT_409_1", "이미 해당 정기 모임에 대해 정산한 유저입니다."), + SETTLEMENT_PROCESS_FAILED(500, "SETTLEMENT_500_1", "정산 처리 중 오류가 발생했습니다. 다시 시도해 주세요."), // Wallet INVALID_FILTER(400, "WALLET_400_1", "유효하지 않은 필터입니다."), From 890f3af5f99f684f21fbe8ccbe0f6c8a1dd1a565 Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:26:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EC=B6=A9=EC=A0=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 7 +++++ .../domain/payment/entity/Payment.java | 2 +- .../payment/repository/PaymentRepository.java | 3 ++ .../payment/service/PaymentService.java | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/onlyone/domain/payment/controller/PaymentController.java b/src/main/java/com/example/onlyone/domain/payment/controller/PaymentController.java index 5bb2cf51..57e113b3 100644 --- a/src/main/java/com/example/onlyone/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/example/onlyone/domain/payment/controller/PaymentController.java @@ -44,6 +44,13 @@ public ResponseEntity confirmPayment(@RequestBody ConfirmTossPayRequest req) return ResponseEntity.ok(CommonResponse.success(response)); } + @Operation(summary = "결제 실패 기록", description = "토스페이먼츠 결제 요청의 최종 실패를 기록합니다.") + @PostMapping(value = "/fail") + public ResponseEntity failPayment(@RequestBody ConfirmTossPayRequest req) { + paymentService.reportFail(req); + return ResponseEntity.ok(CommonResponse.success(null)); + } + // // 결제 취소 요청 // @PostMapping("/cancel/{paymentKey}") // public ResponseEntity cancelPayment( diff --git a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java index 8cd4be4e..b3be594c 100644 --- a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java +++ b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java @@ -22,7 +22,7 @@ public class Payment extends BaseTimeEntity { @Column(name = "toss_payment_key", nullable = false, unique = true) private String tossPaymentKey; - @Column(name = "toss_order_id", nullable = false) + @Column(name = "toss_order_id", nullable = false, unique = true) private String tossOrderId; @Column(name = "total_amount", nullable = false) diff --git a/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java b/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java index a84bbf2d..a3c4e19e 100644 --- a/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java @@ -1,6 +1,7 @@ package com.example.onlyone.domain.payment.repository; import com.example.onlyone.domain.payment.entity.Payment; +import jakarta.validation.constraints.NotBlank; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -8,4 +9,6 @@ public interface PaymentRepository extends JpaRepository { Optional findByTossOrderId(String tossOrderId); Optional findByTossPaymentKey(String tossPaymentKey); + + boolean existsByTossPaymentKey(String paymentKey); } diff --git a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java index 33bacbc2..b34f8efa 100644 --- a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java +++ b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java @@ -25,6 +25,7 @@ import lombok.extern.log4j.Log4j2; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -122,4 +123,31 @@ public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { walletTransaction.updatePayment(payment); return response; } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void reportFail(ConfirmTossPayRequest req) { + User user = userService.getCurrentUser(); + Wallet wallet = walletRepository.findByUser(user) + .orElseThrow(() -> new CustomException(ErrorCode.WALLET_NOT_FOUND)); + // 멱등성 처리 + if (paymentRepository.existsByTossPaymentKey(req.getPaymentKey())) return; + // 실패 기록 + WalletTransaction walletTransaction = WalletTransaction.builder() + .type(Type.CHARGE) + .amount(Math.toIntExact(req.getAmount())) + .balance(wallet.getBalance()) + .walletTransactionStatus(WalletTransactionStatus.FAILED) + .wallet(wallet) + .targetWallet(wallet) + .build(); + walletTransactionRepository.save(walletTransaction); + Payment payment = Payment.builder() + .tossPaymentKey(req.getPaymentKey()) + .tossOrderId(req.getOrderId()) + .totalAmount(req.getAmount()) + .status(Status.CANCELED) + .walletTransaction(walletTransaction) + .build(); + walletTransaction.updatePayment(payment); + } } From 356439d5d7c8dd6d54ff5b040f5ea19d6a732ec2 Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:36:01 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubController.java | 8 ----- .../club/repository/UserClubRepository.java | 12 ++++++++ .../domain/club/service/ClubService.java | 30 +++++++++---------- .../search/controller/SearchController.java | 10 +++++++ .../domain/search/service/SearchService.java | 13 ++++++++ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/club/controller/ClubController.java b/src/main/java/com/example/onlyone/domain/club/controller/ClubController.java index 0f4c501e..bce43991 100644 --- a/src/main/java/com/example/onlyone/domain/club/controller/ClubController.java +++ b/src/main/java/com/example/onlyone/domain/club/controller/ClubController.java @@ -56,12 +56,4 @@ public ResponseEntity withdraw(@PathVariable Long clubId) { clubService.leaveClub(clubId); return ResponseEntity.status(HttpStatus.OK).body(CommonResponse.success(null)); } - - @Operation(summary = "가입하고 있는 모임 조회", description = "가입하고 있는 모임을 조회한다.") - @GetMapping - public ResponseEntity getClubNames() { - List clubNameResponseDto = clubService.getClubNames(); - return ResponseEntity.status(HttpStatus.OK).body(CommonResponse.success(clubNameResponseDto)); - } - } diff --git a/src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java b/src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java index 6c684b29..b6bbc029 100644 --- a/src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java +++ b/src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java @@ -22,4 +22,16 @@ public interface UserClubRepository extends JpaRepository { List findUserIdByClubIds(@Param("clubIds") List clubIds); List findByUserUserIdIn(Collection userIds); + + @Query(""" + select c, + (select count(uc2) + from UserClub uc2 + where uc2.club = c) + from UserClub uc + join uc.club c + where uc.user.userId = :userId + group by c +""") + List findMyClubsWithMemberCount(@Param("userId") Long userId); } diff --git a/src/main/java/com/example/onlyone/domain/club/service/ClubService.java b/src/main/java/com/example/onlyone/domain/club/service/ClubService.java index 0fb8f24e..23234fe6 100644 --- a/src/main/java/com/example/onlyone/domain/club/service/ClubService.java +++ b/src/main/java/com/example/onlyone/domain/club/service/ClubService.java @@ -147,19 +147,19 @@ public void leaveClub(Long clubId) { userClubRepository.delete(userClub); } - /* 가입하고 있는 모임 조회*/ - public List getClubNames() { - Long userId = userService.getCurrentUser().getUserId(); - - List userClubs = userClubRepository.findByUserUserId(userId); - - return userClubs.stream() - .map(UserClub::getClub) - .filter(Objects::nonNull) - .map(c -> ClubNameResponseDto.builder() - .clubId(c.getClubId()) - .name(c.getName()) - .build()) - .toList(); - } +// /* 가입하고 있는 모임 조회*/ +// public List getClubNames() { +// Long userId = userService.getCurrentUser().getUserId(); +// +// List userClubs = userClubRepository.findByUserUs(userId); +// +// return userClubs.stream() +// .map(UserClub::getClub) +// .filter(Objects::nonNull) +// .map(c -> ClubNameResponseDto.builder() +// .clubId(c.getClubId()) +// .name(c.getName()) +// .build()) +// .toList(); +// } } diff --git a/src/main/java/com/example/onlyone/domain/search/controller/SearchController.java b/src/main/java/com/example/onlyone/domain/search/controller/SearchController.java index 89ed7e3c..0e0cdb47 100644 --- a/src/main/java/com/example/onlyone/domain/search/controller/SearchController.java +++ b/src/main/java/com/example/onlyone/domain/search/controller/SearchController.java @@ -1,14 +1,18 @@ package com.example.onlyone.domain.search.controller; import com.example.onlyone.domain.search.dto.request.SearchFilterDto; +import com.example.onlyone.domain.search.dto.response.ClubResponseDto; import com.example.onlyone.domain.search.service.SearchService; import com.example.onlyone.global.common.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @Tag(name = "Search") @RequiredArgsConstructor @@ -72,4 +76,10 @@ public ResponseEntity getClubsByTeammates(@RequestParam(defaultValue = "0") i @RequestParam(defaultValue = "20") int size) { return ResponseEntity.ok(CommonResponse.success(searchService.getClubsByTeammates(page, size))); } + + @Operation(summary = "가입하고 있는 모임 조회", description = "가입하고 있는 모임을 조회한다.") + @GetMapping("/user") + public ResponseEntity getClubNames() { + return ResponseEntity.status(HttpStatus.OK).body(CommonResponse.success(searchService.getMyClubs())); + } } diff --git a/src/main/java/com/example/onlyone/domain/search/service/SearchService.java b/src/main/java/com/example/onlyone/domain/search/service/SearchService.java index 8e4bb653..0f79a7a3 100644 --- a/src/main/java/com/example/onlyone/domain/search/service/SearchService.java +++ b/src/main/java/com/example/onlyone/domain/search/service/SearchService.java @@ -1,6 +1,7 @@ package com.example.onlyone.domain.search.service; import com.example.onlyone.domain.club.entity.Club; +import com.example.onlyone.domain.club.entity.UserClub; import com.example.onlyone.domain.club.repository.ClubRepository; import com.example.onlyone.domain.club.repository.UserClubRepository; import com.example.onlyone.domain.interest.entity.Category; @@ -177,4 +178,16 @@ private List convertToClubResponseDtoWithJoinStatus(List getMyClubs() { + Long userId = userService.getCurrentUser().getUserId(); + List rows = userClubRepository.findMyClubsWithMemberCount(userId); + return rows.stream().map(row -> { + Club club = (Club) row[0]; + Long memberCount = (Long) row[1]; + return ClubResponseDto.from(club, memberCount, true); + }).toList(); + } + } From e20d8b2f9a8912f37678ab2967e27c8afaa50a1e Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:42:25 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=ED=86=A0=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EB=A8=BC=EC=B8=A0=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20API=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 3 + .../payment/repository/PaymentRepository.java | 4 +- .../payment/service/PaymentService.java | 68 +++++++++++-------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java index b3be594c..45441625 100644 --- a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java +++ b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java @@ -39,4 +39,7 @@ public class Payment extends BaseTimeEntity { @OneToOne(mappedBy = "payment", fetch = FetchType.LAZY) private WalletTransaction walletTransaction; + public void updateStatus(Status status) { + this.status = status; + } } diff --git a/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java b/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java index a3c4e19e..c708a864 100644 --- a/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/example/onlyone/domain/payment/repository/PaymentRepository.java @@ -1,13 +1,15 @@ package com.example.onlyone.domain.payment.repository; import com.example.onlyone.domain.payment.entity.Payment; +import jakarta.persistence.LockModeType; import jakarta.validation.constraints.NotBlank; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.Optional; public interface PaymentRepository extends JpaRepository { - Optional findByTossOrderId(String tossOrderId); + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByTossPaymentKey(String tossPaymentKey); boolean existsByTossPaymentKey(String paymentKey); diff --git a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java index b34f8efa..ae43710d 100644 --- a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java +++ b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java @@ -78,16 +78,24 @@ public void confirmPayment(@Valid SavePaymentRequestDto dto, HttpSession session // session.removeAttribute(dto.getOrderId()); } - /* 토스페이먼츠 결제 승인 */ + @Transactional public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { - // 멱등성 체크: 이미 처리된 주문인지 확인 - Optional existingPayment = paymentRepository.findByTossOrderId(req.getOrderId()); - if (existingPayment.isPresent()) { + // 멱등성, 동시성 보호: paymentKey로 행 잠금 + Payment payment = paymentRepository.findByTossPaymentKey(req.getPaymentKey()) + .orElseGet(() -> { + Payment p = Payment.builder() + .tossOrderId(req.getOrderId()) + .status(Status.IN_PROGRESS) + .totalAmount(req.getAmount()) + .build(); + return paymentRepository.save(p); + }); + if (payment.getStatus() == Status.DONE) { throw new CustomException(ErrorCode.ALREADY_COMPLETED_PAYMENT); } - ConfirmTossPayResponse response; + // 토스페이먼츠 승인 API 호출 + final ConfirmTossPayResponse response; try { - // tossPaymentClient를 통해 호출 response = tossPaymentClient.confirmPayment(req); } catch (FeignException.BadRequest e) { throw new CustomException(ErrorCode.INVALID_PAYMENT_INFO); @@ -96,13 +104,14 @@ public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { } catch (Exception e) { throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); } + // 지갑/거래 업데이트 User user = userService.getCurrentUser(); Wallet wallet = walletRepository.findByUser(user) .orElseThrow(() -> new CustomException(ErrorCode.WALLET_NOT_FOUND)); - int amount = Math.toIntExact(req.getAmount()); - // 포인트 업데이트 + int amount = Math.toIntExact(response.getTotalAmount()); + // 지갑 증액 wallet.updateBalance(wallet.getBalance() + amount); - // 충전(결제) 기록 + // 거래 기록 WalletTransaction walletTransaction = WalletTransaction.builder() .type(Type.CHARGE) .amount(amount) @@ -112,26 +121,36 @@ public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { .targetWallet(wallet) .build(); walletTransactionRepository.save(walletTransaction); - Payment payment = Payment.builder() - .tossPaymentKey(response.getPaymentKey()) - .tossOrderId(response.getOrderId()) - .totalAmount(response.getTotalAmount()) - .method(Method.from(response.getMethod())) - .status(Status.from(response.getStatus())) - .walletTransaction(walletTransaction) - .build(); + payment.updateStatus(Status.DONE); walletTransaction.updatePayment(payment); return response; } @Transactional(propagation = Propagation.REQUIRES_NEW) public void reportFail(ConfirmTossPayRequest req) { + // 멱등성, 동시성 보호: paymentKey로 행 잠금 + Payment payment = paymentRepository.findByTossPaymentKey(req.getPaymentKey()) + .orElseGet(() -> { + Payment p = Payment.builder() + .tossOrderId(req.getOrderId()) + .totalAmount(req.getAmount()) + .status(Status.IN_PROGRESS) + .build(); + return paymentRepository.save(p); + }); + // 이미 완료된 결제면 기록하지 않음 + if (payment.getStatus() == Status.DONE) { + return; + } + if (req.getPaymentKey() != null && + payment.getTossPaymentKey() != null && + payment.getTossPaymentKey().equals(req.getPaymentKey())) { + return; + } + // 실패 기록 User user = userService.getCurrentUser(); Wallet wallet = walletRepository.findByUser(user) .orElseThrow(() -> new CustomException(ErrorCode.WALLET_NOT_FOUND)); - // 멱등성 처리 - if (paymentRepository.existsByTossPaymentKey(req.getPaymentKey())) return; - // 실패 기록 WalletTransaction walletTransaction = WalletTransaction.builder() .type(Type.CHARGE) .amount(Math.toIntExact(req.getAmount())) @@ -141,13 +160,8 @@ public void reportFail(ConfirmTossPayRequest req) { .targetWallet(wallet) .build(); walletTransactionRepository.save(walletTransaction); - Payment payment = Payment.builder() - .tossPaymentKey(req.getPaymentKey()) - .tossOrderId(req.getOrderId()) - .totalAmount(req.getAmount()) - .status(Status.CANCELED) - .walletTransaction(walletTransaction) - .build(); + // Payment 갱신 + payment.updateStatus(Status.CANCELED); walletTransaction.updatePayment(payment); } } From cff6689e4f7b502e9e2bc4ad1aa1bf6c2eced966 Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:48:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20Transfer=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20/=20SettlementStatus=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onlyone/domain/wallet/service/WalletService.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java index 3d35d25a..7c91fc4a 100644 --- a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java +++ b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java @@ -113,6 +113,11 @@ public void createAndSaveTransfers(UserSettlement userSettlement, WalletTransact .walletTransaction(walletTransaction) .build(); transferRepository.save(transfer); + Transfer leaderTransfer = Transfer.builder() + .userSettlement(userSettlement) + .walletTransaction(leaderWalletTransaction) + .build(); + transferRepository.save(leaderTransfer); } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -140,7 +145,5 @@ public void createFailedWalletTransactions(Long walletId, Long leaderWalletId, i walletTransactionRepository.save(failedOutgoing); walletTransactionRepository.save(failedIncoming); createAndSaveTransfers(userSettlement, failedOutgoing, failedIncoming); - // FAILED 마킹 필요 - userSettlement.updateStatus(SettlementStatus.FAILED); } } From e2a2d6ed5d128143b80ddf8f1320b8542f560b2e Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:50:45 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20SettlementStatus=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/onlyone/domain/payment/entity/Payment.java | 2 +- .../example/onlyone/domain/wallet/service/WalletService.java | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java index 45441625..363dcaad 100644 --- a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java +++ b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java @@ -22,7 +22,7 @@ public class Payment extends BaseTimeEntity { @Column(name = "toss_payment_key", nullable = false, unique = true) private String tossPaymentKey; - @Column(name = "toss_order_id", nullable = false, unique = true) + @Column(name = "toss_order_id", nullable = false, unique = true) private String tossOrderId; @Column(name = "total_amount", nullable = false) diff --git a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java index 7c91fc4a..1df8ff67 100644 --- a/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java +++ b/src/main/java/com/example/onlyone/domain/wallet/service/WalletService.java @@ -102,8 +102,6 @@ public void createSuccessfulWalletTransactions(Long walletId, Long leaderWalletI walletTransactionRepository.save(leaderWalletTransaction); // Transfer 생성 및 연결 createAndSaveTransfers(userSettlement, walletTransaction, leaderWalletTransaction); - // COMPLETED 마킹 필요 - userSettlement.updateStatus(SettlementStatus.COMPLETED); } public void createAndSaveTransfers(UserSettlement userSettlement, WalletTransaction walletTransaction, WalletTransaction leaderWalletTransaction) { From 406423dacf7f5461a8a4100231bd79dae883eced Mon Sep 17 00:00:00 2001 From: Hayoung Moon <124586544+gkdudans@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:15:36 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EC=B6=A9=EC=A0=84=20API=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/onlyone/domain/payment/entity/Payment.java | 8 +++++++- .../onlyone/domain/payment/service/PaymentService.java | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java index 363dcaad..bf0d5968 100644 --- a/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java +++ b/src/main/java/com/example/onlyone/domain/payment/entity/Payment.java @@ -1,5 +1,6 @@ package com.example.onlyone.domain.payment.entity; +import com.example.onlyone.domain.payment.dto.response.ConfirmTossPayResponse; import com.example.onlyone.domain.wallet.entity.WalletTransaction; import com.example.onlyone.global.BaseTimeEntity; import jakarta.persistence.*; @@ -29,7 +30,7 @@ public class Payment extends BaseTimeEntity { private Long totalAmount; @Enumerated(EnumType.STRING) - @Column(name = "method", nullable = false) + @Column(name = "method") private Method method; @Enumerated(value = EnumType.STRING) @@ -42,4 +43,9 @@ public class Payment extends BaseTimeEntity { public void updateStatus(Status status) { this.status = status; } + + public void updateOnConfirm(String status, String method) { + this.status = Status.valueOf(status); + this.method = Method.valueOf(method); + } } diff --git a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java index ae43710d..0767c12f 100644 --- a/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java +++ b/src/main/java/com/example/onlyone/domain/payment/service/PaymentService.java @@ -84,6 +84,7 @@ public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { Payment payment = paymentRepository.findByTossPaymentKey(req.getPaymentKey()) .orElseGet(() -> { Payment p = Payment.builder() + .tossOrderId(req.getOrderId()) .tossOrderId(req.getOrderId()) .status(Status.IN_PROGRESS) .totalAmount(req.getAmount()) @@ -121,7 +122,7 @@ public ConfirmTossPayResponse confirm(ConfirmTossPayRequest req) { .targetWallet(wallet) .build(); walletTransactionRepository.save(walletTransaction); - payment.updateStatus(Status.DONE); + payment.updateOnConfirm(response.getStatus(), response.getMethod()); walletTransaction.updatePayment(payment); return response; }