diff --git a/build.gradle b/build.gradle index 48838540..cfffb868 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JUnit 플랫폼 런처 (테스트 실행 환경 제공) testImplementation "org.testcontainers:testcontainers:1.19.2" // Docker 기반 통합 테스트 지원 (Testcontainers 기본 라이브러리) testImplementation "org.testcontainers:junit-jupiter:1.19.2" // JUnit 5(Testcontainers 연동) 지원 + testImplementation 'org.testcontainers:postgresql:1.19.2' // 데이터베이스 runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스a H2 (테스트 및 개발 환경용) @@ -67,6 +68,11 @@ dependencies { // AWS implementation 'software.amazon.awssdk:kms:2.20.0' // AWS KMS 의존성 + // RabbitMQ + implementation 'org.springframework.boot:spring-boot-starter-amqp' + testImplementation 'org.springframework.amqp:spring-rabbit-test' + testImplementation 'org.testcontainers:rabbitmq' + // 기타 compileOnly 'org.projectlombok:lombok' // Lombok (Getter, Setter, Constructor 자동 생성) annotationProcessor 'org.projectlombok:lombok' // Lombok 사용을 위한 어노테이션 프로세서 diff --git a/docker-compose.yml b/docker-compose.yml index b019fc84..9d2033e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,24 @@ services: environment: - INFLUXDB_DB=k6 + rabbitmq: + image: rabbitmq:3.12-management + container_name: rabbitmq + restart: always + networks: + - monitoring + ports: + - "5672:5672" + - "15673:15672" + environment: + - RABBITMQ_DEFAULT_USER=guest + - RABBITMQ_DEFAULT_PASS=guest + healthcheck: + test: [ "CMD", "rabbitmq-diagnostics", "-q", "ping" ] + interval: 10s + timeout: 10s + retries: 5 + # 🔹 Grafana (InfluxDB 데이터 시각화) grafana: image: grafana/grafana:9.3.8 @@ -96,7 +114,6 @@ services: ports: - "9090:8080" environment: - - SPRING_PROFILES_ACTIVE=docker - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres-db:5432/xchangepass - SPRING_DATASOURCE_USERNAME=postgres - SPRING_DATASOURCE_PASSWORD=postgres diff --git a/src/main/java/bumblebee/xchangepass/domain/card/controller/CardController.java b/src/main/java/bumblebee/xchangepass/domain/card/controller/CardController.java index b92a12b6..40d2f008 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/controller/CardController.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/controller/CardController.java @@ -5,7 +5,7 @@ import bumblebee.xchangepass.domain.card.dto.response.DetailedCardInfoResponse; import bumblebee.xchangepass.domain.card.service.CardService; import bumblebee.xchangepass.global.error.ErrorCode; -import bumblebee.xchangepass.global.security.jwt.JwtUtil; +import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -40,8 +41,8 @@ public class CardController { }) @ResponseStatus(HttpStatus.CREATED) @PostMapping("/physical") - public void generatePhysicalCard(Authentication authentication) { - cardService.generatePhysicalCard(JwtUtil.getLoginId(authentication)); + public void generatePhysicalCard(@AuthenticationPrincipal CustomUserDetails user) { + cardService.generatePhysicalCard(user.getUserId()); } @Operation(summary = "카드 상태 변경", description = "현재 로그인한 사용자의 카드 상태를 변경합니다.") @@ -56,9 +57,9 @@ public void generatePhysicalCard(Authentication authentication) { }) @ResponseStatus(HttpStatus.NO_CONTENT) @PutMapping("/status") - public void changeCardStatus(Authentication authentication, + public void changeCardStatus(@AuthenticationPrincipal CustomUserDetails user, @RequestBody @Valid ChangeCardStatusRequest request) { - cardService.changeCardStatus(JwtUtil.getLoginId(authentication), request); + cardService.changeCardStatus(user.getUserId(), request); } @Operation(summary = "보유 카드 목록 조회", description = "현재 로그인한 사용자의 보유 카드 목록을 조회합니다.") @@ -73,8 +74,8 @@ public void changeCardStatus(Authentication authentication, }) @ResponseStatus(HttpStatus.OK) @GetMapping - public List getBasicCardInfo(Authentication authentication) { - return cardService.getBasicCardInfo(JwtUtil.getLoginId(authentication)); + public List getBasicCardInfo(@AuthenticationPrincipal CustomUserDetails user) { + return cardService.getBasicCardInfo(user.getUserId()); } @Operation(summary = "카드 상세 정보 조회", description = "특정 카드의 상세 정보를 조회합니다.") diff --git a/src/main/java/bumblebee/xchangepass/domain/card/entity/Card.java b/src/main/java/bumblebee/xchangepass/domain/card/entity/Card.java index ff447059..6ca8dcc9 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/entity/Card.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/entity/Card.java @@ -1,6 +1,6 @@ package bumblebee.xchangepass.domain.card.entity; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import bumblebee.xchangepass.global.common.EncryptionData; import jakarta.persistence.*; import lombok.Builder; diff --git a/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java b/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java index 7d939661..9fc3cd8c 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java @@ -8,7 +8,7 @@ import bumblebee.xchangepass.domain.card.repository.CardRepository; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import bumblebee.xchangepass.global.security.crypto.AESEncryption; diff --git a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/controller/ExchangeTransactionController.java b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/controller/ExchangeTransactionController.java index 508d7e66..ad39c95e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/controller/ExchangeTransactionController.java +++ b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/controller/ExchangeTransactionController.java @@ -3,7 +3,7 @@ import bumblebee.xchangepass.domain.exchangeTransaction.dto.request.ExchangeRequestDTO; import bumblebee.xchangepass.domain.exchangeTransaction.dto.response.ExchangeResponseDTO; import bumblebee.xchangepass.domain.exchangeTransaction.service.ExchangeTransactionService; -import bumblebee.xchangepass.global.security.jwt.JwtUtil; +import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; @@ -42,8 +43,8 @@ public class ExchangeTransactionController { }) @ResponseStatus(HttpStatus.OK) @PostMapping("/create") - public ExchangeResponseDTO createTransaction(@RequestBody ExchangeRequestDTO request, Authentication authentication) { - return exchangeService.createTransaction(request, JwtUtil.getLoginId(authentication)); + public ExchangeResponseDTO createTransaction(@RequestBody ExchangeRequestDTO request, @AuthenticationPrincipal CustomUserDetails user) { + return exchangeService.createTransaction(request, user.getUserId()); } @@ -65,7 +66,7 @@ public ExchangeResponseDTO createTransaction(@RequestBody ExchangeRequestDTO req }) @ResponseStatus(HttpStatus.OK) @PostMapping("/execute") - public ExchangeResponseDTO executeTransaction(@RequestParam Long transactionId, Authentication authentication) { - return exchangeService.executeTransaction(transactionId, JwtUtil.getLoginId(authentication)); + public ExchangeResponseDTO executeTransaction(@RequestParam Long transactionId, @AuthenticationPrincipal CustomUserDetails user) { + return exchangeService.executeTransaction(transactionId, user.getUserId()); } } diff --git a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java index 5f897543..b65226d3 100644 --- a/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java +++ b/src/main/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionService.java @@ -9,12 +9,12 @@ import bumblebee.xchangepass.domain.exchangeTransaction.repository.ExchangeTransactionRepository; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.service.WalletService; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.repository.WalletBalanceRepository; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import lombok.RequiredArgsConstructor; @@ -110,14 +110,14 @@ public ExchangeResponseDTO executeTransaction(Long transactionId, Long userId) { if (fromBalance.getBalance().compareTo(amount) < 0) { WalletInOutRequest chargeRequest = WalletInOutRequest.builder() - .userId(userId) +// .userId(userId) .fromCurrency(Currency.getInstance(fromCurrency)) .toCurrency(Currency.getInstance(fromCurrency)) .amount(amount) .chargeDatetime(LocalDateTime.now()) .build(); - walletService.charge(chargeRequest); +// walletService.charge(chargeRequest); } WalletBalance toBalance = getOrCreateBalance(wallet, toCurrency); diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java similarity index 95% rename from src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java rename to src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java index 2e7c6b7b..f976b482 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java @@ -1,6 +1,7 @@ -package bumblebee.xchangepass.domain.refresh; +package bumblebee.xchangepass.domain.refresh.controller; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenRequest; +import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -43,7 +44,6 @@ public class RefreshTokenController { @ResponseStatus(HttpStatus.OK) @PostMapping("/token-refresh") public void tokenRefresh(@RequestBody @Valid RefreshTokenRequest request) { - // token 재발급 refreshTokenService.refreshToken(request.refreshToken()); } diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshToken.java b/src/main/java/bumblebee/xchangepass/domain/refresh/entity/RefreshToken.java similarity index 96% rename from src/main/java/bumblebee/xchangepass/domain/refresh/RefreshToken.java rename to src/main/java/bumblebee/xchangepass/domain/refresh/entity/RefreshToken.java index 05d12459..614e347a 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshToken.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/entity/RefreshToken.java @@ -1,4 +1,4 @@ -package bumblebee.xchangepass.domain.refresh; +package bumblebee.xchangepass.domain.refresh.entity; import bumblebee.xchangepass.global.error.ErrorCode; import lombok.AccessLevel; diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenRepository.java b/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java similarity index 92% rename from src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenRepository.java rename to src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java index 0e62e459..55402eef 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java @@ -1,21 +1,20 @@ -package bumblebee.xchangepass.domain.refresh; +package bumblebee.xchangepass.domain.refresh.repository; import bumblebee.xchangepass.global.error.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; -import java.util.Set; import java.util.concurrent.TimeUnit; +import static bumblebee.xchangepass.global.common.Constants.REFRESH_TOKEN_TTL; + @Repository @RequiredArgsConstructor public class RefreshTokenRepository { private final RedisTemplate jsonRedisTemplate; - private static final Long REFRESH_TOKEN_TTL = 24 * 60 * 60L; // 24시간 (초 단위) - /** * Refresh Token 저장 (사용자당 하나만 유지, 기존 토큰 자동 삭제) */ diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java b/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java similarity index 92% rename from src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java rename to src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java index a21a53eb..d0bb2dd2 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java @@ -1,5 +1,6 @@ -package bumblebee.xchangepass.domain.refresh; +package bumblebee.xchangepass.domain.refresh.service; +import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository; import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; diff --git a/src/main/java/bumblebee/xchangepass/domain/user/controller/UserController.java b/src/main/java/bumblebee/xchangepass/domain/user/controller/UserController.java index a76f1440..8c70eddc 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/controller/UserController.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/controller/UserController.java @@ -4,8 +4,8 @@ import bumblebee.xchangepass.domain.user.dto.request.UserUpdateRequest; import bumblebee.xchangepass.domain.user.dto.response.UserResponse; import bumblebee.xchangepass.domain.user.service.UserService; -import bumblebee.xchangepass.global.security.jwt.JwtUtil; import bumblebee.xchangepass.domain.user.service.UserRegisterService; +import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; @@ -64,8 +65,8 @@ public void signup(@RequestBody @Valid UserRegisterRequest request) { }) @ResponseStatus(HttpStatus.OK) @GetMapping - public UserResponse read(Authentication authentication){ - return userService.readUser(JwtUtil.getLoginId(authentication)); + public UserResponse read(@AuthenticationPrincipal CustomUserDetails user){ + return userService.readUser(user.getUserId()); } @@ -87,9 +88,9 @@ public UserResponse read(Authentication authentication){ }) @ResponseStatus(HttpStatus.NO_CONTENT) @PutMapping - public void update(Authentication authentication, + public void update(@AuthenticationPrincipal CustomUserDetails user, @RequestBody @Valid UserUpdateRequest request) { - userService.updateUser(JwtUtil.getLoginId(authentication), request); + userService.updateUser(user.getUserId(), request); } @@ -104,7 +105,7 @@ public void update(Authentication authentication, }) @ResponseStatus(HttpStatus.NO_CONTENT) @DeleteMapping - public void softDeleteUser(Authentication authentication) { - userService.softDeleteUser(JwtUtil.getLoginId(authentication)); + public void softDeleteUser(@AuthenticationPrincipal CustomUserDetails user) { + userService.softDeleteUser(user.getUserId()); } } diff --git a/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java b/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java index 618cfc56..d9cd4a59 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java @@ -2,7 +2,7 @@ import bumblebee.xchangepass.domain.exchangeTransaction.entitiy.ExchangeTransaction; import bumblebee.xchangepass.domain.user.entity.value.*; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java index 97e70925..05d79ebd 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java @@ -6,7 +6,7 @@ import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; -import bumblebee.xchangepass.domain.refresh.RefreshTokenRepository; +import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -26,7 +26,6 @@ public class LoginService{ @Transactional public LoginResponse login(final LoginRequest request) { - System.out.println("loginRequestDTO = " + request); // 사용자 정보 조회 UserLoginResponse userInfo = userService.readUserByUserEmail(request.userEmail()); diff --git a/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java b/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java index 3458d700..e00be48e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java @@ -14,7 +14,7 @@ public interface UserRepository extends JpaRepository, UserRepositoryCustom { @Query(value = "SELECT m FROM User m WHERE m.userEmail.value=:email") - Optional findByUserEmail(String email); + Optional findByUserEmail(@Param("email") String email); @Modifying @Query(value = """ @@ -29,5 +29,13 @@ public interface UserRepository extends JpaRepository, UserRepositor FROM User u WHERE u.userId = :userId """) - Optional findByUserId(Long userId); + Optional findByUserId(@Param("userId") Long userId); + + @Query(value = """ + SELECT u + FROM User u + WHERE u.userName.value = :userName and u.userPhoneNumber.value=:userPhoneNumber + """) + Optional findByUserId(@Param("userName") String userName, @Param("userPhoneNumber") String userPhoneNumber); + } diff --git a/src/main/java/bumblebee/xchangepass/domain/user/service/UserRegisterService.java b/src/main/java/bumblebee/xchangepass/domain/user/service/UserRegisterService.java index 87c4345e..82f216b9 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/service/UserRegisterService.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/service/UserRegisterService.java @@ -3,8 +3,8 @@ import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.user.service.NicknameGenerator; -import bumblebee.xchangepass.domain.wallet.service.WalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletServiceImpl; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.PessimisticLockWalletService; import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import bumblebee.xchangepass.global.util.DuplicateKeyExceptionHandler; @@ -20,7 +20,7 @@ public class UserRegisterService { private final UserRepository userRepository; private final NicknameGenerator nicknameGenerator; - private final WalletService walletService; + private final WalletServiceImpl walletService; private final BCryptPasswordEncoder bCryptPasswordEncoder; /** diff --git a/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java b/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java index 52662fa0..f9163483 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java @@ -31,6 +31,11 @@ public UserResponse readUser(Long userId) { return new UserResponse(user); } + public User readUser(String userName, String userPhoneNumber) { + return userRepository.findByUserId(userName, userPhoneNumber) + .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); + } + /** * ✅ 사용자 정보 수정 */ @@ -74,10 +79,8 @@ public void deleteUserBatch(LocalDateTime thirtyDaysAgo) { } public UserLoginResponse readUserByUserId(String userId) { - System.out.println("userId = " + userId); User user = userRepository.findByUserId(Long.parseLong(userId)) .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); - System.out.println("user = " + user.toString()); return UserLoginResponse.fromEntity(user); } diff --git a/src/main/java/bumblebee/xchangepass/domain/walletBalance/entity/WalletBalance.java b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/entity/WalletBalance.java similarity index 79% rename from src/main/java/bumblebee/xchangepass/domain/walletBalance/entity/WalletBalance.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/balance/entity/WalletBalance.java index 40c40be4..ff8e9e41 100644 --- a/src/main/java/bumblebee/xchangepass/domain/walletBalance/entity/WalletBalance.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/entity/WalletBalance.java @@ -1,6 +1,6 @@ -package bumblebee.xchangepass.domain.walletBalance.entity; +package bumblebee.xchangepass.domain.wallet.balance.entity; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import bumblebee.xchangepass.global.converter.CurrencyConverter; import jakarta.persistence.*; import lombok.Getter; @@ -13,8 +13,6 @@ import java.time.LocalDateTime; import java.util.Currency; -import static lombok.AccessLevel.PROTECTED; - @Entity @Table(name = "balance") @Getter @@ -23,23 +21,23 @@ public class WalletBalance { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "balance_id", nullable = false) - public Long balanceId; + private Long balanceId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "wallet_id") - public Wallet wallet; + private Wallet wallet; @Convert(converter = CurrencyConverter.class) - public Currency currency; + private Currency currency; @Column(nullable = false, precision = 18, scale = 2) private BigDecimal balance = BigDecimal.ZERO; @CreatedDate - public LocalDateTime currencyCreatedAt; + private LocalDateTime currencyCreatedAt; @LastModifiedDate - public LocalDateTime currencyModifiedAt; + private LocalDateTime currencyModifiedAt; public WalletBalance(Wallet wallet, Currency currency) { this.wallet = wallet; diff --git a/src/main/java/bumblebee/xchangepass/domain/walletBalance/repository/WalletBalanceRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/repository/WalletBalanceRepository.java similarity index 62% rename from src/main/java/bumblebee/xchangepass/domain/walletBalance/repository/WalletBalanceRepository.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/balance/repository/WalletBalanceRepository.java index ad584d57..d8cea519 100644 --- a/src/main/java/bumblebee/xchangepass/domain/walletBalance/repository/WalletBalanceRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/repository/WalletBalanceRepository.java @@ -1,6 +1,6 @@ -package bumblebee.xchangepass.domain.walletBalance.repository; +package bumblebee.xchangepass.domain.wallet.balance.repository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -8,8 +8,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; import java.util.Currency; import java.util.List; import java.util.Optional; @@ -22,14 +22,14 @@ public interface WalletBalanceRepository extends JpaRepository findByWalletId(Long walletId); + List findByWalletId(@Param("walletId") Long walletId); @Query(""" select wb from WalletBalance wb where wb.wallet.walletId=:walletId and wb.currency=:currency """) - Optional findByWalletIdAndCurrency(Long walletId, Currency currency); + Optional findByWalletIdAndCurrency(@Param("walletId") Long walletId, @Param("currency") Currency currency); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" @@ -37,12 +37,19 @@ public interface WalletBalanceRepository extends JpaRepository findByWalletIdWithPessimisticLock(final Long walletId); + List findByWalletIdWithPessimisticLock(@Param("walletId") final Long walletId); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT wb FROM WalletBalance wb WHERE wb.wallet.walletId = :walletId AND wb.currency = :currency") Optional findByWalletIdAndCurrencyWithPessimisticLock(@Param("walletId") Long walletId, @Param("currency") Currency currency); @Query("SELECT COUNT(wb) > 0 FROM WalletBalance wb WHERE wb.wallet.walletId = :walletId and wb.currency=:currency") - boolean existsByCurrency(Long walletId, Currency currency); + boolean existsByCurrency(@Param("walletId") Long walletId, @Param("currency") Currency currency); + + + //테스트용 + @Transactional + @Modifying(clearAutomatically = true) + @Query("update WalletBalance wb set wb.balance=0 where wb.wallet.walletId=:walletId and wb.currency=:currency") + void zeroBalance(@Param("walletId") Long walletId, @Param("currency") Currency currency); } diff --git a/src/main/java/bumblebee/xchangepass/domain/walletBalance/service/WalletBalanceService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java similarity index 64% rename from src/main/java/bumblebee/xchangepass/domain/walletBalance/service/WalletBalanceService.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java index b52e7eb6..e42b984b 100644 --- a/src/main/java/bumblebee/xchangepass/domain/walletBalance/service/WalletBalanceService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java @@ -1,8 +1,10 @@ -package bumblebee.xchangepass.domain.walletBalance.service; +package bumblebee.xchangepass.domain.wallet.balance.service; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.transaction.service.WalletTransactionService; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import bumblebee.xchangepass.global.error.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,11 +19,10 @@ public class WalletBalanceService { private final WalletBalanceRepository balanceRepository; + private final WalletTransactionService transactionService; - @Transactional public void createBalance(Wallet wallet, Currency currency) { if (balanceRepository.existsByCurrency(wallet.getWalletId(), currency)) { - // 이미 존재하는 경우 생성하지 않음 return; } @@ -29,7 +30,7 @@ public void createBalance(Wallet wallet, Currency currency) { balanceRepository.save(balance); } - @Transactional + @Transactional(readOnly = true) public WalletBalance findBalance(Long walletId, Currency currency) { return balanceRepository.findByWalletIdAndCurrency(walletId, currency) .orElseThrow(ErrorCode.BALANCE_NOT_FOUND::commonException); @@ -41,7 +42,7 @@ public WalletBalance findBalanceWithLock(Long walletId, Currency currency) { .orElseThrow(ErrorCode.BALANCE_NOT_FOUND::commonException); } - @Transactional + @Transactional(readOnly = true) public List findBalances(Long walletId) { return balanceRepository.findByWalletId(walletId); } @@ -51,29 +52,35 @@ public List findBalancesWithLock(Long walletId) { return balanceRepository.findByWalletIdWithPessimisticLock(walletId); } - @Transactional public boolean checkBalance(Long walletId, Currency currency) { return balanceRepository.existsByCurrency(walletId, currency); } - @Transactional public void chargeBalance(WalletBalance balance, BigDecimal amount) { balance.addBalance(amount); balanceRepository.save(balance); + + transactionService.saveTransaction(balance.getWallet().getWalletId(), null, amount, null, balance.getCurrency(), WalletTransactionType.DEPOSIT); } - @Transactional public void withdrawBalance(WalletBalance balance, BigDecimal amount) { + if (amount.compareTo(balance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + balance.subtractBalance(amount); balanceRepository.save(balance); + + transactionService.saveTransaction(balance.getWallet().getWalletId(), null, amount, null, balance.getCurrency(), WalletTransactionType.WITHDRAWAL); } - @Transactional public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, BigDecimal amount) { fromBalance.subtractBalance(amount); toBalance.addBalance(amount); balanceRepository.save(fromBalance); balanceRepository.save(toBalance); + + transactionService.saveTransaction(fromBalance.getWallet().getWalletId(), toBalance.getWallet().getWalletId(), amount, fromBalance.getCurrency(), toBalance.getCurrency(), WalletTransactionType.TRANSFER); } } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletCreateRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletCreateRequest.java deleted file mode 100644 index 5995e93c..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletCreateRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.dto.request; - -public record WalletCreateRequest( - Long userId -) { -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletInOutRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletInOutRequest.java index 01bddb82..e69de29b 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletInOutRequest.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletInOutRequest.java @@ -1,16 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.dto.request; - -import lombok.Builder; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Currency; - -@Builder -public record WalletInOutRequest( - Long userId, - BigDecimal amount, - Currency fromCurrency, - Currency toCurrency, - LocalDateTime chargeDatetime -){} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletTransferRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletTransferRequest.java deleted file mode 100644 index 0aa72971..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletTransferRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.dto.request; - - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Currency; - -public record WalletTransferRequest( - Long senderWalletId, - Long receiverWalletId, - BigDecimal transferAmount, - Currency fromCurrency, - Currency toCurrency, - LocalDateTime transferDatetime -) { -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletBalanceResponse.java b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletBalanceResponse.java deleted file mode 100644 index 915f656b..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletBalanceResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.dto.response; - -import java.math.BigDecimal; - -public record WalletBalanceResponse( - String currency, - BigDecimal balance -) { - public WalletBalanceResponse(String currency, BigDecimal balance) { - this.currency = currency; - this.balance = balance; - } -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletTransactionResponse.java b/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletTransactionResponse.java deleted file mode 100644 index fd8006c7..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/dto/response/WalletTransactionResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.dto.response; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Currency; - -public record WalletTransactionResponse( - -) { -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/repository/LockRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/repository/LockRepository.java deleted file mode 100644 index 6fafb704..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/repository/LockRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.repository; - -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import org.hibernate.cache.spi.support.AbstractReadWriteAccess; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -@Repository -public interface LockRepository extends JpaRepository { - - @Query(value = "SELECT pg_advisory_lock(:key)", nativeQuery = true) - void getLock(Long key); - - @Query(value = "SELECT pg_advisory_unlock(:key)", nativeQuery = true) - void releaseLock(Long key); -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/service/NamedLockWalletFacade.java b/src/main/java/bumblebee/xchangepass/domain/wallet/service/NamedLockWalletFacade.java deleted file mode 100644 index 83dd7fdd..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/service/NamedLockWalletFacade.java +++ /dev/null @@ -1,106 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.service; - -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.LockRepository; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; -import bumblebee.xchangepass.global.error.ErrorCode; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; - -@Component -public class NamedLockWalletFacade { - private final WalletRepository walletRepository; - private final WalletBalanceService balanceService; - private final LockRepository lockRepository; - - public NamedLockWalletFacade(WalletRepository walletRepository, WalletBalanceService walletBalanceService, LockRepository lockRepository) { - this.walletRepository = walletRepository; - this.balanceService = walletBalanceService; - this.lockRepository = lockRepository; - } - - @Transactional - public void charge(WalletInOutRequest request) { - Wallet wallet = walletRepository.findByUserId(request.userId()); - - if (wallet == null) { - throw ErrorCode.WALLET_NOT_FOUND.commonException(); - } - - // Advisory Lock을 잡기 전에 트랜잭션이 유지되도록 보장 - lockRepository.getLock(wallet.getWalletId()); - - try { - if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { - Wallet findWallet = walletRepository.findById(wallet.getWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - balanceService.createBalance(findWallet, request.toCurrency()); - } - - WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); - balanceService.chargeBalance(balance, request.amount()); - - } finally { - // 트랜잭션 종료 시점에서 락을 해제하도록 변경 - lockRepository.releaseLock(wallet.getWalletId()); - } - } - - @Transactional - public BigDecimal withdrawal(WalletInOutRequest request) { - Wallet wallet = null; - WalletBalance balance = null; - try { - wallet = walletRepository.findByUserId(request.userId()); - - // Advisory Lock 획득 - lockRepository.getLock(wallet.getWalletId()); - - balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); - balanceService.withdrawBalance(balance, request.amount()); - - } finally { - if (wallet != null) { - lockRepository.releaseLock(wallet.getWalletId()); - } - } - return balance.getBalance(); - } - - @Transactional - public void transfer(WalletTransferRequest request) { - Wallet fromWallet = walletRepository.findById(request.senderWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet toWallet = walletRepository.findById(request.receiverWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - - // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 - long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); - long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); - - lockRepository.getLock(smallerId); - lockRepository.getLock(largerId); - - try { - WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); - - BigDecimal transferAmount = request.transferAmount(); - - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } finally { - lockRepository.releaseLock(largerId); - lockRepository.releaseLock(smallerId); - } - } -} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java index 63418f0d..e69de29b 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java @@ -1,141 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.service; - -import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; -import bumblebee.xchangepass.domain.card.entity.CardType; -import bumblebee.xchangepass.domain.card.service.CardService; -import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; -import bumblebee.xchangepass.domain.user.entity.User; -import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.dto.response.WalletBalanceResponse; -import bumblebee.xchangepass.domain.wallet.dto.response.WalletTransactionResponse; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; -import bumblebee.xchangepass.global.error.ErrorCode; -import bumblebee.xchangepass.global.exception.CommonException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.util.Currency; -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -public class WalletService { - - private final WalletRepository walletRepository; - private final CardService cardService; - private final WalletBalanceService balanceService; - private final ExchangeService exchangeService; - - @Transactional - public void createWallet(User user, String walletPassword) { - Wallet wallet = new Wallet(user, walletPassword); - - user.changeWallet(walletRepository.save(wallet)); - balanceService.createBalance(wallet, Currency.getInstance("KRW")); - - // ✅ 모바일 카드 발급 (동기 처리) - cardService.generateMobileCard(wallet); - } - - - @Transactional - public List transaction(Long userId) { - - - return null; - } - - - @Transactional - public void charge(WalletInOutRequest request) { - BigDecimal chargeAmount = request.amount(); - if (!request.toCurrency().equals(request.fromCurrency())) { - chargeAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.amount()); - } - - System.out.println("충전시작"); - Wallet wallet = walletRepository.findByUserIdWithLock(request.userId()); - - if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { - Wallet findWallet = walletRepository.findById(wallet.getWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - balanceService.createBalance(findWallet, request.toCurrency()); - } - - WalletBalance balance = balanceService.findBalanceWithLock(wallet.getWalletId(), request.toCurrency()); - balanceService.chargeBalance(balance, chargeAmount); - } - - @Transactional - public BigDecimal withdrawal(WalletInOutRequest request) { - BigDecimal amount = request.amount(); - if (!request.toCurrency().equals(request.fromCurrency())) { - amount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), amount); - } - - Wallet wallet = walletRepository.findByUserIdWithLock(request.userId()); - WalletBalance balance = balanceService.findBalanceWithLock(wallet.getWalletId(), request.toCurrency()); - - if (amount.compareTo(balance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - - balanceService.withdrawBalance(balance, amount); - return balance.getBalance(); - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) -// @Transactional - public void transfer(WalletTransferRequest request) { - WalletBalance fromBalance = balanceService.findBalanceWithLock(request.senderWalletId(), request.fromCurrency()); - - if (!balanceService.checkBalance(request.receiverWalletId(), request.toCurrency())) { - Wallet wallet = walletRepository.findById(request.receiverWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - - balanceService.createBalance(wallet, request.toCurrency()); - } - - WalletBalance toBalance = balanceService.findBalanceWithLock(request.receiverWalletId(), request.toCurrency()); - - BigDecimal transferAmount = request.transferAmount(); - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } - - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } - - - @Transactional - public List balance(Long userId) { - Wallet wallet = walletRepository.findByUserIdWithLock(userId); - System.out.println("wallet.getWalletId() = " + wallet.getWalletId()); - - List balanceList = balanceService.findBalancesWithLock(wallet.getWalletId()); - System.out.println("balanceList.get(0) = " + balanceList.get(0)); - - return balanceList.stream() - .peek(balance -> System.out.println("Processing balance: " + balance.getBalanceId())) - .map(balance -> new WalletBalanceResponse( - balance.currency.getCurrencyCode(), - balance.getBalance() - )) - .toList(); - } -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLockService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLockService.java deleted file mode 100644 index f054d233..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLockService.java +++ /dev/null @@ -1,154 +0,0 @@ -package bumblebee.xchangepass.domain.wallet.service.redisson; - -import bumblebee.xchangepass.domain.user.entity.User; -import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.dto.response.WalletBalanceResponse; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; -import bumblebee.xchangepass.global.error.ErrorCode; -import bumblebee.xchangepass.global.exception.CommonException; -import lombok.RequiredArgsConstructor; -import org.redisson.RedissonMultiLock; -import org.redisson.api.RLock; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.util.Currency; -import java.util.List; -import java.util.concurrent.TimeUnit; - -@Service -@RequiredArgsConstructor -public class RedissonLockService { - - private final WalletRepository walletRepository; - private final UserRepository userRepository; - private final WalletBalanceService balanceService; - private final RedissonLock redissonLock; // RedissonLock을 주입받아 사용 - - @Transactional - public void createWallet(Long userId) { - if (walletRepository.existsByUserId(userId)) { - throw new CommonException(ErrorCode.WALLET_ALREADY_EXIST); - } - - User user = userRepository.findById(userId) - .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); - // 나중에 수정 필요 - Wallet wallet = new Wallet(user, "1234"); - - - walletRepository.save(wallet); - walletRepository.flush(); - balanceService.createBalance(wallet, Currency.getInstance("KRW")); - } - - /** - * 🔒 지갑 충전 (RedissonLock 적용) - */ - @Transactional - public void charge(WalletInOutRequest request) { - String lockKey = "wallet:" + request.userId(); - redissonLock.tryLockVoid(lockKey, 10, 10, () -> { - System.out.println("🔒 충전 시작"); - Wallet wallet = walletRepository.findByUserId(request.userId()); - - if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { - Wallet findWallet = walletRepository.findById(wallet.getWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - balanceService.createBalance(findWallet, request.toCurrency()); - } - - WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); - balanceService.chargeBalance(balance, request.amount()); - System.out.println("🔓 충전 완료"); - }); - } - - /** - * 🔒 지갑 출금 (RedissonLock 적용) - */ - @Transactional - public BigDecimal withdrawal(WalletInOutRequest request) { - String lockKey = "wallet:" + request.userId(); - return redissonLock.tryLock(lockKey, 10, 10, () -> { - Wallet wallet = walletRepository.findByUserId(request.userId()); - WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); - - if (request.amount().compareTo(balance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - balanceService.withdrawBalance(balance, request.amount()); - return balance.getBalance(); - }); - } - - /** - * 🔒 지갑 송금 (멀티 락 적용) - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void transfer(WalletTransferRequest request) { - String senderLockKey = "wallet:" + request.senderWalletId(); - String receiverLockKey = "wallet:" + request.receiverWalletId(); - - RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); - RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); - RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); - - boolean acquired = false; - try { - acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); - if (!acquired) { - throw new RuntimeException("🔴 송금 중 락을 획득하지 못했습니다."); - } - - System.out.println("🔵 [송금 시작] " + request.senderWalletId() + " -> " + request.receiverWalletId()); - - WalletBalance fromBalance = balanceService.findBalance(request.senderWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(request.receiverWalletId(), request.toCurrency()); - - if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - balanceService.transferBalance(fromBalance, toBalance, request.transferAmount()); - - System.out.println(balanceService.findBalance(request.receiverWalletId(), request.toCurrency()).getBalance()); - System.out.println(balanceService.findBalance(request.senderWalletId(), request.fromCurrency()).getBalance()); - - - } catch (InterruptedException e) { - throw new RuntimeException("🔴 락 획득 중 인터럽트 발생", e); - } finally { - if (acquired) { - System.out.println("🔓 [Redisson Lock 해제] " + senderLockKey + " & " + receiverLockKey); - multiLock.unlock(); - } - } - } - - - @Transactional - public List balance(Long userId) { - Wallet wallet = walletRepository.findByUserId(userId); - System.out.println("wallet.getWalletId() = " + wallet.getWalletId()); - - List balanceList = balanceService.findBalances(wallet.getWalletId()); - System.out.println("balanceList.get(0) = " + balanceList.get(0)); - - return balanceList.stream() - .peek(balance -> System.out.println("Processing balance: " + balance.getBalanceId())) - .map(balance -> new WalletBalanceResponse( - balance.currency.getCurrencyCode(), - balance.getBalance() - )) - .toList(); - } -} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/DeadLetterConsumer.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/DeadLetterConsumer.java new file mode 100644 index 00000000..2afb91c4 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/DeadLetterConsumer.java @@ -0,0 +1,48 @@ +package bumblebee.xchangepass.domain.wallet.transaction.consumer; + +import bumblebee.xchangepass.domain.wallet.transaction.dto.WalletTransactionMessage; +import com.rabbitmq.client.Channel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeadLetterConsumer { + + private final RabbitTemplate rabbitTemplate; + private final SlackNotifier slackNotifier; + + @RabbitListener(queues = "wallet-transaction-dlx-queue") + public void handleDeadLetter(WalletTransactionMessage message, + @Header(AmqpHeaders.DELIVERY_TAG) long tag, + @Header("x-death") List> xDeathHeader, + Channel channel) throws IOException { + + int retryCount = 0; + if (xDeathHeader != null && !xDeathHeader.isEmpty()) { + Map death = xDeathHeader.get(0); + retryCount = ((Long) death.get("count")).intValue(); + } + + if (retryCount < 3) { + log.warn("♻️ DLQ 재시도 {}회차: {}", retryCount + 1, message); + rabbitTemplate.convertAndSend("wallet-transaction-retry-queue", message); + } else { + log.error("🚨 DLQ 재시도 초과, 슬랙 알림 전송: {}", message); + slackNotifier.send("🚨 DLQ 처리 실패: " + message); + } + + // 수동 ack + channel.basicAck(tag, false); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java new file mode 100644 index 00000000..60fdb896 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java @@ -0,0 +1,54 @@ +package bumblebee.xchangepass.domain.wallet.transaction.consumer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SlackNotifier { + + @Value("${slack.webhook.url}") + private String webhookUrl; + + private final RestTemplate restTemplate = new RestTemplate(); + + public void send(String message) { + Map payload = new HashMap<>(); + payload.put("text", ":rotating_light: *DLQ 경고 발생*"); + payload.put("blocks", List.of( + Map.of( + "type", "section", + "text", Map.of( + "type", "mrkdwn", + "text", "*📛 DLQ 메시지 알림!*\n```" + message + "```" + ) + ), + Map.of( + "type", "context", + "elements", List.of( + Map.of("type", "mrkdwn", "text", ":clock1: " + LocalDateTime.now()) + ) + ) + )); + + try { + restTemplate.postForEntity(webhookUrl, payload, String.class); + } catch (Exception e) { + log.error("Slack 전송 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/WalletTransactionConsumer.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/WalletTransactionConsumer.java new file mode 100644 index 00000000..e912ea2b --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/WalletTransactionConsumer.java @@ -0,0 +1,59 @@ +package bumblebee.xchangepass.domain.wallet.transaction.consumer; + +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionStatus; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.transaction.dto.WalletTransactionMessage; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Currency; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WalletTransactionConsumer { + + private final WalletTransactionRepository transactionRepository; + private final WalletRepository walletRepository; + + @RabbitListener(queues = "wallet-transaction-queue") + @Transactional + public void processTransaction(WalletTransactionMessage message) { + try { + Wallet myWallet = walletRepository.findById(message.myWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + Wallet counterWallet = (message.counterWalletId() != null) + ? walletRepository.findById(message.counterWalletId()).orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException) + : null; + + WalletTransaction transaction = new WalletTransaction( + myWallet, + counterWallet, + message.amount(), + message.fromCurrency() != null ? Currency.getInstance(message.fromCurrency()) : null, + Currency.getInstance(message.toCurrency()), + WalletTransactionType.valueOf(message.transactionType()) + ); + + transaction.updateStatus(WalletTransactionStatus.SUCCESS); + + transactionRepository.save(transaction); + } catch (Exception e) { + log.error("❌ 트랜잭션 처리 중 예외 발생. DLQ로 보냅니다. message={}", message, e); + + // 📌 이 예외를 던지면 RabbitMQ는 재시도 없이 DLQ로 메시지를 넘깁니다 + throw new AmqpRejectAndDontRequeueException("트랜잭션 처리 실패", e); + } + } + +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/WalletTransactionMessage.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/WalletTransactionMessage.java new file mode 100644 index 00000000..40a219d5 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/WalletTransactionMessage.java @@ -0,0 +1,13 @@ +package bumblebee.xchangepass.domain.wallet.transaction.dto; + +import java.math.BigDecimal; + +//내부 지갑 트랜잭션 메시지 +public record WalletTransactionMessage( + Long myWalletId, + Long counterWalletId, + BigDecimal amount, + String fromCurrency, + String toCurrency, + String transactionType +) {} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/request/WalletTransactionSearchCondition.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/request/WalletTransactionSearchCondition.java new file mode 100644 index 00000000..045e386a --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/request/WalletTransactionSearchCondition.java @@ -0,0 +1,28 @@ +package bumblebee.xchangepass.domain.wallet.transaction.dto.request; + +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionStatus; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +@Schema(description = "거래내역 조회 요청 객체") +public record WalletTransactionSearchCondition( + + @Schema(description = "거래 종류", example = "TRANSFER") + WalletTransactionType transactionType, + + @Schema(description = "거래 완료 상황", example = "PENDING") + WalletTransactionStatus status, + + @Schema(description = "조회 시작 일자", example = "2025-03-26T00:00:00") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Schema(description = "조회 종료 일자", example = "2025-03-26T00:00:00") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate +) { +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/response/WalletTransactionListResponse.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/response/WalletTransactionListResponse.java new file mode 100644 index 00000000..edab9663 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/dto/response/WalletTransactionListResponse.java @@ -0,0 +1,60 @@ +package bumblebee.xchangepass.domain.wallet.transaction.dto.response; + +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionStatus; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "화폐별 잔액 응답 객체") +public record WalletTransactionListResponse( + + @Schema(description = "거래내역 ID", example = "1") + Long transactionId, + + @Schema(description = "송금 금액", example = "10000.00") + BigDecimal amount, + + @Schema(description = "가지고 계신 화폐 종류", example = "KRW, USD") + Currency fromCurrency, + + @Schema(description = "보내고자 하는 화폐 종류", example = "KRW, USD") + Currency toCurrency, + + @Schema(description = "거래 종류", example = "DEPOSIT, WITHDRAWAL, TRANSFER") + WalletTransactionType transactionType, + + @Schema(description = "거래 진행 상황", example = "PENDING, SUCCESS, FAILED") + WalletTransactionStatus status, + + @Schema(description = "거래 완료된 시간", example = "2024-02-20T12:34:56") + LocalDateTime updatedAt, + + @Schema(description = "받으시는 분 walletId", example = "1") + Long counterWalletId, + + @Schema(description = "받으시는 분 이름", example = "홍길동") + String counterpartyName + +) { + public static WalletTransactionListResponse fromEntity(WalletTransaction transaction) { + Wallet counterWallet = transaction.getCounterWallet(); + + return new WalletTransactionListResponse( + transaction.getWalletTransactionId(), + transaction.getAmount(), + transaction.getFromCurrency(), + transaction.getToCurrency(), + transaction.getTransactionType(), + transaction.getStatus(), + transaction.getUpdatedAt(), + counterWallet != null ? counterWallet.getWalletId() : null, + counterWallet != null ? counterWallet.getUser().getUserName().getValue() : null + + ); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransaction.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransaction.java new file mode 100644 index 00000000..9d6ff4ff --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransaction.java @@ -0,0 +1,67 @@ +package bumblebee.xchangepass.domain.wallet.transaction.entity; + +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Entity +@Table(name = "wallet_transaction") +@Getter +@NoArgsConstructor() +@EntityListeners(AuditingEntityListener.class) +public class WalletTransaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "wallet_transaction_id", nullable = false) + private Long walletTransactionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "my_wallet_id", nullable = false) + private Wallet myWallet; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "counter_wallet_id") + private Wallet counterWallet; + + @Column(nullable = false) + private BigDecimal amount; + + private Currency fromCurrency; + + @Column(nullable = false) + private Currency toCurrency; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private WalletTransactionType transactionType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private WalletTransactionStatus status; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt = LocalDateTime.now(); + + public WalletTransaction(Wallet myWallet, Wallet counterWallet, BigDecimal amount, Currency fromCurrency, Currency toCurrency, WalletTransactionType transactionType) { + this.myWallet = myWallet; + this.counterWallet = counterWallet; + this.amount = amount; + this.fromCurrency = fromCurrency; + this.toCurrency = toCurrency; + this.transactionType = transactionType; + this.status = WalletTransactionStatus.PENDING; + } + + public void updateStatus(WalletTransactionStatus newStatus) { + this.status = newStatus; + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionStatus.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionStatus.java new file mode 100644 index 00000000..babcfac7 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionStatus.java @@ -0,0 +1,7 @@ +package bumblebee.xchangepass.domain.wallet.transaction.entity; + +public enum WalletTransactionStatus { + PENDING, // 처리 중 + SUCCESS, // 완료됨 + FAILED // 실패 +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionType.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionType.java new file mode 100644 index 00000000..d8b56cfa --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/entity/WalletTransactionType.java @@ -0,0 +1,7 @@ +package bumblebee.xchangepass.domain.wallet.transaction.entity; + +public enum WalletTransactionType { + DEPOSIT, // 입금 + WITHDRAWAL, // 출금 + TRANSFER, // 송금 +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/producer/WalletTransactionProducer.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/producer/WalletTransactionProducer.java new file mode 100644 index 00000000..944e1507 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/producer/WalletTransactionProducer.java @@ -0,0 +1,34 @@ +package bumblebee.xchangepass.domain.wallet.transaction.producer; + +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.transaction.dto.WalletTransactionMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Currency; + +@Service +@RequiredArgsConstructor +public class WalletTransactionProducer { + + private final RabbitTemplate rabbitTemplate; + + public void sendAsyncTransaction(Long myWalletId, Long counterWalletId, BigDecimal amount, + Currency fromCurrency, Currency toCurrency, + WalletTransactionType transactionType) { + + WalletTransactionMessage message = new WalletTransactionMessage( + myWalletId, + counterWalletId, + amount, + fromCurrency != null ? fromCurrency.getCurrencyCode() : null, + toCurrency.getCurrencyCode(), + transactionType.name() + ); + + rabbitTemplate.convertAndSend("wallet-transaction-queue", message); + } + +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/WalletTransactionRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/WalletTransactionRepository.java new file mode 100644 index 00000000..ffa1bd9c --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/WalletTransactionRepository.java @@ -0,0 +1,21 @@ +package bumblebee.xchangepass.domain.wallet.transaction.repository; + +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import bumblebee.xchangepass.domain.wallet.transaction.repository.search.WalletTransactionRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface WalletTransactionRepository extends JpaRepository, WalletTransactionRepositoryCustom { + @Query(""" + select t from WalletTransaction t + where t.myWallet.walletId=:walletId or t.counterWallet.walletId=:walletId + order by t.updatedAt DESC + """) + List getWalletTransaction(@Param("walletId") Long walletId); + +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustom.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustom.java new file mode 100644 index 00000000..868b70b8 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustom.java @@ -0,0 +1,10 @@ +package bumblebee.xchangepass.domain.wallet.transaction.repository.search; + +import bumblebee.xchangepass.domain.wallet.transaction.dto.request.WalletTransactionSearchCondition; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface WalletTransactionRepositoryCustom { + Page search(Long walletId, WalletTransactionSearchCondition condition, Pageable pageable); +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustomImpl.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustomImpl.java new file mode 100644 index 00000000..0c527cbc --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/repository/search/WalletTransactionRepositoryCustomImpl.java @@ -0,0 +1,55 @@ +package bumblebee.xchangepass.domain.wallet.transaction.repository.search; + +import bumblebee.xchangepass.domain.wallet.transaction.dto.request.WalletTransactionSearchCondition; +import bumblebee.xchangepass.domain.wallet.transaction.entity.QWalletTransaction; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import java.util.List; + +@RequiredArgsConstructor +public class WalletTransactionRepositoryCustomImpl implements WalletTransactionRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page search(Long walletId, WalletTransactionSearchCondition cond, Pageable pageable) { + QWalletTransaction tx = QWalletTransaction.walletTransaction; + + BooleanBuilder builder = new BooleanBuilder(); + if (walletId != null) { + builder.and( + tx.myWallet.walletId.eq(walletId) + .or(tx.counterWallet.walletId.eq(walletId)) + ); + } + if (cond.transactionType() != null) { + builder.and(tx.transactionType.eq(cond.transactionType())); + } + if (cond.status() != null) { + builder.and(tx.status.eq(cond.status())); + } + if (cond.startDate() != null) { + builder.and(tx.updatedAt.goe(cond.startDate())); + } + if (cond.endDate() != null) { + builder.and(tx.updatedAt.loe(cond.endDate())); + } + + List results = queryFactory + .selectFrom(tx) + .where(builder) + .orderBy(tx.updatedAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return PageableExecutionUtils.getPage(results, pageable, + () -> queryFactory.selectFrom(tx).where(builder).fetchCount()); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/service/WalletTransactionService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/service/WalletTransactionService.java new file mode 100644 index 00000000..4050f221 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/service/WalletTransactionService.java @@ -0,0 +1,50 @@ +package bumblebee.xchangepass.domain.wallet.transaction.service; + +import bumblebee.xchangepass.domain.wallet.transaction.dto.request.WalletTransactionSearchCondition; +import bumblebee.xchangepass.domain.wallet.transaction.dto.response.WalletTransactionListResponse; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.transaction.producer.WalletTransactionProducer; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Currency; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class WalletTransactionService { + + private final WalletTransactionRepository transactionRepository; + private final WalletRepository walletRepository; + private final WalletTransactionProducer transactionProducer; + + @Transactional + public void saveTransaction(Long myWalletId, Long counterWalletId, BigDecimal amount, Currency fromCurrency, Currency toCurrency, WalletTransactionType transactionType) { + + transactionProducer.sendAsyncTransaction( + myWalletId, + counterWalletId, + amount, + fromCurrency, + toCurrency, + transactionType + ); + } + + @Transactional + public List getTransaction(Long userId, WalletTransactionSearchCondition cond, Pageable pageable) { + Wallet wallet = walletRepository.findByUserIdWithLock(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + return transactionRepository.search(wallet.getWalletId(), cond, pageable) + .stream().map(WalletTransactionListResponse::fromEntity) + .toList(); + } + +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/controller/WalletController.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java similarity index 63% rename from src/main/java/bumblebee/xchangepass/domain/wallet/controller/WalletController.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java index e96ff094..c2ab5a85 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/controller/WalletController.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java @@ -1,13 +1,13 @@ -package bumblebee.xchangepass.domain.wallet.controller; +package bumblebee.xchangepass.domain.wallet.wallet.controller; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletCreateRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.dto.response.WalletBalanceResponse; -import bumblebee.xchangepass.domain.wallet.dto.response.WalletTransactionResponse; -import bumblebee.xchangepass.domain.wallet.service.NamedLockWalletFacade; -import bumblebee.xchangepass.domain.wallet.service.WalletService; -import bumblebee.xchangepass.domain.wallet.service.redisson.RedissonLockService; +import bumblebee.xchangepass.domain.wallet.transaction.dto.request.WalletTransactionSearchCondition; +import bumblebee.xchangepass.domain.wallet.transaction.dto.response.WalletTransactionListResponse; +import bumblebee.xchangepass.domain.wallet.transaction.service.WalletTransactionService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; +import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -15,8 +15,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; @@ -29,17 +33,24 @@ @Tag(name = "Wallet", description = "Wallet CRUD API") public class WalletController { - private final WalletService walletService; - private final NamedLockWalletFacade namedLockService; - private final RedissonLockService redissonLockService; + private final WalletServiceFactory walletServiceFactory; + private final WalletTransactionService transactionService; @Operation(summary = "거래내역 조회", description = "거래내역을 조회합니다.") @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "거래내역 조회 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\n \"code\": \"W001\"," + + "\n \"message\": \"지갑을 찾을 수 없습니다.\"}")) + ) }) @GetMapping("/transaction") @ResponseStatus(HttpStatus.OK) - public List transaction(@RequestParam Long userId) { - return walletService.transaction(userId); + public List transaction(@AuthenticationPrincipal CustomUserDetails user, + @ModelAttribute WalletTransactionSearchCondition condition, + Pageable pageable) { + return transactionService.getTransaction(user.getUserId(), condition, pageable); } @Operation(summary = "잔액 충전", description = "잔액을 충전합니다.") @@ -60,8 +71,9 @@ public List transaction(@RequestParam Long userId) { }) @PostMapping("/charge") @ResponseStatus(HttpStatus.CREATED) - public void charge(@RequestBody WalletInOutRequest request) { - redissonLockService.charge(request); + public void charge(@RequestBody @Valid WalletInOutRequest request, + @AuthenticationPrincipal CustomUserDetails user) { + walletServiceFactory.getService("namedLock").charge(user.getUserId(), request); } @Operation(summary = "출금", description = "돈을 출금합니다.") @@ -76,11 +88,12 @@ public void charge(@RequestBody WalletInOutRequest request) { }) @PutMapping("/withdraw") @ResponseStatus(HttpStatus.OK) - public BigDecimal withdrawal(@RequestBody WalletInOutRequest request) { - return redissonLockService.withdrawal(request); + public BigDecimal withdrawal(@RequestBody @Valid WalletInOutRequest request, + @AuthenticationPrincipal CustomUserDetails user) { + return walletServiceFactory.getService("namedLock").withdrawal(user.getUserId(), request); } - @Operation(summary = "송금", description = "돈을 송금합니다.") + @Operation(summary = "앱 내 송금", description = "돈을 송금합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "송금 성공", content = @Content(mediaType = "application/json")), @ApiResponse(responseCode = "400", description = "먼저 충전이 필요합니다.", @@ -98,8 +111,9 @@ public BigDecimal withdrawal(@RequestBody WalletInOutRequest request) { }) @PutMapping("/transfer") @ResponseStatus(HttpStatus.NO_CONTENT) - public void transfer(@RequestBody WalletTransferRequest request) { - redissonLockService.transfer(request); + public void transfer(@RequestBody @Valid WalletTransferRequest request, + @AuthenticationPrincipal CustomUserDetails user) { + walletServiceFactory.getService("namedLock").transfer(user.getUserId(), request); } @Operation(summary = "잔액 조회", description = "잔액을 조회합니다.") @@ -114,8 +128,8 @@ public void transfer(@RequestBody WalletTransferRequest request) { }) @GetMapping("/balance") @ResponseStatus(HttpStatus.OK) - public List balance(@RequestParam Long userId) { - return walletService.balance(userId); + public List balance(@AuthenticationPrincipal CustomUserDetails user) { + return walletServiceFactory.getService("namedLock").balance(user.getUserId()); } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletInOutRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletInOutRequest.java new file mode 100644 index 00000000..fcc64757 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletInOutRequest.java @@ -0,0 +1,39 @@ +package bumblebee.xchangepass.domain.wallet.wallet.dto.request; + +import bumblebee.xchangepass.global.validation.ValidCurrency; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Min; +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Builder +@Schema(description = "입금, 출금 요청 객체") +public record WalletInOutRequest( + @Schema(description = "입금 및 출금할 금액", example = "10000.00") + @DecimalMin(value = "1.00", message = "금액은 1.00 이상이어야 합니다.") + @Digits(integer = 10, fraction = 2, message = "최대 10자리 정수와 소수점 2자리까지 가능합니다.") + BigDecimal amount, + + @Schema(description = "가지고 계신 화폐 종류", example = "KRW, USD") + @ValidCurrency + Currency fromCurrency, + + @Schema(description = "바꾸고 싶어 하시는 화폐 종류", example = "KRW, USD") + @ValidCurrency + Currency toCurrency, + + @Schema(description = "입금 및 출금 날짜", example = "2024-02-20T12:34:56") + LocalDateTime chargeDatetime +) { + public WalletInOutRequest(BigDecimal amount, Currency fromCurrency, Currency toCurrency, LocalDateTime chargeDatetime) { + this.amount = amount; + this.fromCurrency = fromCurrency; + this.toCurrency = toCurrency; + this.chargeDatetime = chargeDatetime; + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletTransferRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletTransferRequest.java new file mode 100644 index 00000000..07642d2e --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletTransferRequest.java @@ -0,0 +1,39 @@ +package bumblebee.xchangepass.domain.wallet.wallet.dto.request; + +import bumblebee.xchangepass.global.validation.ValidCurrency; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "송금 요청 객체") +public record WalletTransferRequest( + @Schema(description = "받으시는 분 성함", example = "홍길동") + @NotNull + String receiverName, + + @Schema(description = "받으시는 분 전화번호", example = "010-1234-1234") + @NotNull + String receiverPhoneNumber, + + @Schema(description = "송금할 금액", example = "10000.00") + @DecimalMin(value = "1.00", message = "금액은 1.00 이상이어야 합니다.") + @Digits(integer = 10, fraction = 2, message = "최대 10자리 정수와 소수점 2자리까지 가능합니다.") + BigDecimal transferAmount, + + @Schema(description = "가지고 계신 화폐 종류", example = "KRW, USD") + @ValidCurrency + Currency fromCurrency, + + @Schema(description = "보내고자 하는 화폐 종류", example = "KRW, USD") + @ValidCurrency + Currency toCurrency, + + @Schema(description = "송금 날짜", example = "2024-02-20T12:34:56") + LocalDateTime transferDatetime +) { +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/response/WalletBalanceResponse.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/response/WalletBalanceResponse.java new file mode 100644 index 00000000..476216d9 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/response/WalletBalanceResponse.java @@ -0,0 +1,19 @@ +package bumblebee.xchangepass.domain.wallet.wallet.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "화폐별 잔액 응답 객체") +public record WalletBalanceResponse( + @Schema(description = "화폐 종류", example = "KRW, USD") + String currency, + + @Schema(description = "잔액", example = "10000.00") + BigDecimal balance +) { + public WalletBalanceResponse(String currency, BigDecimal balance) { + this.currency = currency; + this.balance = balance; + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/entity/Wallet.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/Wallet.java similarity index 72% rename from src/main/java/bumblebee/xchangepass/domain/wallet/entity/Wallet.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/Wallet.java index c6827ddb..457e669a 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/entity/Wallet.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/Wallet.java @@ -1,20 +1,17 @@ -package bumblebee.xchangepass.domain.wallet.entity; +package bumblebee.xchangepass.domain.wallet.wallet.entity; import bumblebee.xchangepass.domain.card.entity.Card; import bumblebee.xchangepass.domain.user.entity.User; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; import java.util.List; -import static lombok.AccessLevel.PROTECTED; - @Entity @Table(name = "wallet") @Getter @@ -23,23 +20,23 @@ public class Wallet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "wallet_id", nullable = false) - public Long walletId; + private Long walletId; @Column(name = "wallet_password", nullable = false) - public String walletPassword; + private String walletPassword; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - public User user; + private User user; @OneToMany(mappedBy = "wallet", fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) - List cards; + private List cards; @OneToMany(mappedBy = "wallet", fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) - List walletBalances; + private List walletBalances; @CreatedDate - public LocalDateTime walletCreatedAt; + private LocalDateTime walletCreatedAt; public Wallet(User user, String walletPassword) { this.user = user; diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/NamedLockRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/NamedLockRepository.java new file mode 100644 index 00000000..62307cd7 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/NamedLockRepository.java @@ -0,0 +1,17 @@ +package bumblebee.xchangepass.domain.wallet.wallet.repository; + +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface NamedLockRepository extends JpaRepository { + + @Query(value = "SELECT pg_advisory_lock(:key)", nativeQuery = true) + void getLock(@Param("key") Long key); + + @Query(value = "SELECT pg_advisory_unlock(:key)", nativeQuery = true) + Boolean releaseLock(@Param("key") Long key); +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/repository/WalletRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/WalletRepository.java similarity index 63% rename from src/main/java/bumblebee/xchangepass/domain/wallet/repository/WalletRepository.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/WalletRepository.java index e4cc7162..d980b8d0 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/repository/WalletRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/WalletRepository.java @@ -1,28 +1,28 @@ -package bumblebee.xchangepass.domain.wallet.repository; +package bumblebee.xchangepass.domain.wallet.wallet.repository; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Repository -//@Transactional(readOnly = true) public interface WalletRepository extends JpaRepository { -// @Lock(LockModeType.PESSIMISTIC_WRITE) -// @Transactional(readOnly = false) @Query("SELECT w FROM Wallet w WHERE w.user.userId = :userId") - Wallet findByUserId(Long userId); + Optional findByUserId(@Param("userId") Long userId); @Lock(LockModeType.PESSIMISTIC_WRITE) @Transactional(readOnly = false) @Query("SELECT w FROM Wallet w WHERE w.user.userId = :userId") - Wallet findByUserIdWithLock(Long userId); + Optional findByUserIdWithLock(@Param("userId") Long userId); @Query("SELECT COUNT(w) > 0 FROM Wallet w WHERE w.user.userId = :userId") - boolean existsByUserId(Long userId); + boolean existsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletService.java new file mode 100644 index 00000000..d18d9be0 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletService.java @@ -0,0 +1,22 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service; + +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; + +import java.math.BigDecimal; +import java.util.List; + + +public interface WalletService { + + String getType(); + + void charge(Long userId, WalletInOutRequest request); + + BigDecimal withdrawal(Long userId, WalletInOutRequest request); + + void transfer(Long senderId, WalletTransferRequest request); + + List balance(Long userId); +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletServiceFactory.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletServiceFactory.java new file mode 100644 index 00000000..a9d13103 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletServiceFactory.java @@ -0,0 +1,24 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class WalletServiceFactory { + private final Map walletServices; + + @Autowired + public WalletServiceFactory(List walletServiceList) { + this.walletServices = walletServiceList.stream() + .collect(Collectors.toMap(WalletService::getType, Function.identity())); + } + + public WalletService getService(String type) { + return walletServices.get(type); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletServiceImpl.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletServiceImpl.java new file mode 100644 index 00000000..43031947 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletServiceImpl.java @@ -0,0 +1,32 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service.impl; + +import bumblebee.xchangepass.domain.card.service.CardService; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Currency; + +@Service +@RequiredArgsConstructor +public class WalletServiceImpl { + + private final WalletRepository walletRepository; + private final CardService cardService; + private final WalletBalanceService balanceService; + + @Transactional + public void createWallet(User user, String walletPassword) { + Wallet wallet = new Wallet(user, walletPassword); + + user.changeWallet(walletRepository.save(wallet)); + balanceService.createBalance(wallet, Currency.getInstance("KRW")); + + // ✅ 모바일 카드 발급 (동기 처리) + cardService.generateMobileCard(wallet); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/NamedLockWalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/NamedLockWalletService.java new file mode 100644 index 00000000..a1ae4f65 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/NamedLockWalletService.java @@ -0,0 +1,169 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock; + +import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.user.service.UserService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.NamedLockRepository; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.global.error.ErrorCode; +import jakarta.persistence.LockTimeoutException; +import jakarta.persistence.PessimisticLockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Slf4j +@Primary +@Service +@RequiredArgsConstructor +public class NamedLockWalletService implements WalletService { + private final WalletRepository walletRepository; + private final WalletBalanceService balanceService; + private final NamedLockRepository namedLockRepository; + private final ExchangeService exchangeService; + private final UserService userService; + + @Override + public String getType() { + return "namedLock"; + } + + @Override + @Transactional + public void charge(Long userId, WalletInOutRequest request) { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + BigDecimal chargeAmount = request.amount(); + if (!request.toCurrency().equals(request.fromCurrency())) { + chargeAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.amount()); + } + + namedLockRepository.getLock(wallet.getWalletId()); + try { + if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { + Wallet findWallet = walletRepository.findById(wallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + balanceService.createBalance(findWallet, request.toCurrency()); + } + + WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); + balanceService.chargeBalance(balance, chargeAmount); + } finally { + // 트랜잭션 종료 시점에서 락을 해제하도록 변경 + Boolean unlockSuccess = namedLockRepository.releaseLock(wallet.getWalletId()); + if (!unlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] 사용자 ID: {}", userId); + } + } + } + + @Override + @Transactional + public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + BigDecimal amount = request.amount(); + if (!request.toCurrency().equals(request.fromCurrency())) { + amount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), amount); + } + + namedLockRepository.getLock(wallet.getWalletId()); + try { + WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); + balanceService.withdrawBalance(balance, amount); + + return balance.getBalance(); + } finally { + Boolean unlockSuccess = namedLockRepository.releaseLock(wallet.getWalletId()); + if (!unlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] 사용자 ID: {}", userId); + } + } + + + } + + @Override + @Transactional + public void transfer(Long senderId, WalletTransferRequest request) { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + + Wallet fromWallet = walletRepository.findByUserId(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet toWallet = walletRepository.findById(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 + long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); + long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); + + namedLockRepository.getLock(smallerId); + namedLockRepository.getLock(largerId); + + try { + WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); + + BigDecimal transferAmount = request.transferAmount(); + + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } finally { + Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); + if (!largeUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", largerId); + } + + Boolean smallUnlockSuccess = namedLockRepository.releaseLock(smallerId); + if (!smallUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); + } + } + } + + @Override + @Transactional + public List balance(Long userId) { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + namedLockRepository.getLock(wallet.getWalletId()); + try { + List balanceList = balanceService.findBalances(wallet.getWalletId()); + + return balanceList.stream() + .map(balance -> new WalletBalanceResponse( + balance.getCurrency().getCurrencyCode(), + balance.getBalance() + )) + .toList(); + } finally { + Boolean unlockSuccess = namedLockRepository.releaseLock(wallet.getWalletId()); + if (!unlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] 사용자 ID: {}", userId); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/PessimisticLockWalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/PessimisticLockWalletService.java new file mode 100644 index 00000000..297194e9 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/PessimisticLockWalletService.java @@ -0,0 +1,159 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock; + +import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.user.service.UserService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.global.error.ErrorCode; +import jakarta.persistence.LockTimeoutException; +import jakarta.persistence.PessimisticLockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PessimisticLockWalletService implements WalletService { + + private final WalletRepository walletRepository; + private final WalletBalanceService balanceService; + private final ExchangeService exchangeService; + private final UserService userService; + + @Override + public String getType() { + return "pessimisticLock"; + } + + @Override + @Transactional + public void charge(Long userId, WalletInOutRequest request) { + BigDecimal chargeAmount = request.amount(); + if (!request.toCurrency().equals(request.fromCurrency())) { + chargeAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.amount()); + } + + try { + Wallet wallet = walletRepository.findByUserIdWithLock(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { + Wallet findWallet = walletRepository.findById(wallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + balanceService.createBalance(findWallet, request.toCurrency()); + } + + WalletBalance balance = balanceService.findBalanceWithLock(wallet.getWalletId(), request.toCurrency()); + balanceService.chargeBalance(balance, chargeAmount); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", userId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } + + } + + @Override + @Transactional + public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { + BigDecimal amount = request.amount(); + if (!request.toCurrency().equals(request.fromCurrency())) { + amount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), amount); + } + + try { + Wallet wallet = walletRepository.findByUserIdWithLock(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + WalletBalance balance = balanceService.findBalanceWithLock(wallet.getWalletId(), request.toCurrency()); + if (balance == null) { + throw ErrorCode.BALANCE_NOT_FOUND.commonException(); + } + + if (amount.compareTo(balance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + balanceService.withdrawBalance(balance, amount); + return balance.getBalance(); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", userId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void transfer(Long senderId, WalletTransferRequest request) { + try { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + WalletBalance fromBalance = balanceService.findBalanceWithLock(senderWallet.getWalletId(), request.fromCurrency()); + if (fromBalance == null) { + throw ErrorCode.BALANCE_NOT_FOUND.commonException(); + } + + if (!balanceService.checkBalance(receiverWallet.getWalletId(), request.toCurrency())) { + Wallet wallet = walletRepository.findById(receiverWallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + balanceService.createBalance(wallet, request.toCurrency()); + } + + WalletBalance toBalance = balanceService.findBalanceWithLock(receiverWallet.getWalletId(), request.toCurrency()); + + BigDecimal transferAmount = request.transferAmount(); + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", senderId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } + } + + @Override + @Transactional + public List balance(Long userId) { + try { + Wallet wallet = walletRepository.findByUserIdWithLock(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + List balanceList = balanceService.findBalancesWithLock(wallet.getWalletId()); + + return balanceList.stream() + .map(balance -> new WalletBalanceResponse( + balance.getCurrency().getCurrencyCode(), + balance.getBalance() + )) + .toList(); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", userId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLock.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLock.java similarity index 62% rename from src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLock.java rename to src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLock.java index 386f5513..b2213d1e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/service/redisson/RedissonLock.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLock.java @@ -1,6 +1,8 @@ -package bumblebee.xchangepass.domain.wallet.service.redisson; +package bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.redisson; +import bumblebee.xchangepass.global.error.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; @@ -8,6 +10,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; +@Slf4j @Service @RequiredArgsConstructor public class RedissonLock { @@ -23,37 +26,40 @@ public class RedissonLock { */ public T tryLock(String lockName, long waitTime, long leaseTime, Supplier task) { RLock lock = redissonClient.getLock(lockName); + boolean acquired = false; + try { - if (lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) { - try { - return task.get(); // 반환값 지원 - } finally { - unlock(lock); - } - } else { - throw new RuntimeException("🚨 락 획득 실패: " + lockName); + acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!acquired) { + throw ErrorCode.LOCK_TIME_OUT.commonException(); } + return task.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("🚨 락 시도 중 인터럽트 발생: " + lockName, e); + throw ErrorCode.THREAD_INTERRUPTED.commonException(); + } finally { + if (acquired) { + unlock(lock); + } } } public void tryLockVoid(String lockName, long waitTime, long leaseTime, Runnable task) { RLock lock = redissonClient.getLock(lockName); + boolean acquired = false; try { - if (lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) { - try { - task.run(); // 실행할 로직 수행 - } finally { - unlock(lock); - } - } else { - throw new RuntimeException("🚨 락 획득 실패: " + lockName); + acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!acquired) { + throw ErrorCode.LOCK_TIME_OUT.commonException(); } + task.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RuntimeException("🚨 락 시도 중 인터럽트 발생: " + lockName, e); + throw ErrorCode.THREAD_INTERRUPTED.commonException(); + } finally { + if (acquired) { + unlock(lock); + } } } @@ -79,8 +85,10 @@ public void lock(String lockName, long leaseTime, Runnable task) { * @param lock 락 객체 */ private void unlock(RLock lock) { - if (lock.isHeldByCurrentThread()) { + try { lock.unlock(); + } catch (IllegalMonitorStateException e) { + log.error("⚠️ [Lock 해제 실패] Lock Name: {}", lock.getName(), e); } } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLockWalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLockWalletService.java new file mode 100644 index 00000000..d68af43b --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/lock/redisson/RedissonLockWalletService.java @@ -0,0 +1,169 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.redisson; + +import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.user.service.UserService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.RedissonMultiLock; +import org.redisson.api.RLock; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RedissonLockWalletService implements WalletService { + + private final WalletRepository walletRepository; + private final WalletBalanceService balanceService; + private final RedissonLock redissonLock; + private final ExchangeService exchangeService; + private final UserService userService; + + @Override + public String getType() { + return "redissonLock"; + } + + /** + * 🔒 지갑 충전 (RedissonLock 적용) + */ + @Override + @Transactional + public void charge(Long userId, WalletInOutRequest request) { + BigDecimal chargeAmount; + if (!request.toCurrency().equals(request.fromCurrency())) { + chargeAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.amount()); + } else { + chargeAmount = request.amount(); + } + + String lockKey = "wallet:" + userId; + redissonLock.tryLockVoid(lockKey, 10, 10, () -> { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) { + Wallet findWallet = walletRepository.findById(wallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + balanceService.createBalance(findWallet, request.toCurrency()); + } + + WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); + balanceService.chargeBalance(balance, chargeAmount); + }); + } + + /** + * 🔒 지갑 출금 (RedissonLock 적용) + */ + @Override + @Transactional + public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { + BigDecimal amount; + if (!request.toCurrency().equals(request.fromCurrency())) { + amount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.amount()); + } else { + amount = request.amount(); + } + + String lockKey = "wallet:" + userId; + return redissonLock.tryLock(lockKey, 10, 10, () -> { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency()); + + if (request.amount().compareTo(balance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + balanceService.withdrawBalance(balance, amount); + return balance.getBalance(); + }); + } + + /** + * 🔒 지갑 송금 (멀티 락 적용) + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void transfer(Long senderId, WalletTransferRequest request) { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + String senderLockKey = "senderWallet:" + senderWallet.getWalletId(); + String receiverLockKey = "senderWallet:" + receiverWallet.getWalletId(); + + RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); + RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); + RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); + + boolean acquired = false; + try { + acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); + if (!acquired) { + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } + + WalletBalance fromBalance = balanceService.findBalance(senderWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(receiverWallet.getWalletId(), request.toCurrency()); + + BigDecimal transferAmount = request.transferAmount(); + if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ErrorCode.THREAD_INTERRUPTED.commonException(); + } finally { + if (acquired) { + try { + multiLock.unlock(); // ✅ unlock 예외 처리 추가 + } catch (IllegalMonitorStateException e) { + log.error("⚠️ [MultiLock 해제 실패] senderId: {}, receiverId: {}", senderId, receiverWallet.getWalletId(), e); + } + } + } + } + + @Override + @Transactional + public List balance(Long userId) { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + List balanceList = balanceService.findBalances(wallet.getWalletId()); + + return balanceList.stream() + .map(balance -> new WalletBalanceResponse( + balance.getCurrency().getCurrencyCode(), + balance.getBalance() + )) + .toList(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/global/common/Constants.java b/src/main/java/bumblebee/xchangepass/global/common/Constants.java index 3335ff50..14b98feb 100644 --- a/src/main/java/bumblebee/xchangepass/global/common/Constants.java +++ b/src/main/java/bumblebee/xchangepass/global/common/Constants.java @@ -7,7 +7,18 @@ public class Constants { //Redis Port 번호 public static final int REDIS_PORT = 6379; + //RabbitMQ 큐 이름 + public static final String WALLET_TRANSACTION = "wallet-transaction-queue"; + public static final String DLQ_NAME = "wallet-transaction-dlx-queue"; + public static final String DLX_NAME = "wallet-transaction-dlx"; + public static final String DLQ_ROUTING_KEY = "wallet-transaction-dlx-key"; + public static final String RETRY_QUEUE = "wallet-transaction-retry-queue"; + // 암호화 알고리즘 상수 public static final String AES_CBC_PADDING = "AES/CBC/PKCS5Padding"; public static final String RSAES_OAEP_SHA_256 = "RSAES_OAEP_SHA_256"; + + // Token 유효 기간 + public static final Long REFRESH_TOKEN_TTL = 24 * 60 * 60L; // 24시간 (초 단위) + public static final Long JWT_TOKEN_VALID = 1000 * 60 * 30L; // jwt AccessToken 만료 시간 1시간 } \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/global/config/RabbitMQConfig.java b/src/main/java/bumblebee/xchangepass/global/config/RabbitMQConfig.java new file mode 100644 index 00000000..7980cdac --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/config/RabbitMQConfig.java @@ -0,0 +1,71 @@ +package bumblebee.xchangepass.global.config; + +import org.aspectj.apache.bcel.generic.RET; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +import static bumblebee.xchangepass.global.common.Constants.*; + + +@EnableRabbit +@Configuration +public class RabbitMQConfig { + + @Bean + public Queue transactionQueue() { + Map args = new HashMap<>(); + args.put("x-dead-letter-exchange", ""); + args.put("x-dead-letter-routing-key", RETRY_QUEUE); + return new Queue(WALLET_TRANSACTION, true, false, false, args); + } + + @Bean + public Queue retryQueue() { + Map args = new HashMap<>(); + args.put("x-dead-letter-exchange", ""); + args.put("x-dead-letter-routing-key", WALLET_TRANSACTION); + args.put("x-message-ttl", 5000); + args.put("x-max-length", 1000); + + return new Queue(RETRY_QUEUE, true, false, false, args); + } + + @Bean + public Queue deadLetterQueue() { + return QueueBuilder.durable(DLQ_NAME).build(); + } + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(DLX_NAME); + } + + @Bean + public Binding deadLetterBinding() { + return BindingBuilder.bind(deadLetterQueue()) + .to(deadLetterExchange()) + .with(DLQ_ROUTING_KEY); + } + + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, + MessageConverter jsonMessageConverter) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(jsonMessageConverter); + return rabbitTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/global/config/RedisConfig.java b/src/main/java/bumblebee/xchangepass/global/config/RedisConfig.java index c7f08935..f0f619ac 100644 --- a/src/main/java/bumblebee/xchangepass/global/config/RedisConfig.java +++ b/src/main/java/bumblebee/xchangepass/global/config/RedisConfig.java @@ -1,7 +1,6 @@ package bumblebee.xchangepass.global.config; import bumblebee.xchangepass.domain.card.dto.response.DetailedCardInfoResponse; -import bumblebee.xchangepass.domain.exchangeRate.dto.response.ExchangeRateResponse; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java index df9870e2..a87c9da7 100644 --- a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java +++ b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java @@ -26,11 +26,15 @@ public enum ErrorCode { BALANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "B001", "해당 화폐 잔액이 존재하지 않습니다."), BALANCE_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "B002", "충전 금액이 부족합니다."), + /*Lock*/ + LOCK_TIME_OUT(HttpStatus.CONFLICT, "L001", "현재 지갑이 사용 중이거나 다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요."), + THREAD_INTERRUPTED(HttpStatus.INTERNAL_SERVER_ERROR, "L002", "작업이 예상치 못하게 중단되었습니다. 다시 시도해주세요."), + /*Exchange_rate*/ EXCHANGE_RATE_NOT_FOUND(HttpStatus.NOT_FOUND, "E001", "존재 하지 않는 환율입니다."), EXCHANGE_SAVE_FAIL(HttpStatus.BAD_REQUEST, "E002", "환율 정보 저장 실패"), EXCHANGE_RATE_FOR_COUNTRY(HttpStatus.BAD_REQUEST, "E003", "이 나라에 대한 환율 정보가 없습니다."), - EXCHANGE_RATE_EXCEED(HttpStatus.TOO_MANY_REQUESTS, "E004", "환율 요청 초과"), + EXCHANGE_RATE_EXCEED(HttpStatus.TOO_MANY_REQUESTS, "E004", "환율 요청 초과"), EXCHANGE_DATA_ACCESS_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "E005", "데이터베이스 접근 중 오류 발생"), EXCHANGE_SQL_EXECUTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E006", "SQL 실행 중 오류 발생"), EXCHANGE_TABLE_DROP_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "E007", "테이블 DML 중 오류 발생"), diff --git a/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java b/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java index d121ab7a..226aefb7 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java +++ b/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java @@ -29,14 +29,14 @@ @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; // ✅ 기존 `SecurityConfig`에서 유지 - private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler; // ✅ 기존 `SecurityConfig`에서 유지 - private final CustomAccessDeniedHandler customAccessDeniedHandler; // ✅ 기존 `SecurityConfig`에서 유지 + private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Value("${cors.url}") - private String corsUrl; // ✅ 기존 `SpringSecurityConfig`에서 유지 + private String corsUrl; @Value("${cors.front.url}") - private String frontUrl; // ✅ 기존 `SpringSecurityConfig`에서 유지 + private String frontUrl; /** * 🔒 비밀번호 암호화 설정 (BCrypt 사용) @@ -48,7 +48,6 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { /** * 🎭 역할(Role) 계층 구조 설정 - * ✅ 기존 `SpringSecurityConfig`에서 유지 */ @Bean public RoleHierarchy roleHierarchy() { @@ -57,23 +56,17 @@ public RoleHierarchy roleHierarchy() { """); } - /** - * 🔥 Spring Security 설정 통합 - * ✅ 기존 `SpringSecurityConfig`의 CORS 및 기본 설정 유지 - * ✅ 기존 `SecurityConfig`의 JWT 필터 및 예외 처리 유지 - */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - System.out.println("security start"); http - .csrf(AbstractHttpConfigurer::disable) // ✅ 기존 `SpringSecurityConfig` & `SecurityConfig`에서 유지 (CSRF 비활성화) - .cors(corsCustomizer -> corsCustomizer.configurationSource(corsConfigurationSource())) // ✅ 기존 `SpringSecurityConfig`에서 유지 (CORS 설정) - .formLogin(AbstractHttpConfigurer::disable) // ✅ 기존 `SpringSecurityConfig` & `SecurityConfig`에서 유지 (Form 로그인 비활성화) - .logout(AbstractHttpConfigurer::disable) // ✅ 기존 `SpringSecurityConfig` & `SecurityConfig`에서 유지 (로그아웃 비활성화) - .httpBasic(AbstractHttpConfigurer::disable) // ✅ 기존 `SpringSecurityConfig`에서 유지 (HTTP Basic 인증 비활성화) + .csrf(AbstractHttpConfigurer::disable) + .cors(corsCustomizer -> corsCustomizer.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ✅ 기존 `SpringSecurityConfig` & `SecurityConfig`에서 유지 (세션 미사용, JWT 사용) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 🔹 경로별 인가 작업 .authorizeHttpRequests(auth -> auth @@ -90,7 +83,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 그 외 모든 요청은 인증 필요 .anyRequest().authenticated()) - // 🔥 JWT 필터 추가 (UsernamePasswordAuthenticationFilter 앞에 배치) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) // ⚠️ 예외 처리 핸들러 추가 @@ -103,7 +95,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti /** * 🌍 CORS 설정 - * ✅ 기존 `SpringSecurityConfig`에서 유지하며 `Bean`으로 관리 */ @Bean public CorsConfigurationSource corsConfigurationSource() { diff --git a/src/main/java/bumblebee/xchangepass/global/security/jwt/CustomUserDetails.java b/src/main/java/bumblebee/xchangepass/global/security/jwt/CustomUserDetails.java new file mode 100644 index 00000000..bbf08565 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/CustomUserDetails.java @@ -0,0 +1,38 @@ +package bumblebee.xchangepass.global.security.jwt; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +public class CustomUserDetails implements UserDetails { + private final Long userId; + private final String email; + private final String password; + private final String role; + + public CustomUserDetails(Long userId, String email, String password, String role) { + this.userId = userId; + this.email = email; + this.password = password; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(() -> role); + } + + @Override + public String getUsername() { + return email; + } + + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } +} diff --git a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java index 5165edd9..fddcc8f8 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java @@ -1,5 +1,6 @@ package bumblebee.xchangepass.global.security.jwt; +import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse; import bumblebee.xchangepass.domain.user.service.UserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -30,7 +31,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse final String token = request.getHeader("Authorization"); String userId = null; - System.out.println("dofilter start"); // Bearer token 검증 후 user name 조회 if(token != null && !token.isEmpty()) { @@ -39,15 +39,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse userId = jwtProvider.getUserIdFromToken(jwtToken); } - System.out.println("userId = " + userId); // token 검증 완료 후 SecurityContextHolder 내 인증 정보가 없는 경우 저장 if(userId != null && !userId.isEmpty() && SecurityContextHolder.getContext().getAuthentication() == null) { - // Spring Security Context Holder 인증 정보 set SecurityContextHolder.getContext().setAuthentication(getUserAuth(userId)); } - System.out.println("dofilter end"); filterChain.doFilter(request,response); } @@ -58,11 +55,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse * @return 사용자 UsernamePasswordAuthenticationToken */ private UsernamePasswordAuthenticationToken getUserAuth(String userEmail) { - var userInfo = userService.readUserByUserId(userEmail); + UserLoginResponse userInfo = userService.readUserByUserId(userEmail); - return new UsernamePasswordAuthenticationToken(userInfo.userId(), + CustomUserDetails userDetails = new CustomUserDetails( + userInfo.userId(), + userInfo.userEmail(), userInfo.password(), - Collections.singleton(new SimpleGrantedAuthority(userInfo.role().toString())) + userInfo.role().toString() + ); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() ); } diff --git a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java index 0e84cf99..492def24 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java @@ -14,14 +14,13 @@ import java.util.Map; import java.util.function.Function; +import static bumblebee.xchangepass.global.common.Constants.JWT_TOKEN_VALID; + @Component @RequiredArgsConstructor @Slf4j public class JwtProvider { - // jwt 만료 시간 1시간 - private static final long JWT_TOKEN_VALID = (long) 1000 * 60 * 30; - @Value("${jwt.secret}") private String secret; diff --git a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtUtil.java b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtUtil.java deleted file mode 100644 index 24d5e244..00000000 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtUtil.java +++ /dev/null @@ -1,36 +0,0 @@ -package bumblebee.xchangepass.global.security.jwt; - -import lombok.experimental.UtilityClass; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.Authentication; - -@UtilityClass -public class JwtUtil { - - /** - * Spring Security Context에서 로그인한 사용자의 id 조회 - * - * @param authentication Authentication - * @return 로그인한 사용자의 id - * @throws AccessDeniedException AccessDeniedException - */ - public Long getLoginId(final Authentication authentication) throws AccessDeniedException { - // 정상적으로 로그인한 사용자 정보인지 체크 - checkAuth(authentication); - - return Long.parseLong(authentication.getPrincipal().toString()); - } - - /** - * 정상적으로 로그인한 사용자 정보인지 체크 - * - * @param authentication Authentication - * @throws AccessDeniedException AccessDeniedException - */ - private void checkAuth(final Authentication authentication) throws AccessDeniedException { - if(authentication == null || !authentication.isAuthenticated()) { - throw new AccessDeniedException("로그인 정보가 존재하지 않습니다."); - } - } - -} diff --git a/src/main/java/bumblebee/xchangepass/global/validation/CurrencyValidator.java b/src/main/java/bumblebee/xchangepass/global/validation/CurrencyValidator.java new file mode 100644 index 00000000..bf0a79be --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/validation/CurrencyValidator.java @@ -0,0 +1,23 @@ +package bumblebee.xchangepass.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Currency; + +public class CurrencyValidator implements ConstraintValidator { + + @Override + public boolean isValid(Currency currency, ConstraintValidatorContext constraintValidatorContext) { + if (currency == null) { + return false; + } + + try { + Currency.getInstance(currency.getCurrencyCode()); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/global/validation/ValidCurrency.java b/src/main/java/bumblebee/xchangepass/global/validation/ValidCurrency.java new file mode 100644 index 00000000..e9ff83b8 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/validation/ValidCurrency.java @@ -0,0 +1,16 @@ +package bumblebee.xchangepass.global.validation; + +import jakarta.validation.Constraint; +import org.springframework.messaging.handler.annotation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = CurrencyValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidCurrency { + String message() default "유효하지 않은 통화 코드입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java index bffce45c..ce903657 100644 --- a/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java @@ -12,10 +12,10 @@ import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; import bumblebee.xchangepass.domain.user.service.UserRegisterService; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.service.WalletService; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; import bumblebee.xchangepass.global.error.ErrorCode; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java index 7e0edb3e..747ba6a5 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java @@ -1,114 +1,82 @@ package bumblebee.xchangepass.domain.user.service; +import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; +import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService; import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; -import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse; -import bumblebee.xchangepass.domain.user.entity.Role; import bumblebee.xchangepass.domain.user.entity.Sex; -import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.user.login.LoginService; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; -import bumblebee.xchangepass.domain.refresh.RefreshToken; -import bumblebee.xchangepass.domain.refresh.RefreshTokenService; -import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers class UserLoginScenarioTest { - - @Mock - private UserRepository userRepository; - - @Mock - private NicknameGenerator nicknameGenerator; - - @Mock - private BCryptPasswordEncoder bCryptPasswordEncoder; - - @Mock - private JwtProvider jwtProvider; - - @Mock + @Autowired private UserService userService; - @InjectMocks + @Autowired private UserRegisterService userRegisterService; - @InjectMocks + @Autowired private LoginService loginService; - @InjectMocks + @Autowired private RefreshTokenService refreshTokenService; private UserRegisterRequest registerRequest; private LoginRequest loginRequest; private String refreshToken; - @BeforeEach - void setUp() { - registerRequest = new UserRegisterRequest("test@example.com", "Password123!", "test", "010-1234-5678", Sex.MALE, "1234"); - loginRequest = new LoginRequest("test@example.com", "Password123"); - refreshToken = "validRefreshToken"; - - // 회원가입 시 닉네임 생성 - when(nicknameGenerator.generateUniqueNickname()).thenReturn("testUser"); - - // 비밀번호 암호화 Mock - when(bCryptPasswordEncoder.encode(anyString())).thenReturn("hashedPassword"); - - // 로그인 시 사용자 조회 - UserLoginResponse userInfo = new UserLoginResponse(1L, "test@example.com", "hashedPassword", "testUser", "010-1234-5678", Role.ROLE_USER); - when(userService.readUserByUserEmail(loginRequest.userEmail())).thenReturn(userInfo); - when(bCryptPasswordEncoder.matches(loginRequest.password(), userInfo.password())).thenReturn(true); + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("testuser") + .withPassword("testpass"); + + @DynamicPropertySource + static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } - // JWT 발급 Mock - when(jwtProvider.generateAccessToken(1L)).thenReturn("accessToken"); - when(jwtProvider.generateRefreshToken(1L)).thenReturn("refreshToken"); - // Refresh Token 검증 Mock - when(jwtProvider.validateToken(refreshToken)).thenReturn(true); + @BeforeEach + void setUp() { + registerRequest = new UserRegisterRequest( + "test@example.com", "Password123!", "test", "010-1234-5678", Sex.MALE, "1234" + ); + loginRequest = new LoginRequest("test@example.com", "Password123!"); } @Test void 회원가입_로그인_Refresh토큰_재발급_테스트() { - // 1️⃣ 회원가입 - assertDoesNotThrow(() -> userRegisterService.signupUser(registerRequest)); - verify(nicknameGenerator, times(1)).generateUniqueNickname(); - verify(userRepository, times(1)).save(any()); + // 1. 회원가입 + userRegisterService.signupUser(registerRequest); + assertEquals("test@example.com", userService.readUser("test", "010-1234-5678").getUserEmail().getValue()); - // 2️⃣ 로그인 → AccessToken, RefreshToken 발급 확인 + // 2. 로그인 → 토큰 확인 LoginResponse loginResponse = loginService.login(loginRequest); + assertNotNull(loginResponse.accessToken()); + assertNotNull(loginResponse.refreshToken()); - assertNotNull(loginResponse); - assertEquals("accessToken", loginResponse.accessToken()); - assertEquals("refreshToken", loginResponse.refreshToken()); - - verify(userService, times(1)).readUserByUserEmail(loginRequest.userEmail()); - verify(jwtProvider, times(1)).generateAccessToken(1L); - verify(jwtProvider, times(1)).generateRefreshToken(1L); - - // 3️⃣ Refresh Token 저장 (테스트 환경에서 직접 추가) - RefreshToken.putRefreshToken(refreshToken, 1L); - - // 4️⃣ Refresh Token을 이용하여 새로운 AccessToken, RefreshToken 발급 - RefreshTokenResponse refreshResponse = refreshTokenService.refreshToken(refreshToken); - - assertNotNull(refreshResponse); - assertEquals("accessToken", refreshResponse.accessToken()); - assertEquals("refreshToken", refreshResponse.refreshToken()); - - verify(jwtProvider, times(1)).validateToken(refreshToken); - verify(jwtProvider, times(2)).generateAccessToken(1L); - verify(jwtProvider, times(2)).generateRefreshToken(1L); + // 3. 토큰 재발급 + RefreshTokenResponse refreshResponse = refreshTokenService.refreshToken(loginResponse.refreshToken()); + assertNotNull(refreshResponse.accessToken()); + assertNotNull(refreshResponse.refreshToken()); } } diff --git a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java index 254860ee..74bd8630 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java @@ -1,17 +1,22 @@ package bumblebee.xchangepass.domain.user.service; +import bumblebee.xchangepass.config.RedisTestBase; +import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository; import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; +import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse; import bumblebee.xchangepass.domain.user.entity.Role; import bumblebee.xchangepass.domain.user.entity.Sex; import bumblebee.xchangepass.domain.user.repository.UserRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletServiceImpl; import bumblebee.xchangepass.global.exception.CommonException; import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.user.login.LoginService; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; -import bumblebee.xchangepass.domain.refresh.RefreshToken; -import bumblebee.xchangepass.domain.refresh.RefreshTokenService; +import bumblebee.xchangepass.domain.refresh.entity.RefreshToken; +import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,12 +25,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.ActiveProfiles; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) -class UserLoginUnitTest { +class UserLoginUnitTest extends RedisTestBase { @Mock private UserRepository userRepository; @@ -36,12 +43,18 @@ class UserLoginUnitTest { @Mock private BCryptPasswordEncoder bCryptPasswordEncoder; + @Mock + private WalletServiceImpl walletService; + @Mock private JwtProvider jwtProvider; @Mock private UserService userService; + @Mock + private RefreshTokenRepository refreshTokenRepository; + @InjectMocks private UserRegisterService userRegisterService; @@ -64,7 +77,10 @@ void setUp() { @Test void signupUser_Success() { - when(nicknameGenerator.generateUniqueNickname()).thenReturn("test"); + when(nicknameGenerator.generateUniqueNickname()).thenReturn("test111"); + + User mockUser = registerRequest.toEntity(bCryptPasswordEncoder, "test111"); + when(userRepository.save(any(User.class))).thenReturn(mockUser); assertDoesNotThrow(() -> userRegisterService.signupUser(registerRequest)); @@ -117,6 +133,7 @@ void login_Fail_InvalidPassword() { @Test void refreshToken_Success() { when(jwtProvider.validateToken(refreshToken)).thenReturn(true); + when(refreshTokenRepository.getUserIdFromRefreshToken(refreshToken)).thenReturn(1L); when(jwtProvider.generateAccessToken(1L)).thenReturn("newAccessToken"); when(jwtProvider.generateRefreshToken(1L)).thenReturn("newRefreshToken"); diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java new file mode 100644 index 00000000..b483cb8c --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java @@ -0,0 +1,27 @@ +package bumblebee.xchangepass.domain.wallet.transaction; + +import bumblebee.xchangepass.domain.wallet.transaction.consumer.SlackNotifier; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@AutoConfigureMockMvc +class SlackNotifierIntegrationTest { + + @Autowired + private SlackNotifier slackNotifier; + + @Test + void sendSlackMessageTest() { + // given + String message = "✅ 테스트 메시지입니다 (통합 테스트)"; + + // when + slackNotifier.send(message); + + // then + // 별도 assertion은 없고 Slack에서 수동으로 확인 + } +} diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/WalletTransactionIntegrationTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/WalletTransactionIntegrationTest.java new file mode 100644 index 00000000..db37bd4c --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/WalletTransactionIntegrationTest.java @@ -0,0 +1,239 @@ +package bumblebee.xchangepass.domain.wallet.transaction; + +import bumblebee.xchangepass.config.TestUserInitializer; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.user.repository.UserRepository; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.transaction.consumer.DeadLetterConsumer; +import bumblebee.xchangepass.domain.wallet.transaction.consumer.SlackNotifier; +import bumblebee.xchangepass.domain.wallet.transaction.dto.WalletTransactionMessage; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransaction; +import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.global.error.ErrorCode; +import com.rabbitmq.client.Channel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Currency; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@Import(TestUserInitializer.class) +public class WalletTransactionIntegrationTest { + + @Autowired private UserRepository userRepository; + + @Autowired private WalletRepository walletRepository; + + @Autowired private WalletBalanceRepository balanceRepository; + + @Autowired private WalletBalanceService balanceService; + + @Autowired private WalletTransactionRepository transactionRepository; + + @MockBean + private SlackNotifier slackNotifier; + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("testuser") + .withPassword("testpass"); + + @Container + static GenericContainer rabbitMqContainer = new GenericContainer<>("rabbitmq:3-management") + .withExposedPorts(5672, 15672) + .withEnv("RABBITMQ_DEFAULT_USER", "guest") + .withEnv("RABBITMQ_DEFAULT_PASS", "guest"); + + @DynamicPropertySource + static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.rabbitmq.host", rabbitMqContainer::getHost); + registry.add("spring.rabbitmq.port", () -> rabbitMqContainer.getMappedPort(5672)); + } + + private Wallet testWallet1; + private Wallet testWallet2; + Currency krw = Currency.getInstance("KRW"); + + @BeforeEach + void initAll() throws InterruptedException { + setup(); + clearTransactions(); + Thread.sleep(3000); + } + + + void setup() { + User user1 = userRepository.findByUserEmail("Test1@gmail.com").orElseThrow(); + User user2 = userRepository.findByUserEmail("Test2@gmail.com").orElseThrow(); + + testWallet1 = walletRepository.findByUserId(user1.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + testWallet2 = walletRepository.findByUserId(user2.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + if (!balanceService.checkBalance(testWallet1.getWalletId(), krw)) + balanceService.createBalance(testWallet1, krw); + if (!balanceService.checkBalance(testWallet2.getWalletId(), krw)) + balanceService.createBalance(testWallet2, krw); + } + + void clearTransactions() { + transactionRepository.deleteAll(); // 트랜잭션만 초기화 + balanceRepository.zeroBalance(testWallet1.getWalletId(), krw); + balanceRepository.zeroBalance(testWallet2.getWalletId(), krw); + } + + @Test + @DisplayName("💰 1. 충전(DEPOSIT) 트랜잭션 비동기 저장 확인") + void depositTransactionTest() throws InterruptedException { + var balance = balanceService.findBalanceWithLock(testWallet1.getWalletId(), Currency.getInstance("KRW")); + System.out.println("balance.getBalance() = " + balance.getBalance()); + balanceService.chargeBalance(balance, new BigDecimal("5000")); + + Thread.sleep(3000); + + var transactions = transactionRepository.getWalletTransaction(testWallet1.getWalletId()); + assertEquals(1, transactions.size()); + assertEquals(WalletTransactionType.DEPOSIT, transactions.get(0).getTransactionType()); + } + + @Test + @DisplayName("💸 2-1. 인출(WITHDRAW) - 성공") + void withdrawSuccessTest() throws InterruptedException { + var balance = balanceService.findBalanceWithLock(testWallet1.getWalletId(), Currency.getInstance("KRW")); + System.out.println("balance.getBalance() = " + balance.getBalance()); + balanceService.chargeBalance(balance, new BigDecimal("5000")); + System.out.println("balance.getBalance() = " + balance.getBalance()); + + balanceService.withdrawBalance(balance, new BigDecimal("2000")); + + Thread.sleep(3000); + + var transactions = transactionRepository.getWalletTransaction(testWallet1.getWalletId()); + assertEquals(2, transactions.size()); // DEPOSIT + WITHDRAW + } + + @Test + @DisplayName("🚫 2-2. 인출(WITHDRAW) - 잔액 부족으로 실패") + void withdrawFailTest() throws InterruptedException { + var balance = balanceService.findBalanceWithLock(testWallet1.getWalletId(), Currency.getInstance("KRW")); + System.out.println("balance.getBalance() = " + balance.getBalance()); + try { + balanceService.withdrawBalance(balance, new BigDecimal("9999999")); + } catch (Exception ignored) {} + + Thread.sleep(3000); + + List txs = transactionRepository.getWalletTransaction(testWallet1.getWalletId()); + assertTrue(txs.isEmpty()); + } + + @Test + @DisplayName("💱 3-1. 송금(TRANSFER) - 성공") + void transferSuccessTest() throws InterruptedException { + var fromBalance = balanceService.findBalanceWithLock(testWallet1.getWalletId(), Currency.getInstance("KRW")); + var toBalance = balanceService.findBalanceWithLock(testWallet2.getWalletId(), Currency.getInstance("KRW")); + System.out.println("fromBalance.getBalance() = " + fromBalance.getBalance()); + + balanceService.chargeBalance(fromBalance, new BigDecimal("5000")); + balanceService.transferBalance(fromBalance, toBalance, new BigDecimal("1000")); + System.out.println("fromBalance.getBalance() = " + fromBalance.getBalance()); + System.out.println("toBalance.getBalance() = " + toBalance.getBalance()); + + + Thread.sleep(3000); + + List senderTransaction = transactionRepository.getWalletTransaction(testWallet1.getWalletId()); + List receiverTransaction = transactionRepository.getWalletTransaction(testWallet2.getWalletId()); + + System.out.println("senderTransaction = " + senderTransaction.get(1).getWalletTransactionId()); + System.out.println("receiverTransaction = " + receiverTransaction.get(0).getWalletTransactionId()); + + assertEquals(1, senderTransaction.stream() + .filter(tx -> tx.getTransactionType() == WalletTransactionType.TRANSFER && + tx.getMyWallet().getWalletId().equals(testWallet1.getWalletId()) && + tx.getCounterWallet() != null && + tx.getCounterWallet().getWalletId().equals(testWallet2.getWalletId())) + .count(), "보내는 쪽 트랜잭션이 정확하지 않습니다"); + + assertEquals(1, receiverTransaction.stream() + .filter(tx -> tx.getTransactionType() == WalletTransactionType.TRANSFER && + tx.getMyWallet().getWalletId().equals(testWallet1.getWalletId()) && + tx.getCounterWallet() != null && + tx.getCounterWallet().getWalletId().equals(testWallet2.getWalletId())) + .count(), "받는 쪽 트랜잭션이 정확하지 않습니다"); + } + + @Test + @DisplayName("📤 4. DLQ/Retry 시나리오 (수동 트리거 필요)") + void retryAndDLQTest() throws InterruptedException, IOException { + // 실패 메시지를 직접 전송하는 코드 필요 + // 실패 케이스 전송 → retry → DLQ 확인 → 슬랙 전송까지 수동 확인 + // 혹은 mock/slack-notifier 사용 + + WalletTransactionMessage message = new WalletTransactionMessage(testWallet1.getWalletId(),null, new BigDecimal("10000000"),krw.getCurrencyCode(),krw.getCurrencyCode(),WalletTransactionType.WITHDRAWAL.toString()); // 실패 메시지 + + Map xDeath = Map.of("count", 3L); + List> xDeathHeader = List.of(xDeath); + var channel = Mockito.mock(Channel.class); + + DeadLetterConsumer consumer = new DeadLetterConsumer(new RabbitTemplate(), slackNotifier); + consumer.handleDeadLetter(message, 1L, xDeathHeader, channel); + + verify(slackNotifier, times(1)).send(contains("DLQ 처리 실패")); + } + + @Test + @DisplayName("🔍 5. 거래내역 필터링 조회") + void filterTransactionTest() throws InterruptedException { + var balance = balanceService.findBalanceWithLock(testWallet1.getWalletId(), Currency.getInstance("KRW")); + System.out.println("balance.getBalance() = " + balance.getBalance()); + + balanceService.chargeBalance(balance, new BigDecimal("1000")); + Thread.sleep(2000); + + var transactions = transactionRepository.findAll(); // 필요시 커스텀 조회로 교체 + var deposits = transactions.stream().filter(t -> t.getTransactionType() == WalletTransactionType.DEPOSIT).toList(); + + assertEquals(1, deposits.size()); + } + +} diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletIntegrationServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java similarity index 67% rename from src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletIntegrationServiceTest.java rename to src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java index b07664c6..3f859040 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletIntegrationServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java @@ -1,31 +1,43 @@ -package bumblebee.xchangepass.domain.wallet.service; +package bumblebee.xchangepass.domain.wallet.wallet.service; import bumblebee.xchangepass.domain.user.entity.Sex; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.repository.WalletBalanceRepository; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletServiceImpl; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.PessimisticLockWalletService; +import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.containers.PostgreSQLContainer; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Currency; import java.util.UUID; -import java.util.concurrent.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -33,12 +45,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest +@Testcontainers @ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class WalletIntegrationServiceTest { @Autowired - private WalletService walletService; + private WalletServiceImpl walletService; + @Autowired + private PessimisticLockWalletService pessimisticLockWalletService; @Autowired private UserRepository userRepository; @Autowired @@ -47,6 +61,8 @@ class WalletIntegrationServiceTest { private WalletBalanceRepository walletBalanceRepository; @Autowired private WalletBalanceService balanceService; + @Autowired + private WalletTransactionRepository walletTransactionRepository; private final BigDecimal CHARGE_AMOUNT = new BigDecimal("10000.00"); private final BigDecimal TRANSFER_AMOUNT = new BigDecimal("5000.00"); @@ -57,11 +73,37 @@ class WalletIntegrationServiceTest { private Wallet senderWallet; private Wallet receiverWallet; - /** - * 💡 모든 테스트 실행 전에 사용자 & 지갑을 미리 생성 - */ + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("testuser") + .withPassword("testpass"); + + @Container + static GenericContainer rabbitMqContainer = new GenericContainer<>("rabbitmq:3-management") + .withExposedPorts(5672, 15672) + .withEnv("RABBITMQ_DEFAULT_USER", "guest") + .withEnv("RABBITMQ_DEFAULT_PASS", "guest"); + + @DynamicPropertySource + static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.rabbitmq.host", rabbitMqContainer::getHost); + registry.add("spring.rabbitmq.port", () -> rabbitMqContainer.getMappedPort(5672)); + } + + @BeforeEach - void setup() { + void setup() throws InterruptedException { + Thread.sleep(300); + walletTransactionRepository.deleteAll(); walletBalanceRepository.deleteAll(); walletRepository.deleteAll(); userRepository.deleteAll(); // 기존 데이터 삭제 @@ -90,7 +132,8 @@ private User createUser(String email, String password, String username, String n private Wallet createWalletForUser(User user, String walletPassword) { walletService.createWallet(user, walletPassword); - return walletRepository.findByUserId(user.getUserId()); + return walletRepository.findByUserId(user.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); } private String generateRandomId() { @@ -98,11 +141,12 @@ private String generateRandomId() { } @Test + @DisplayName("잔액이 충분할 때 송금이 성공한다") void testTransferSuccess() { - walletService.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); + pessimisticLockWalletService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); - walletService.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); WalletBalance receiverBalance = balanceService.findBalance(receiverWallet.getWalletId(), CURRENCY); @@ -112,16 +156,17 @@ void testTransferSuccess() { } @Test + @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), CHARGE_AMOUNT.add(BigDecimal.ONE), CURRENCY, CURRENCY, null); - Exception exception = assertThrows(RuntimeException.class, () -> walletService.transfer(transferRequest)); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + Exception exception = assertThrows(RuntimeException.class, () -> pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @Test - @Transactional + @DisplayName("계좌에 충전이 성공한다") void testChargeWallet() { - walletService.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); + pessimisticLockWalletService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); assertThat(balance.getBalance()).isEqualByComparingTo(CHARGE_AMOUNT); @@ -132,7 +177,8 @@ void testChargeWallet() { * // */ @Test - void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException { + @DisplayName("여러 사용자가 동시에 같은 계좌로 송금하면 모든 송금이 처리된다") + void concurrentTransfersToSameWallet() throws InterruptedException { // Given: 초기 충전 WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); balanceService.chargeBalance(balance, CHARGE_AMOUNT.multiply(BigDecimal.valueOf(100))); @@ -146,15 +192,13 @@ void testChargeWallet() { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest( - senderId, receiverId, TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { try { startLatch.await(); // 🔥 모든 스레드가 동시에 실행되도록 대기 - walletService.transfer(transferRequest); + pessimisticLockWalletService.transfer(senderId, transferRequest); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); } finally { @@ -194,12 +238,13 @@ void testChargeWallet() { * ✅ 송금 도중 출금이 발생하면 한쪽이 실패하는지 확인하는 동시성 테스트 (5번 반복) */ @RepeatedTest(5) // 5번 반복 실행 - void 송금_도중_발생한_출금은_실패한다() throws Exception { + @DisplayName("송금 도중 출금이 발생하면 둘 중 하나는 실패한다") + void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception { WalletInOutRequest chargeRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - walletService.charge(chargeRequest); + pessimisticLockWalletService.charge(sender.getUserId(), chargeRequest); AtomicReference withdrawAmount = new AtomicReference<>(BigDecimal.ZERO); AtomicBoolean isWithdrawFirst = new AtomicBoolean(false); @@ -210,10 +255,9 @@ void testChargeWallet() { Future transferFuture = executorService.submit(() -> { try { System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); - walletService.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + + pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); @@ -227,9 +271,9 @@ void testChargeWallet() { latch.await(); // 🔥 송금이 끝날 때까지 출금 대기 System.out.println("💸 [출금 시작]"); WalletInOutRequest withdrawRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - withdrawAmount.set(walletService.withdrawal(withdrawRequest)); + withdrawAmount.set(pessimisticLockWalletService.withdrawal(sender.getUserId(), withdrawRequest)); isWithdrawFirst.set(true); } catch (Exception e) { System.err.println("[출금 중 예외 발생]: " + e.getMessage()); @@ -269,17 +313,16 @@ void testChargeWallet() { * ✅ 충전과 이체가 동시에 발생할 때 이체가 실패하는지 확인하는 동시성 테스트 */ @RepeatedTest(5) // 5번 반복 실행 - void 계좌_충전과_이체가_동시에_발생하면_이체는_실패한다() throws Exception { + @DisplayName("충전과 이체가 동시에 발생하면 이체는 실패하고 충전은 성공한다") + void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { // Given: 초기 잔액 설정 System.out.println("초기 Sender 잔액 = " + balanceService.findBalance(senderWallet.getWalletId(), CURRENCY).getBalance()); WalletInOutRequest chargeRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행) @@ -287,7 +330,7 @@ void testChargeWallet() { try { latch.await(); // 🔥 이체가 끝날 때까지 충전 대기 System.out.println("💰 충전 시작"); - walletService.charge(chargeRequest); + pessimisticLockWalletService.charge(sender.getUserId(), chargeRequest); } catch (InterruptedException e) { throw new RuntimeException(e); } @@ -297,7 +340,7 @@ void testChargeWallet() { try { System.out.println("🚀 이체 시작"); Exception exception = assertThrows(CommonException.class, () -> { - walletService.transfer(transferRequest); + pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest); }); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } finally { diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletNamedLockServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java similarity index 66% rename from src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletNamedLockServiceTest.java rename to src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java index c0d9b541..497bd4fd 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletNamedLockServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java @@ -1,17 +1,23 @@ -package bumblebee.xchangepass.domain.wallet.service; +package bumblebee.xchangepass.domain.wallet.wallet.service; import bumblebee.xchangepass.domain.user.entity.Sex; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.repository.WalletBalanceRepository; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletServiceImpl; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.NamedLockWalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.PessimisticLockWalletService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,7 +25,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -35,15 +47,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest +@Testcontainers @ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class WalletNamedLockServiceTest { @Autowired - private WalletService walletService; - + private WalletServiceImpl walletService; @Autowired - private NamedLockWalletFacade lockWalletFacade; + private NamedLockWalletService lockWalletFacade; @Autowired private UserRepository userRepository; @Autowired @@ -52,6 +63,8 @@ class WalletNamedLockServiceTest { private WalletBalanceRepository walletBalanceRepository; @Autowired private WalletBalanceService balanceService; + @Autowired + private WalletTransactionRepository walletTransactionRepository; private final BigDecimal CHARGE_AMOUNT = new BigDecimal("10000.00"); private final BigDecimal TRANSFER_AMOUNT = new BigDecimal("5000.00"); @@ -62,14 +75,41 @@ class WalletNamedLockServiceTest { private Wallet senderWallet; private Wallet receiverWallet; - /** - * 💡 모든 테스트 실행 전에 사용자 & 지갑을 미리 생성 - */ + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("testuser") + .withPassword("testpass"); + + @Container + static GenericContainer rabbitMqContainer = new GenericContainer<>("rabbitmq:3-management") + .withExposedPorts(5672, 15672) + .withEnv("RABBITMQ_DEFAULT_USER", "guest") + .withEnv("RABBITMQ_DEFAULT_PASS", "guest"); + + @DynamicPropertySource + static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.rabbitmq.host", rabbitMqContainer::getHost); + registry.add("spring.rabbitmq.port", () -> rabbitMqContainer.getMappedPort(5672)); + } + + @BeforeEach - void setup() { + void setup() throws InterruptedException { + Thread.sleep(300); + walletTransactionRepository.deleteAll(); walletBalanceRepository.deleteAll(); walletRepository.deleteAll(); userRepository.deleteAll(); // 기존 데이터 삭제 + userRepository.flush(); // 유저 생성 sender = createUser("sender@example.com", "passwordA123!", "sen", "S" + generateRandomId(), "010-1111-2222", Sex.MALE); @@ -84,6 +124,8 @@ void setup() { // 지갑 생성 및 검증 senderWallet = createWalletForUser(sender, "1234"); receiverWallet = createWalletForUser(receiver, "1234"); + System.out.println("senderWallet = " + senderWallet.getWalletId()); + System.out.println("receiverWallet = " + receiverWallet.getWalletId()); } // 중복되지 않는 유저 생성 @@ -96,7 +138,8 @@ private User createUser(String email, String password, String username, String n private Wallet createWalletForUser(User user, String walletPassword) { walletService.createWallet(user, walletPassword); - return walletRepository.findByUserId(user.getUserId()); + return walletRepository.findByUserId(user.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); } // 랜덤한 ID 생성 (UUID 활용) @@ -105,11 +148,12 @@ private String generateRandomId() { } @Test + @DisplayName("잔액이 충분할 때 송금이 성공한다") void testTransferSuccess() { - lockWalletFacade.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); + lockWalletFacade.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); - lockWalletFacade.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + lockWalletFacade.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); WalletBalance receiverBalance = balanceService.findBalance(receiverWallet.getWalletId(), CURRENCY); @@ -119,16 +163,18 @@ void testTransferSuccess() { } @Test + @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), CHARGE_AMOUNT.add(BigDecimal.ONE), CURRENCY, CURRENCY, null); - Exception exception = assertThrows(RuntimeException.class, () -> lockWalletFacade.transfer(transferRequest)); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + Exception exception = assertThrows(RuntimeException.class, () -> lockWalletFacade.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @Test @Transactional + @DisplayName("계좌에 충전이 성공한다") void testChargeWallet() { - lockWalletFacade.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); + lockWalletFacade.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); assertThat(balance.getBalance()).isEqualByComparingTo(CHARGE_AMOUNT); @@ -139,7 +185,8 @@ void testChargeWallet() { * // */ @Test - void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException { + @DisplayName("여러 사용자가 동시에 같은 계좌로 송금하면 모든 송금이 처리된다") + void concurrentTransfersToSameWallet() throws InterruptedException { // Given: 초기 충전 WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); balanceService.chargeBalance(balance, CHARGE_AMOUNT.multiply(BigDecimal.valueOf(100))); @@ -153,15 +200,13 @@ void testChargeWallet() { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest( - senderId, receiverId, TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { try { startLatch.await(); // 🔥 모든 스레드가 동시에 실행되도록 대기 - lockWalletFacade.transfer(transferRequest); + lockWalletFacade.transfer(senderId, transferRequest); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); } finally { @@ -201,9 +246,10 @@ void testChargeWallet() { * ✅ 송금 도중 출금이 발생하면 한쪽이 실패하는지 확인하는 동시성 테스트 (5번 반복) */ @RepeatedTest(5) // 5번 반복 실행 - void 송금_도중_발생한_출금은_실패한다() throws Exception { - lockWalletFacade.charge(new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + @DisplayName("송금 도중 출금이 발생하면 둘 중 하나는 실패한다") + void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception { + lockWalletFacade.charge(sender.getUserId(), new WalletInOutRequest( + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() )); AtomicBoolean isWithdrawFirst = new AtomicBoolean(false); @@ -217,10 +263,9 @@ void testChargeWallet() { latch.await(); Thread.sleep(20); // 🔥 실행 순서를 조정 System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); - lockWalletFacade.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + + lockWalletFacade.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); @@ -232,9 +277,9 @@ void testChargeWallet() { latch.await(); System.out.println("💸 [출금 시작]"); WalletInOutRequest withdrawRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - lockWalletFacade.withdrawal(withdrawRequest); + lockWalletFacade.withdrawal(sender.getUserId(), withdrawRequest); isWithdrawFirst.set(true); } catch (Exception e) { System.err.println("[출금 중 예외 발생]: " + e.getMessage()); @@ -260,17 +305,16 @@ void testChargeWallet() { * ✅ 충전과 이체가 동시에 발생할 때 이체가 실패하는지 확인하는 동시성 테스트 */ @RepeatedTest(5) // 5번 반복 실행 - void 계좌_충전과_이체가_동시에_발생하면_이체는_실패한다() throws Exception { + @DisplayName("충전과 이체가 동시에 발생하면 이체는 실패하고 충전은 성공한다") + void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { // Given: 초기 잔액 설정 System.out.println("초기 Sender 잔액 = " + balanceService.findBalance(senderWallet.getWalletId(), CURRENCY).getBalance()); WalletInOutRequest chargeRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행) @@ -278,7 +322,7 @@ void testChargeWallet() { try { latch.await(); // 🔥 이체가 끝날 때까지 충전 대기 System.out.println("💰 충전 시작"); - lockWalletFacade.charge(chargeRequest); + lockWalletFacade.charge(sender.getUserId(), chargeRequest); } catch (InterruptedException e) { throw new RuntimeException(e); } @@ -288,7 +332,7 @@ void testChargeWallet() { try { System.out.println("🚀 이체 시작"); Exception exception = assertThrows(CommonException.class, () -> { - lockWalletFacade.transfer(transferRequest); + lockWalletFacade.transfer(sender.getUserId(), transferRequest); }); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } finally { diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletRedissonServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java similarity index 67% rename from src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletRedissonServiceTest.java rename to src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java index 4f679d1c..2f0af398 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/service/WalletRedissonServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java @@ -1,18 +1,23 @@ -package bumblebee.xchangepass.domain.wallet.service; +package bumblebee.xchangepass.domain.wallet.wallet.service; import bumblebee.xchangepass.domain.user.entity.Sex; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletInOutRequest; -import bumblebee.xchangepass.domain.wallet.dto.request.WalletTransferRequest; -import bumblebee.xchangepass.domain.wallet.entity.Wallet; -import bumblebee.xchangepass.domain.wallet.repository.WalletRepository; -import bumblebee.xchangepass.domain.wallet.service.redisson.RedissonLockService; -import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; -import bumblebee.xchangepass.domain.walletBalance.repository.WalletBalanceRepository; -import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.transaction.repository.WalletTransactionRepository; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletServiceImpl; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.PessimisticLockWalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.lock.redisson.RedissonLockWalletService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.global.error.ErrorCode; import bumblebee.xchangepass.global.exception.CommonException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,7 +25,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -37,15 +48,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest +@Testcontainers @ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class WalletRedissonServiceTest { @Autowired - private WalletService walletService; - + private WalletServiceImpl walletService; @Autowired - private RedissonLockService lockService; + private RedissonLockWalletService lockService; @Autowired private UserRepository userRepository; @Autowired @@ -54,6 +64,8 @@ class WalletRedissonServiceTest { private WalletBalanceRepository walletBalanceRepository; @Autowired private WalletBalanceService balanceService; + @Autowired + private WalletTransactionRepository walletTransactionRepository; private final BigDecimal CHARGE_AMOUNT = new BigDecimal("10000.00"); private final BigDecimal TRANSFER_AMOUNT = new BigDecimal("5000.00"); @@ -64,14 +76,41 @@ class WalletRedissonServiceTest { private Wallet senderWallet; private Wallet receiverWallet; - /** - * 💡 모든 테스트 실행 전에 사용자 & 지갑을 미리 생성 - */ + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("testuser") + .withPassword("testpass"); + + @Container + static GenericContainer rabbitMqContainer = new GenericContainer<>("rabbitmq:3-management") + .withExposedPorts(5672, 15672) + .withEnv("RABBITMQ_DEFAULT_USER", "guest") + .withEnv("RABBITMQ_DEFAULT_PASS", "guest"); + + @DynamicPropertySource + static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.rabbitmq.host", rabbitMqContainer::getHost); + registry.add("spring.rabbitmq.port", () -> rabbitMqContainer.getMappedPort(5672)); + } + + @BeforeEach - void setup() { + void setup() throws InterruptedException { + Thread.sleep(300); + walletTransactionRepository.deleteAll(); walletBalanceRepository.deleteAll(); walletRepository.deleteAll(); userRepository.deleteAll(); // 기존 데이터 삭제 + userRepository.flush(); // 유저 생성 sender = createUser("sender@example.com", "passwordA123!", "sen", "S" + generateRandomId(), "010-1111-2222", Sex.MALE); @@ -98,7 +137,8 @@ private User createUser(String email, String password, String username, String n private Wallet createWalletForUser(User user, String walletPassword) { walletService.createWallet(user, walletPassword); - return walletRepository.findByUserId(user.getUserId()); + return walletRepository.findByUserId(user.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); } // 랜덤한 ID 생성 (UUID 활용) @@ -107,11 +147,12 @@ private String generateRandomId() { } @Test + @DisplayName("잔액이 충분할 때 송금이 성공한다") void testTransferSuccess() { - lockService.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); + lockService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); - lockService.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + lockService.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); WalletBalance receiverBalance = balanceService.findBalance(receiverWallet.getWalletId(), CURRENCY); @@ -121,16 +162,18 @@ void testTransferSuccess() { } @Test + @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(sender.getUserId(), receiver.getUserId(), CHARGE_AMOUNT.add(BigDecimal.ONE), CURRENCY, CURRENCY, null); - Exception exception = assertThrows(RuntimeException.class, () -> lockService.transfer(transferRequest)); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + Exception exception = assertThrows(RuntimeException.class, () -> lockService.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @Test @Transactional + @DisplayName("계좌에 충전이 성공한다") void testChargeWallet() { - lockService.charge(new WalletInOutRequest(sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); + lockService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now())); WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); assertThat(balance.getBalance()).isEqualByComparingTo(CHARGE_AMOUNT); @@ -141,7 +184,8 @@ void testChargeWallet() { * // */ @Test - void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException { + @DisplayName("여러 사용자가 동시에 같은 계좌로 송금하면 모든 송금이 처리된다") + void concurrentTransfersToSameWallet() throws InterruptedException { // Given: 초기 충전 WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); balanceService.chargeBalance(balance, CHARGE_AMOUNT.multiply(BigDecimal.valueOf(100))); @@ -156,15 +200,13 @@ void testChargeWallet() { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest( - senderId, receiverId, TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { try { startLatch.await(); // 🔥 모든 스레드가 동시에 실행되도록 대기 - lockService.transfer(transferRequest); + lockService.transfer(senderId, transferRequest); transferCount.incrementAndGet(); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); @@ -207,9 +249,10 @@ void testChargeWallet() { * ✅ 송금 도중 출금이 발생하면 한쪽이 실패하는지 확인하는 동시성 테스트 (5번 반복) */ @RepeatedTest(5) // 5번 반복 실행 - void 송금_도중_발생한_출금은_실패한다() throws Exception { - lockService.charge(new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + @DisplayName("송금 도중 출금이 발생하면 둘 중 하나는 실패한다") + void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception { + lockService.charge(sender.getUserId(), new WalletInOutRequest( + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() )); AtomicBoolean isWithdrawFirst = new AtomicBoolean(false); @@ -223,10 +266,9 @@ void testChargeWallet() { latch.await(); Thread.sleep(20); // 🔥 실행 순서를 조정 System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); - lockService.transfer(transferRequest); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + + lockService.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); } catch (Exception e) { System.err.println("[송금 중 예외 발생]: " + e.getMessage()); @@ -238,9 +280,9 @@ void testChargeWallet() { latch.await(); System.out.println("💸 [출금 시작]"); WalletInOutRequest withdrawRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - lockService.withdrawal(withdrawRequest); + lockService.withdrawal(sender.getUserId(), withdrawRequest); isWithdrawFirst.set(true); } catch (Exception e) { System.err.println("[출금 중 예외 발생]: " + e.getMessage()); @@ -266,17 +308,16 @@ void testChargeWallet() { * ✅ 충전과 이체가 동시에 발생할 때 이체가 실패하는지 확인하는 동시성 테스트 */ @RepeatedTest(5) // 5번 반복 실행 - void 계좌_충전과_이체가_동시에_발생하면_이체는_실패한다() throws Exception { + @DisplayName("충전과 이체가 동시에 발생하면 이체는 실패하고 충전은 성공한다") + void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { // Given: 초기 잔액 설정 System.out.println("초기 Sender 잔액 = " + balanceService.findBalance(senderWallet.getWalletId(), CURRENCY).getBalance()); WalletInOutRequest chargeRequest = new WalletInOutRequest( - sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() + CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest( - sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() - ); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행) @@ -284,7 +325,7 @@ void testChargeWallet() { try { latch.await(); // 🔥 이체가 끝날 때까지 충전 대기 System.out.println("💰 충전 시작"); - lockService.charge(chargeRequest); + lockService.charge(sender.getUserId(), chargeRequest); } catch (InterruptedException e) { throw new RuntimeException(e); } @@ -294,7 +335,7 @@ void testChargeWallet() { try { System.out.println("🚀 이체 시작"); Exception exception = assertThrows(CommonException.class, () -> { - lockService.transfer(transferRequest); + lockService.transfer(sender.getUserId(), transferRequest); }); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } finally {