diff --git a/build.gradle b/build.gradle index 550ad900..36f05d3e 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,7 @@ dependencies { implementation 'io.projectreactor.netty:reactor-netty:1.1.22' // Reactor Netty 의존성 implementation 'com.fasterxml.jackson.core:jackson-databind' // JSON 파싱을 위한 의존성 implementation 'com.google.code.gson:gson:2.10.1' // JSON 데이터를 다룰 수 있는 클래스 + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' // MacOS에서 DNS 관련 네이티브 라이브러리 } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 06284c2a..131cac66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,16 @@ services: - "6379:6379" volumes: - redis-data:/data - command: [ "redis-server", "--appendonly", "yes" ] + command: [ + "redis-server", + "--save", "900 1", # 900초(15분)마다 최소 1개 이상의 변경이 있으면 스냅샷 저장 + "--save", "300 10", # 300초(5분)마다 최소 10개 이상의 변경이 있으면 저장 + "--save", "60 1000", # 60초마다 최소 1000개의 변경사항이 있으면 저장 + "--appendonly", "yes", # AOF 활성화 + "--appendfsync", "everysec", # 1초마다 디스크에 기록 + "--auto-aof-rewrite-percentage", "100", # AOF 크기가 100% 커지면 리라이트 + "--auto-aof-rewrite-min-size", "64mb" # AOF 크기가 최소 64MB일 때 리라이트 + ] # 🔹 PostgreSQL (Spring Boot가 사용하는 DB) postgres: diff --git a/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java b/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java index 99347039..e9a4aa50 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java @@ -1,16 +1,18 @@ package bumblebee.xchangepass.domain.card.service; -import bumblebee.xchangepass.domain.ExchangeRate.dto.response.ExchangeRateResponse; -import bumblebee.xchangepass.domain.ExchangeRate.service.ExchangeService; +//import bumblebee.xchangepass.domain.ExchangeRate.dto.response.ExchangeRateResponse; +//import bumblebee.xchangepass.domain.ExchangeRate.service.ExchangeService; import bumblebee.xchangepass.domain.card.dto.request.PaymentRequest; import bumblebee.xchangepass.domain.card.dto.response.PaymentResponse; import bumblebee.xchangepass.domain.card.entity.CardStatus; import bumblebee.xchangepass.domain.cardTransaction.dto.request.PaymentApprovedEvent; +import bumblebee.xchangepass.domain.exchangeRate.dto.response.ExchangeRateResponse; +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.entity.Wallet; -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.global.error.ErrorCode; import bumblebee.xchangepass.global.security.crypto.AESEncryption; import bumblebee.xchangepass.global.security.crypto.RSAEncryption; diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java index f976b482..a8b27a1c 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java @@ -1,7 +1,8 @@ package bumblebee.xchangepass.domain.refresh.controller; -import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenRequest; import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService; +import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; +import bumblebee.xchangepass.global.error.ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -9,12 +10,13 @@ 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.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -43,8 +45,20 @@ public class RefreshTokenController { }) @ResponseStatus(HttpStatus.OK) @PostMapping("/token-refresh") - public void tokenRefresh(@RequestBody @Valid RefreshTokenRequest request) { - refreshTokenService.refreshToken(request.refreshToken()); + public ResponseEntity tokenRefresh(@CookieValue(value = "refreshToken", required = false) String refreshToken) { + // refreshToken이 없다면 예외 처리 + if (refreshToken == null) { + throw ErrorCode.REFRESH_TOKEN_INVALID.commonException(); + } + + // 새 토큰 재발급 + RefreshTokenResponse response = refreshTokenService.refreshToken(refreshToken); + + ResponseCookie refreshCookie = refreshTokenService.saveRefreshToken(response); + + return ResponseEntity.ok() + .header("Set-Cookie", refreshCookie.toString()) + .build(); } } \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/dto/RefreshTokenRequest.java b/src/main/java/bumblebee/xchangepass/domain/refresh/dto/RefreshTokenRequest.java deleted file mode 100644 index 2b455050..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/dto/RefreshTokenRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package bumblebee.xchangepass.domain.refresh.dto; - -public record RefreshTokenRequest(String refreshToken) { - -} diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java b/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java index 55402eef..15d9dd76 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java @@ -51,4 +51,12 @@ public boolean isValidRefreshToken(Long userId, String refreshToken) { String storedToken = (String) jsonRedisTemplate.opsForValue().get("refresh_token:" + userId); return storedToken != null && storedToken.equals(refreshToken); } + + /** + * 사용자 ID로 Refresh Token 조회 + */ + public String getRefreshToken(Long userId) { + String key = "refresh_token:" + userId; + return (String) jsonRedisTemplate.opsForValue().get(key); + } } diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java b/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java index d0bb2dd2..648e4c6e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java @@ -5,6 +5,7 @@ import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Service; @Service @@ -27,10 +28,18 @@ public RefreshTokenResponse refreshToken(final String refreshToken) { // Redis에서 사용자 ID 조회 Long userId = refreshTokenRepository.getUserIdFromRefreshToken(refreshToken); + // Redis에 저장된 토큰과 비교 → 재사용 감지 + String storedToken = refreshTokenRepository.getRefreshToken(userId); + + if (storedToken == null || !storedToken.equals(refreshToken)) { + // 이미 사용된 토큰 or 위조된 토큰 → 재사용 시도 + refreshTokenRepository.deleteRefreshToken(userId); // Redis 강제 삭제 + + throw ErrorCode.REFRESH_TOKEN_INVALID.commonException(); + } + // 새로운 Access Token 생성 String newAccessToken = jwtProvider.generateAccessToken(userId); - - // 새로운 Refresh Token 생성 후 Redis에 저장 (기존 것은 자동 삭제됨) String newRefreshToken = jwtProvider.generateRefreshToken(userId); refreshTokenRepository.saveRefreshToken(newRefreshToken, userId); @@ -40,6 +49,16 @@ public RefreshTokenResponse refreshToken(final String refreshToken) { .build(); } + public ResponseCookie saveRefreshToken(RefreshTokenResponse response) { + return ResponseCookie.from("refreshToken", response.refreshToken()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(60 * 60 * 24 * 7) + .sameSite("Strict") + .build(); + } + /** * refresh token 검증 * 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 3b3feec0..b1e6f673 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java @@ -1,7 +1,7 @@ package bumblebee.xchangepass.domain.user.entity; -import bumblebee.xchangepass.domain.ExchangeTransaction.entitiy.ExchangeTransaction; import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import bumblebee.xchangepass.domain.exchangeTransaction.entitiy.ExchangeTransaction; import bumblebee.xchangepass.domain.user.entity.value.*; import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import jakarta.persistence.*; diff --git a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java index 896c98f0..c62a8f5d 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java @@ -1,7 +1,7 @@ package bumblebee.xchangepass.domain.user.login; +import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; -import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -11,10 +11,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor @Tag(name = "Login", description = "Login API") @@ -40,8 +44,16 @@ public class LoginController { }) @ResponseStatus(HttpStatus.OK) @PostMapping("/login") - public LoginResponse memberLogin(@RequestBody @Valid LoginRequest loginRequest) { - return loginService.login(loginRequest); + public ResponseEntity memberLogin(@RequestBody @Valid LoginRequest loginRequest) { + RefreshTokenResponse response = loginService.login(loginRequest); + + ResponseCookie accessCookie = loginService.saveAccessToken(response.accessToken()); + ResponseCookie refreshCookie = loginService.saveRefreshToken(response); + + return ResponseEntity.ok() + .header("Set-Cookie", accessCookie.toString()) + .header("Set-Cookie", refreshCookie.toString()) + .build(); } @Operation(summary = "로그아웃", description = "로그아웃합니다.") @@ -55,8 +67,20 @@ public LoginResponse memberLogin(@RequestBody @Valid LoginRequest loginRequest) }) @ResponseStatus(HttpStatus.OK) @PostMapping("/logout") - public void logout(@RequestHeader("Authorization") String refreshToken) { + public ResponseEntity logout(@RequestHeader("Authorization") String refreshToken) { + if (refreshToken.startsWith("Bearer ")) { + refreshToken = refreshToken.substring(7); + } + // Refresh Token 삭제 loginService.logout(refreshToken); + + ResponseCookie expiredRefresh = loginService.deleteRefreshToken(); + ResponseCookie expiredAccess = loginService.deleteAccessToken(); + + return ResponseEntity.ok() + .header("Set-Cookie", expiredRefresh.toString()) + .header("Set-Cookie", expiredAccess.toString()) + .build(); } } 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 05d79ebd..7ab298f7 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java @@ -1,13 +1,14 @@ package bumblebee.xchangepass.domain.user.login; +import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; +import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository; import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse; import bumblebee.xchangepass.domain.user.service.UserService; import bumblebee.xchangepass.global.error.ErrorCode; 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.repository.RefreshTokenRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +26,7 @@ public class LoginService{ private final RefreshTokenRepository refreshTokenRepository; @Transactional - public LoginResponse login(final LoginRequest request) { + public RefreshTokenResponse login(final LoginRequest request) { // 사용자 정보 조회 UserLoginResponse userInfo = userService.readUserByUserEmail(request.userEmail()); @@ -40,12 +41,33 @@ public LoginResponse login(final LoginRequest request) { String refreshToken = jwtProvider.generateRefreshToken(userInfo.userId()); refreshTokenRepository.saveRefreshToken(refreshToken, userInfo.userId()); - return LoginResponse.builder() + return RefreshTokenResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .build(); } + public ResponseCookie saveAccessToken(String accessToken) { + return ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(60 * 15) // 15분 + .sameSite("Strict") + .build(); + } + + public ResponseCookie saveRefreshToken(RefreshTokenResponse response) { + // 쿠키 설정 + return ResponseCookie.from("refreshToken", response.refreshToken()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(60 * 60 * 24 * 7) // 7일 + .sameSite("Strict") + .build(); + } + public void logout(String refreshToken) { // "Bearer " 접두사 제거 if (refreshToken.startsWith("Bearer ")) { @@ -55,4 +77,25 @@ public void logout(String refreshToken) { Long userId = refreshTokenRepository.getUserIdFromRefreshToken(refreshToken); refreshTokenRepository.deleteRefreshToken(userId); } + + public ResponseCookie deleteAccessToken() { + return ResponseCookie.from("accessToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) // 즉시 만료 + .sameSite("Strict") + .build(); + } + + + public ResponseCookie deleteRefreshToken() { + return ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) // 쿠키 만료 + .sameSite("Strict") + .build(); + } } diff --git a/src/main/java/bumblebee/xchangepass/domain/user/login/dto/response/LoginResponse.java b/src/main/java/bumblebee/xchangepass/domain/user/login/dto/response/LoginResponse.java deleted file mode 100644 index fe796d61..00000000 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/dto/response/LoginResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package bumblebee.xchangepass.domain.user.login.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - - -//추후 수정 예정 -@Schema(description = "임시 로그인 응답 객체") -public record LoginResponse( - String accessToken, - String refreshToken -) { - @Builder - public LoginResponse { - } -} 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 f9163483..3bd4b626 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/service/UserService.java @@ -32,7 +32,7 @@ public UserResponse readUser(Long userId) { } public User readUser(String userName, String userPhoneNumber) { - return userRepository.findByUserId(userName, userPhoneNumber) + return userRepository.findByNameAndPhoneNumber(userName, userPhoneNumber) .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java index e42b984b..2d467527 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/balance/service/WalletBalanceService.java @@ -2,6 +2,8 @@ import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectEvent; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectionService; import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType; import bumblebee.xchangepass.domain.wallet.transaction.service.WalletTransactionService; import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; @@ -11,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.Currency; import java.util.List; @@ -21,6 +24,11 @@ public class WalletBalanceService { private final WalletBalanceRepository balanceRepository; private final WalletTransactionService transactionService; + /** + * 화폐별 잔액 생성 + * @param wallet + * @param currency + */ public void createBalance(Wallet wallet, Currency currency) { if (balanceRepository.existsByCurrency(wallet.getWalletId(), currency)) { return; @@ -30,32 +38,65 @@ public void createBalance(Wallet wallet, Currency currency) { balanceRepository.save(balance); } + /** + * 화폐별 잔액 조회 + * @param walletId + * @param currency + * @return + */ @Transactional(readOnly = true) public WalletBalance findBalance(Long walletId, Currency currency) { return balanceRepository.findByWalletIdAndCurrency(walletId, currency) .orElseThrow(ErrorCode.BALANCE_NOT_FOUND::commonException); } + /** + * 비관적 락 적용, 화폐별 잔액 조회 + * @param walletId + * @param currency + * @return + */ @Transactional public WalletBalance findBalanceWithLock(Long walletId, Currency currency) { return balanceRepository.findByWalletIdAndCurrencyWithPessimisticLock(walletId, currency) .orElseThrow(ErrorCode.BALANCE_NOT_FOUND::commonException); } + /** + * 화폐별 잔액 목록 조회 + * @param walletId + * @return + */ @Transactional(readOnly = true) public List findBalances(Long walletId) { return balanceRepository.findByWalletId(walletId); } + /** + * 비관적 락 적용, 화폐별 잔액 목록 조회 + * @param walletId + * @return + */ @Transactional public List findBalancesWithLock(Long walletId) { return balanceRepository.findByWalletIdWithPessimisticLock(walletId); } + /** + * 충전된 화폐별 잔액이 있는지 확인 + * @param walletId + * @param currency + * @return + */ public boolean checkBalance(Long walletId, Currency currency) { return balanceRepository.existsByCurrency(walletId, currency); } + /** + * 화폐별 잔액 입금 + * @param balance + * @param amount + */ public void chargeBalance(WalletBalance balance, BigDecimal amount) { balance.addBalance(amount); balanceRepository.save(balance); @@ -63,6 +104,11 @@ public void chargeBalance(WalletBalance balance, BigDecimal amount) { transactionService.saveTransaction(balance.getWallet().getWalletId(), null, amount, null, balance.getCurrency(), WalletTransactionType.DEPOSIT); } + /** + * 화폐별 잔액 출금 + * @param balance + * @param amount + */ public void withdrawBalance(WalletBalance balance, BigDecimal amount) { if (amount.compareTo(balance.getBalance()) > 0) { throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); @@ -74,6 +120,12 @@ public void withdrawBalance(WalletBalance balance, BigDecimal amount) { transactionService.saveTransaction(balance.getWallet().getWalletId(), null, amount, null, balance.getCurrency(), WalletTransactionType.WITHDRAWAL); } + /** + * 화폐별 잔액 송금 + * @param fromBalance + * @param toBalance + * @param amount + */ public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, BigDecimal amount) { fromBalance.subtractBalance(amount); toBalance.addBalance(amount); 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 deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectEvent.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectEvent.java new file mode 100644 index 00000000..aa071d35 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectEvent.java @@ -0,0 +1,11 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record FraudDetectEvent( + Long userId, + BigDecimal amount, + LocalDateTime timestamp, + String detail +) {} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java new file mode 100644 index 00000000..550abd92 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java @@ -0,0 +1,30 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import bumblebee.xchangepass.domain.wallet.transaction.consumer.SlackNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FraudDetectionService { + + private final RedisFraudStorageService redisFraudStorage; + private final FraudRuleEvaluator ruleEvaluator; + private final SlackNotifier slackNotifier; + + public void detect(FraudDetectEvent event) { + redisFraudStorage.store(event.userId(), event.amount()); + + boolean isFraud = ruleEvaluator.isSuspicious(event.userId(), event.amount()); + + if (isFraud) { + String reason = ruleEvaluator.getLastDetectedReason(); + slackNotifier.notifyFraud(new FraudDetectEvent( + event.userId(), + event.amount(), + event.timestamp(), + reason + )); + } + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRecord.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRecord.java new file mode 100644 index 00000000..7bf6f85c --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRecord.java @@ -0,0 +1,18 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record FraudRecord( + BigDecimal amount, + LocalDateTime timestamp +) { + public String serialize() { + return amount + "," + timestamp.toString(); + } + + public static FraudRecord deserialize(String value) { + String[] parts = value.split(",", 2); + return new FraudRecord(new BigDecimal(parts[0]), LocalDateTime.parse(parts[1])); + } +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java new file mode 100644 index 00000000..8e34ea77 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java @@ -0,0 +1,62 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scripting.support.ResourceScriptSource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FraudRuleEvaluator { + + private final RedisTemplate redisTemplate; + + private String lastDetectedReason; + + public Boolean isSuspicious(Long userId, BigDecimal amount) { + this.lastDetectedReason = null; + + Long nowEpoch = System.currentTimeMillis() / 1000; + Boolean isNight = isNightTime(); + + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/fraud_check.lua"))); + script.setResultType(String.class); + + String result = redisTemplate.execute( + script, + List.of("fraud:user:" + userId), + amount.toString(), + String.valueOf(nowEpoch), + "500000", // 누적 한도 + "5", // 5분 내 최대 거래 수 + "true", // 기록 저장 여부 + isNight ? "1" : "0" + ); + + switch (result) { + case "1" -> lastDetectedReason = "누적 금액 초과"; + case "2" -> lastDetectedReason = "5분 내 거래 횟수 초과"; + case "3" -> lastDetectedReason = "동일 금액 반복"; + case "4" -> lastDetectedReason = "심야 시간대 거래"; + default -> lastDetectedReason = null; + } + + return !"0".equals(result); + } + + public String getLastDetectedReason() { + return lastDetectedReason != null ? lastDetectedReason : "알 수 없음"; + } + + private Boolean isNightTime() { + LocalTime now = LocalTime.now(); + return now.isAfter(LocalTime.of(2, 30)) && now.isBefore(LocalTime.of(3, 30)); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/RedisFraudStorageService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/RedisFraudStorageService.java new file mode 100644 index 00000000..46bbf652 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/RedisFraudStorageService.java @@ -0,0 +1,34 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RedisFraudStorageService { + + private final RedisTemplate redisTemplate; + + private static final Duration TTL = Duration.ofMinutes(10); + + public void store(Long userId, BigDecimal amount) { + String key = "fraud:" + userId; + String value = new FraudRecord(amount, LocalDateTime.now()).serialize(); + redisTemplate.opsForList().rightPush(key, value); + redisTemplate.expire(key, TTL); + } + + public List getRecentTransactions(Long userId){ + String key = "fraud:" + userId; + List raw = redisTemplate.opsForList().range(key, 0, -1); + return raw.stream() + .map(FraudRecord::deserialize) + .toList(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java deleted file mode 100644 index e69de29b..00000000 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 index 2afb91c4..7b2034f6 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/DeadLetterConsumer.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/DeadLetterConsumer.java @@ -39,7 +39,7 @@ public void handleDeadLetter(WalletTransactionMessage message, rabbitTemplate.convertAndSend("wallet-transaction-retry-queue", message); } else { log.error("🚨 DLQ 재시도 초과, 슬랙 알림 전송: {}", message); - slackNotifier.send("🚨 DLQ 처리 실패: " + message); + slackNotifier.failToSaveTransaction("🚨 DLQ 처리 실패: " + message); } // 수동 ack 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 index 60fdb896..c638aa54 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java @@ -1,15 +1,14 @@ package bumblebee.xchangepass.domain.wallet.transaction.consumer; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectEvent; 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; @@ -26,7 +25,7 @@ public class SlackNotifier { private final RestTemplate restTemplate = new RestTemplate(); - public void send(String message) { + public void failToSaveTransaction(String message) { Map payload = new HashMap<>(); payload.put("text", ":rotating_light: *DLQ 경고 발생*"); payload.put("blocks", List.of( @@ -51,4 +50,44 @@ public void send(String message) { log.error("Slack 전송 실패", e); } } + + public void notifyFraud(FraudDetectEvent event) { + Map payload = new HashMap<>(); + payload.put("text", "🚨 이상 거래 감지"); + + payload.put("blocks", List.of( + Map.of( + "type", "header", + "text", Map.of( + "type", "plain_text", + "text", "🚨 이상 거래 탐지", + "emoji", true + ) + ), + Map.of( + "type", "section", + "fields", List.of( + Map.of("type", "mrkdwn", "text", "*사용자 ID:*\n" + event.userId()), + Map.of("type", "mrkdwn", "text", "*금액:*\n" + event.amount()), + Map.of("type", "mrkdwn", "text", "*시각:*\n" + event.timestamp()), + Map.of("type", "mrkdwn", "text", "*사유:*\n" + event.detail()) + ) + ), + Map.of( + "type", "context", + "elements", List.of( + Map.of("type", "mrkdwn", "text", ":shield: 이상 거래는 자동으로 기록되고 있습니다.") + ) + ) + )); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(payload, headers); + restTemplate.postForEntity(webhookUrl, request, 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/wallet/controller/WalletController.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java index c2ab5a85..02a5b008 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/controller/WalletController.java @@ -7,6 +7,7 @@ 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.domain.wallet.wallet.service.impl.WalletFacadeService; import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -19,7 +20,6 @@ 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.*; @@ -35,6 +35,7 @@ public class WalletController { private final WalletServiceFactory walletServiceFactory; private final WalletTransactionService transactionService; + private final WalletFacadeService walletFacadeService; @Operation(summary = "거래내역 조회", description = "거래내역을 조회합니다.") @ApiResponses(value = { @@ -113,7 +114,30 @@ public BigDecimal withdrawal(@RequestBody @Valid WalletInOutRequest request, @ResponseStatus(HttpStatus.NO_CONTENT) public void transfer(@RequestBody @Valid WalletTransferRequest request, @AuthenticationPrincipal CustomUserDetails user) { - walletServiceFactory.getService("namedLock").transfer(user.getUserId(), request); + walletFacadeService.transfer(user.getUserId(), request); + } + + @Operation(summary = "앱 내 예약 송금", description = "돈을 송금합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "송금 성공", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "먼저 충전이 필요합니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\n \"code\": \"B001\"," + + "\n \"message\": \"해당 화폐 잔액이 존재하지 않습니다.\"}")) + ), + @ApiResponse(responseCode = "400", description = "잔액이 부족합니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\n \"code\": \"B002\"," + + "\n \"message\": \"해당 화폐 잔액이 부족합니다.\"}")) + ) + }) + @PutMapping("/transfer-schedule") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void scheduleTransfer(@RequestBody @Valid WalletTransferRequest request, + @AuthenticationPrincipal CustomUserDetails user) { + walletFacadeService.transfer(user.getUserId(), request); } @Operation(summary = "잔액 조회", description = "잔액을 조회합니다.") 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 index 07642d2e..a229969a 100644 --- 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 @@ -1,9 +1,11 @@ package bumblebee.xchangepass.domain.wallet.wallet.dto.request; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; 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.FutureOrPresent; import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; @@ -34,6 +36,10 @@ public record WalletTransferRequest( Currency toCurrency, @Schema(description = "송금 날짜", example = "2024-02-20T12:34:56") - LocalDateTime transferDatetime + @FutureOrPresent + LocalDateTime transferDatetime, + + @Schema(description = "송금 타입", example = "GENERAL") + WalletTransferType transferType ) { } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/ScheduledTransfer.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/ScheduledTransfer.java new file mode 100644 index 00000000..a6b200af --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/ScheduledTransfer.java @@ -0,0 +1,54 @@ +package bumblebee.xchangepass.domain.wallet.wallet.entity; + +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +import static bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferStatus.*; + +@Getter +@Entity +@Table(name = "scheduled_transfer") +@NoArgsConstructor +public class ScheduledTransfer { + @Id + @GeneratedValue + private Long scheduledTransferId; + + private Long senderId; + + private String receiverName; + private String receiverPhoneNumber; + + private BigDecimal transferAmount; + private Currency fromCurrency; + private Currency toCurrency; + private LocalDateTime scheduledAt; + + @Enumerated(EnumType.STRING) + private WalletTransferStatus status = PENDING; + + public void markSuccess() { + status = SUCCESS; + } + + public void markFailed() { + status = FAILED; + } + + public ScheduledTransfer(Long senderId, WalletTransferRequest request) { + this.senderId = senderId; + this.receiverName = request.receiverName(); + this.receiverPhoneNumber = request.receiverPhoneNumber(); + this.transferAmount = request.transferAmount(); + this.fromCurrency = request.fromCurrency(); + this.toCurrency = request.toCurrency(); + this.scheduledAt = request.transferDatetime(); + } + +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferStatus.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferStatus.java new file mode 100644 index 00000000..3c9262a6 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferStatus.java @@ -0,0 +1,7 @@ +package bumblebee.xchangepass.domain.wallet.wallet.entity; + +public enum WalletTransferStatus { + PENDING, // 처리 중 + SUCCESS, // 완료됨 + FAILED // 실패 +} \ No newline at end of file diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferType.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferType.java new file mode 100644 index 00000000..63af28f1 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferType.java @@ -0,0 +1,6 @@ +package bumblebee.xchangepass.domain.wallet.wallet.entity; + +public enum WalletTransferType { + GENERAL, // 일반 + SCHEDULE // 예약 +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/ScheduledTransferRepository.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/ScheduledTransferRepository.java new file mode 100644 index 00000000..60b7a358 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/ScheduledTransferRepository.java @@ -0,0 +1,13 @@ +package bumblebee.xchangepass.domain.wallet.wallet.repository; + +import bumblebee.xchangepass.domain.wallet.wallet.entity.ScheduledTransfer; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ScheduledTransferRepository extends JpaRepository { + + List findByStatusAndScheduledAtBefore(WalletTransferStatus status, LocalDateTime time); +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferExecutor.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferExecutor.java new file mode 100644 index 00000000..6551ca15 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferExecutor.java @@ -0,0 +1,29 @@ +package bumblebee.xchangepass.domain.wallet.wallet.scheduler; + +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.ScheduledTransfer; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ScheduledTransferExecutor { + + private final WalletService walletService; // 바로 구현체 주입 (NamedLockWalletService) + + public void execute(ScheduledTransfer scheduled) { + WalletTransferRequest request = new WalletTransferRequest( + scheduled.getReceiverName(), + scheduled.getReceiverPhoneNumber(), + scheduled.getTransferAmount(), + scheduled.getFromCurrency(), + scheduled.getToCurrency(), + scheduled.getScheduledAt(), + WalletTransferType.GENERAL + ); + walletService.transfer(scheduled.getSenderId(), request); + scheduled.markSuccess(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java new file mode 100644 index 00000000..9d33f961 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java @@ -0,0 +1,48 @@ +package bumblebee.xchangepass.domain.wallet.wallet.scheduler; + +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.ScheduledTransfer; +import bumblebee.xchangepass.domain.wallet.wallet.repository.ScheduledTransferRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferStatus.PENDING; + +@Slf4j +@Service +public class ScheduledTransferService { + @Autowired + ScheduledTransferRepository repository; + + @Autowired + ScheduledTransferRepository scheduledTransferRepository; + + @Autowired + ScheduledTransferExecutor executor; + + @Scheduled(fixedDelay = 60000) // 1분마다 실행 + @Transactional + public void processScheduledTransfers() { + List pending = repository.findByStatusAndScheduledAtBefore(PENDING, LocalDateTime.now()); + + for (ScheduledTransfer scheduled : pending) { + try { + executor.execute(scheduled); + } catch (Exception e) { + scheduled.markFailed(); + log.error("예약 송금 실패: {}", scheduled.getScheduledTransferId(), e); + } + } + } + + public void saveSchedule(Long senderId, WalletTransferRequest request) { + ScheduledTransfer entity = new ScheduledTransfer(senderId,request); + scheduledTransferRepository.save(entity); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletFacadeService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletFacadeService.java new file mode 100644 index 00000000..d946b336 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletFacadeService.java @@ -0,0 +1,26 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service.impl; + +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; +import bumblebee.xchangepass.domain.wallet.wallet.scheduler.ScheduledTransferService; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class WalletFacadeService { + + private final WalletServiceFactory walletServiceFactory; + private final ScheduledTransferService scheduledTransferService; + + public void transfer(Long senderId, WalletTransferRequest request) { + if (request.transferType() == WalletTransferType.SCHEDULE) { + scheduledTransferService.saveSchedule(senderId, request); + } else { + WalletService service = walletServiceFactory.getService("namedLock"); + service.transfer(senderId, request); + } + } +} 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 index a1ae4f65..7b8eefd8 100644 --- 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 @@ -3,26 +3,26 @@ 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.fraud.service.FraudDetectEvent; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectionService; 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.time.LocalDateTime; import java.util.List; @Slf4j @@ -33,6 +33,7 @@ public class NamedLockWalletService implements WalletService { private final WalletRepository walletRepository; private final WalletBalanceService balanceService; private final NamedLockRepository namedLockRepository; + private final FraudDetectionService fraudDetectionService; private final ExchangeService exchangeService; private final UserService userService; @@ -88,7 +89,7 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { balanceService.withdrawBalance(balance, amount); return balance.getBalance(); - } finally { + } finally { Boolean unlockSuccess = namedLockRepository.releaseLock(wallet.getWalletId()); if (!unlockSuccess) { log.error("⚠️ [Named Lock 해제 실패] 사용자 ID: {}", userId); @@ -129,6 +130,13 @@ public void transfer(Long senderId, WalletTransferRequest request) { transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); } + fraudDetectionService.detect(new FraudDetectEvent( + senderId, + transferAmount, + LocalDateTime.now(), + null + )); + balanceService.transferBalance(fromBalance, toBalance, transferAmount); } finally { Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); @@ -140,6 +148,7 @@ public void transfer(Long senderId, WalletTransferRequest request) { if (!smallUnlockSuccess) { log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); } + } } 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 index 297194e9..f183e106 100644 --- 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 @@ -10,6 +10,7 @@ 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.scheduler.ScheduledTransferService; import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; import bumblebee.xchangepass.global.error.ErrorCode; import jakarta.persistence.LockTimeoutException; @@ -31,6 +32,7 @@ public class PessimisticLockWalletService implements WalletService { private final WalletRepository walletRepository; private final WalletBalanceService balanceService; + private final ScheduledTransferService scheduledTransferService; private final ExchangeService exchangeService; private final UserService userService; 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 index d68af43b..cd519c2d 100644 --- 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 @@ -10,6 +10,7 @@ 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.scheduler.ScheduledTransferService; import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; import bumblebee.xchangepass.global.error.ErrorCode; import lombok.RequiredArgsConstructor; @@ -31,6 +32,7 @@ public class RedissonLockWalletService implements WalletService { private final WalletRepository walletRepository; private final WalletBalanceService balanceService; + private final ScheduledTransferService scheduledTransferService; private final RedissonLock redissonLock; private final ExchangeService exchangeService; private final UserService userService; @@ -149,6 +151,7 @@ public void transfer(Long senderId, WalletTransferRequest request) { } } } + } @Override 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 006c75ac..426241f1 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java @@ -4,9 +4,11 @@ import bumblebee.xchangepass.domain.user.service.UserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -18,6 +20,7 @@ import java.util.Collections; import java.util.List; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { @@ -28,26 +31,38 @@ public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - final String token = request.getHeader("Authorization"); - - String userId = null; - - // Bearer token 검증 후 user name 조회 - if(token != null && !token.isEmpty()) { - String jwtToken = token.substring(7); - - userId = jwtProvider.getUserIdFromToken(jwtToken); + try { + final String token = resolveTokenFromCookie(request); + + // Bearer token 검증 후 user name 조회 + if (token != null && !token.isEmpty()) { + String userId = jwtProvider.getUserIdFromToken(token); + + if (userId != null && !userId.isEmpty() + && SecurityContextHolder.getContext().getAuthentication() == null) { + SecurityContextHolder.getContext().setAuthentication(getUserAuth(userId)); + } + } + } catch (Exception e) { + log.warn("[JwtAuthFilter] JWT 처리 중 예외 발생: {}", e.getMessage()); } + filterChain.doFilter(request,response); + } + + private String resolveTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() == null) return null; - // token 검증 완료 후 SecurityContextHolder 내 인증 정보가 없는 경우 저장 - if(userId != null && !userId.isEmpty() && SecurityContextHolder.getContext().getAuthentication() == null) { - SecurityContextHolder.getContext().setAuthentication(getUserAuth(userId)); + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } } - filterChain.doFilter(request,response); + return null; } + /** * token의 사용자 idx를 이용하여 사용자 정보 조회하고, UsernamePasswordAuthenticationToken 생성 * 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 492def24..68f6ffd2 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtProvider.java @@ -12,6 +12,7 @@ import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import static bumblebee.xchangepass.global.common.Constants.JWT_TOKEN_VALID; @@ -38,7 +39,7 @@ public void init() { * @return token Username */ public String getUserIdFromToken(final String token) { - return getClaimFromToken(token, Claims::getId); + return getClaimFromToken(token, Claims::getSubject); } /** @@ -124,7 +125,8 @@ public String generateAccessToken(final String id, final Map cla private String doGenerateAccessToken(final String id, final Map claims) { return Jwts.builder() .setClaims(claims) - .setId(id) + .setSubject(id) // 사용자 ID + .setId(UUID.randomUUID().toString()) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALID)) // 30분 .signWith(key) @@ -159,7 +161,8 @@ public String generateRefreshToken(final long id) { */ private String doGenerateRefreshToken(final String id) { return Jwts.builder() - .setId(id) + .setSubject(id) // 사용자 ID + .setId(UUID.randomUUID().toString()) .setExpiration(new Date(System.currentTimeMillis() + (JWT_TOKEN_VALID * 2) * 24)) // 24시간 .setIssuedAt(new Date(System.currentTimeMillis())) .signWith(key) diff --git a/src/main/resources/lua/fraud_check.lua b/src/main/resources/lua/fraud_check.lua new file mode 100644 index 00000000..f634764d --- /dev/null +++ b/src/main/resources/lua/fraud_check.lua @@ -0,0 +1,74 @@ +-- KEYS[1] = 거래 기록 key (e.g., fraud:user:1234) +-- ARGV[1] = 현재 금액 +-- ARGV[2] = 현재 timestamp (epoch) +-- ARGV[3] = 10분 누적 한도 (500000) +-- ARGV[4] = 5분 거래 횟수 한도 (5) +-- ARGV[5] = 새 거래를 history에 추가할지 여부 ("true" or "false") +-- ARGV[6] = isNightFlag ("1"이면 심야시간) + +local key = KEYS[1] +local currentAmount = tonumber(ARGV[1]) +local now = tonumber(ARGV[2]) +local totalLimit = tonumber(ARGV[3]) +local freqLimit = tonumber(ARGV[4]) +local addFlag = ARGV[5] +local isNight = tonumber(ARGV[6]) + +local startTime = now - 600 +local history = redis.call("ZRANGEBYSCORE", key, startTime, now) + +local sum = 0 +local recentAmounts = {} +local count5min = 0 + +-- 최근 10분 이내 기록 조회 +for _, record in ipairs(history) do + local amountStr, timestampStr = record:match("([^|]+)|([^|]+)") + if amountStr and timestampStr then + local amount = tonumber(amountStr) + local timestamp = tonumber(timestampStr) + + sum = sum + amount + + if timestamp >= now - 300 then + count5min = count5min + 1 + end + + table.insert(recentAmounts, 1, amount) + if #recentAmounts > 3 then + table.remove(recentAmounts) + end + end +end + +-- 룰1: 총합 +if sum + currentAmount > totalLimit then + return "1" +end + +-- 룰2: 최근 5분간 5건 이상 +if count5min >= freqLimit then + return "2" +end + +-- 룰3: 최근 거래 3건 동일 +if #recentAmounts == 3 and + recentAmounts[1] == currentAmount and + recentAmounts[2] == currentAmount and + recentAmounts[3] == currentAmount then + return "3" +end + +-- 룰4: 심야 시간 거래 +if isNight == 1 then + return "4" +end + +-- 기록 추가 +if addFlag == "true" then + local value = tostring(currentAmount) .. "|" .. tostring(now) + redis.call("ZADD", key, now, value) + redis.call("EXPIRE", key, 7200) +end + +return "0" 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 747ba6a5..9788a784 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java @@ -6,8 +6,10 @@ import bumblebee.xchangepass.domain.user.entity.Sex; 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.user.repository.UserRepository; +import bumblebee.xchangepass.global.exception.CommonException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -18,8 +20,7 @@ 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; +import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @ActiveProfiles("test") @@ -37,9 +38,12 @@ class UserLoginScenarioTest { @Autowired private RefreshTokenService refreshTokenService; + @Autowired + private UserRepository userRepository; + private UserRegisterRequest registerRequest; private LoginRequest loginRequest; - private String refreshToken; + private Long userId; @Container static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") @@ -54,29 +58,59 @@ static void overrideDataSourceProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.password", postgresContainer::getPassword); } - @BeforeEach void setUp() { + userRepository.deleteAll(); registerRequest = new UserRegisterRequest( "test@example.com", "Password123!", "test", "010-1234-5678", Sex.MALE, "1234" ); loginRequest = new LoginRequest("test@example.com", "Password123!"); + userRegisterService.signupUser(registerRequest); + userId = userService.readUser("test", "010-1234-5678").getUserId(); } @Test + @DisplayName("✅ RefreshToken 재발급 성공 시 새로운 토큰이 반환되어야 한다") void 회원가입_로그인_Refresh토큰_재발급_테스트() { - // 1. 회원가입 - userRegisterService.signupUser(registerRequest); - assertEquals("test@example.com", userService.readUser("test", "010-1234-5678").getUserEmail().getValue()); - - // 2. 로그인 → 토큰 확인 - LoginResponse loginResponse = loginService.login(loginRequest); + RefreshTokenResponse loginResponse = loginService.login(loginRequest); assertNotNull(loginResponse.accessToken()); assertNotNull(loginResponse.refreshToken()); - // 3. 토큰 재발급 RefreshTokenResponse refreshResponse = refreshTokenService.refreshToken(loginResponse.refreshToken()); assertNotNull(refreshResponse.accessToken()); assertNotNull(refreshResponse.refreshToken()); + + assertNotEquals(loginResponse.accessToken(), refreshResponse.accessToken()); + assertNotEquals(loginResponse.refreshToken(), refreshResponse.refreshToken()); + } + + @Test + @DisplayName("❌ 이미 사용된 RefreshToken을 재사용하면 401 예외가 발생해야 한다") + void 재사용된_RefreshToken_요청시_401_예외() { + RefreshTokenResponse loginResponse = loginService.login(loginRequest); + refreshTokenService.refreshToken(loginResponse.refreshToken()); + + assertThrows(CommonException.class, () -> { + refreshTokenService.refreshToken(loginResponse.refreshToken()); + }); + } + + @Test + @DisplayName("❌ 위조된 RefreshToken을 사용할 경우 401 예외가 발생해야 한다") + void 위조된_RefreshToken_사용시_401_예외() { + String fakeToken = "abc.def.ghi"; + + assertThrows(CommonException.class, () -> { + refreshTokenService.refreshToken(fakeToken); + }); + } + + @Test + @DisplayName("❌ RefreshToken 쿠키가 누락되면 401 예외가 발생해야 한다") + void RefreshToken_쿠키_누락시_예외() { + assertThrows(CommonException.class, () -> { + refreshTokenService.refreshToken(null); + }); + } } 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 74bd8630..04c70fa9 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java @@ -1,23 +1,20 @@ package bumblebee.xchangepass.domain.user.service; import bumblebee.xchangepass.config.RedisTestBase; +import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; +import bumblebee.xchangepass.domain.refresh.entity.RefreshToken; import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository; +import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService; 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.entity.User; +import bumblebee.xchangepass.domain.user.login.LoginService; +import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; +import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse; 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.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; import org.junit.jupiter.api.extension.ExtendWith; @@ -43,8 +40,6 @@ class UserLoginUnitTest extends RedisTestBase { @Mock private BCryptPasswordEncoder bCryptPasswordEncoder; - @Mock - private WalletServiceImpl walletService; @Mock private JwtProvider jwtProvider; @@ -106,7 +101,7 @@ void login_Success() { when(jwtProvider.generateAccessToken(userInfo.userId())).thenReturn("accessToken"); when(jwtProvider.generateRefreshToken(userInfo.userId())).thenReturn("refreshToken"); - LoginResponse response = loginService.login(loginRequest); + RefreshTokenResponse response = loginService.login(loginRequest); assertNotNull(response); assertEquals("accessToken", response.accessToken()); @@ -134,6 +129,7 @@ void login_Fail_InvalidPassword() { void refreshToken_Success() { when(jwtProvider.validateToken(refreshToken)).thenReturn(true); when(refreshTokenRepository.getUserIdFromRefreshToken(refreshToken)).thenReturn(1L); + when(refreshTokenRepository.getRefreshToken(1L)).thenReturn(refreshToken); when(jwtProvider.generateAccessToken(1L)).thenReturn("newAccessToken"); when(jwtProvider.generateRefreshToken(1L)).thenReturn("newRefreshToken"); diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java new file mode 100644 index 00000000..4003d6b0 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java @@ -0,0 +1,111 @@ +package bumblebee.xchangepass.domain.wallet.fraud; + +import bumblebee.xchangepass.config.TestUserInitializer; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudRuleEvaluator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +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 org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@Import(TestUserInitializer.class) +@SpringBootTest +class FraudRuleEvaluatorTest { + + @Autowired + private FraudRuleEvaluator fraudRuleEvaluator; + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("postgres") + .withPassword("postgres"); + + @Container + static GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void overrideRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", redisContainer::getHost); + registry.add("spring.redis.port", () -> redisContainer.getMappedPort(6379)); + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } + + @Test + void test_Lua_script_detects_no_fraud_on_single_transaction() { + Long userId = 1001L; + BigDecimal amount = new BigDecimal("10000"); + + boolean suspicious = fraudRuleEvaluator.isSuspicious(userId, amount); + System.out.println("🚨 감지 결과: " + suspicious); + + assertThat(suspicious).isFalse(); + } + + @Test + void test_Lua_script_detects_high_frequency_transactions() { + Long userId = 1002L; + BigDecimal amount = new BigDecimal("20000"); + + Boolean suspicious = false; + + // 10건 연속 트랜잭션으로 빈도 룰 유도 + for (int i = 0; i < 10; i++) { + if(suspicious) break; + suspicious = fraudRuleEvaluator.isSuspicious(userId, amount); + } + + assertThat(suspicious).isTrue(); + } + + @Test + void test_performance_of_lua_rule() { + Long userId = 1003L; + BigDecimal amount = new BigDecimal("5000"); + + long start = System.currentTimeMillis(); + + for (int i = 0; i < 1000; i++) { + fraudRuleEvaluator.isSuspicious(userId, amount); + } + + long end = System.currentTimeMillis(); + long duration = end - start; + + System.out.println("🔥 1000회 실행 시간: " + duration + "ms"); + assertThat(duration).isLessThan(3000); // 3초 내면 성능 OK + } + + @Test + void benchmarkLuaPerformance() { + Long userId = 1L; + BigDecimal amount = BigDecimal.valueOf(10000); + + // 성능 측정 시작 + long start = System.currentTimeMillis(); + + for (int i = 0; i < 1000; i++) { + fraudRuleEvaluator.isSuspicious(userId, amount); + } + + long end = System.currentTimeMillis(); + long elapsed = end - start; + + System.out.println("🔥 Lua 이상 거래 탐지 1000회 실행 시간: " + elapsed + "ms"); + } +} + diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java index b483cb8c..abdca6bd 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/SlackNotifierIntegrationTest.java @@ -14,12 +14,12 @@ class SlackNotifierIntegrationTest { private SlackNotifier slackNotifier; @Test - void sendSlackMessageTest() { + void failToSaveTransactionSlackMessageTest() { // given String message = "✅ 테스트 메시지입니다 (통합 테스트)"; // when - slackNotifier.send(message); + slackNotifier.failToSaveTransaction(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 index db37bd4c..ae4e96c8 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/WalletTransactionIntegrationTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/transaction/WalletTransactionIntegrationTest.java @@ -218,7 +218,7 @@ void retryAndDLQTest() throws InterruptedException, IOException { DeadLetterConsumer consumer = new DeadLetterConsumer(new RabbitTemplate(), slackNotifier); consumer.handleDeadLetter(message, 1L, xDeathHeader, channel); - verify(slackNotifier, times(1)).send(contains("DLQ 처리 실패")); + verify(slackNotifier, times(1)).failToSaveTransaction(contains("DLQ 처리 실패")); } @Test diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java new file mode 100644 index 00000000..6bc87d38 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java @@ -0,0 +1,132 @@ +package bumblebee.xchangepass.domain.wallet.wallet.service; + +import bumblebee.xchangepass.config.TestUserInitializer; +import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; +import bumblebee.xchangepass.domain.user.entity.Sex; +import bumblebee.xchangepass.domain.user.service.UserRegisterService; +import bumblebee.xchangepass.domain.user.service.UserService; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; +import bumblebee.xchangepass.domain.wallet.wallet.entity.ScheduledTransfer; +import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; +import bumblebee.xchangepass.domain.wallet.wallet.repository.ScheduledTransferRepository; +import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; +import bumblebee.xchangepass.domain.wallet.wallet.scheduler.ScheduledTransferService; +import bumblebee.xchangepass.domain.wallet.wallet.service.impl.WalletFacadeService; +import bumblebee.xchangepass.global.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +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 org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Testcontainers +@Import(TestUserInitializer.class) +public class ScheduledTransferServiceTest { + + @Autowired private ScheduledTransferService scheduledTransferService; + @Autowired private ScheduledTransferRepository scheduledTransferRepository; + @Autowired private WalletFacadeService walletFacadeService; + @Autowired private UserRegisterService userRegisterService; + @Autowired private UserService userService; + @Autowired + private WalletRepository walletRepository; + @Autowired + private WalletBalanceService balanceService; + + private Long senderId; + private Wallet testWallet1; + private Wallet testWallet2; + Currency krw = Currency.getInstance("KRW"); + + @Container + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("xcp_test") + .withUsername("postgres") + .withPassword("postgres"); + + @Container + static GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + + registry.add("spring.redis.host", redisContainer::getHost); + registry.add("spring.redis.port", () -> redisContainer.getMappedPort(6379)); + } + + @BeforeEach + void setUp() { + userRegisterService.signupUser(new UserRegisterRequest( + "sender@example.com", "Password123!", "보내는사람", "010-1111-1111", Sex.MALE, "1234" + )); + userRegisterService.signupUser(new UserRegisterRequest( + "receiver@example.com", "Password123!", "받는사람", "010-2222-2222", Sex.FEMALE, "1234" + )); + + var user1 = userService.readUser("보내는사람", "010-1111-1111"); + var user2 = userService.readUser("받는사람", "010-2222-2222"); + + senderId = user1.getUserId(); + + 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); + + // 🔥 충전 추가 + balanceService.chargeBalance( + balanceService.findBalanceWithLock(testWallet1.getWalletId(), krw), + new BigDecimal("10000") + ); + } + + @Test + void 예약송금_등록_및_처리_정상동작() { + WalletTransferRequest request = new WalletTransferRequest( + "받는사람", + "010-2222-2222", + BigDecimal.valueOf(1000), + Currency.getInstance("KRW"), + Currency.getInstance("KRW"), + LocalDateTime.now().minusSeconds(5), + WalletTransferType.SCHEDULE + ); + + walletFacadeService.transfer(senderId, request); + + List list = scheduledTransferRepository.findAll(); + assertThat(list).hasSize(1); + assertThat(list.get(0).getStatus().name()).isEqualTo("PENDING"); + + scheduledTransferService.processScheduledTransfers(); + + ScheduledTransfer after = scheduledTransferRepository.findById(list.get(0).getScheduledTransferId()).orElseThrow(); + assertThat(after.getStatus().name()).isEqualTo("SUCCESS"); + } +} diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java index 3f859040..2202d7ce 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletIntegrationServiceTest.java @@ -10,6 +10,7 @@ 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.entity.WalletTransferType; 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; @@ -145,7 +146,7 @@ private String generateRandomId() { void testTransferSuccess() { pessimisticLockWalletService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); @@ -158,7 +159,7 @@ void testTransferSuccess() { @Test @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); Exception exception = assertThrows(RuntimeException.class, () -> pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @@ -192,7 +193,7 @@ void concurrentTransfersToSameWallet() throws InterruptedException { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { @@ -255,7 +256,7 @@ void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception Future transferFuture = executorService.submit(() -> { try { System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); pessimisticLockWalletService.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); @@ -322,7 +323,7 @@ void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행) diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java index 497bd4fd..982bf380 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletNamedLockServiceTest.java @@ -3,17 +3,18 @@ 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.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudRuleEvaluator; 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.entity.WalletTransferType; 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; @@ -21,8 +22,8 @@ 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.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; @@ -45,6 +46,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; @SpringBootTest @Testcontainers @@ -66,6 +69,9 @@ class WalletNamedLockServiceTest { @Autowired private WalletTransactionRepository walletTransactionRepository; + @MockBean + private FraudRuleEvaluator fraudRuleEvaluator; + private final BigDecimal CHARGE_AMOUNT = new BigDecimal("10000.00"); private final BigDecimal TRANSFER_AMOUNT = new BigDecimal("5000.00"); private final Currency CURRENCY = Currency.getInstance("KRW"); @@ -152,7 +158,7 @@ private String generateRandomId() { void testTransferSuccess() { lockWalletFacade.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); lockWalletFacade.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); @@ -165,7 +171,7 @@ void testTransferSuccess() { @Test @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); Exception exception = assertThrows(RuntimeException.class, () -> lockWalletFacade.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @@ -191,6 +197,8 @@ void concurrentTransfersToSameWallet() throws InterruptedException { WalletBalance balance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); balanceService.chargeBalance(balance, CHARGE_AMOUNT.multiply(BigDecimal.valueOf(100))); + when(fraudRuleEvaluator.isSuspicious(any(), any())).thenReturn(false); + // 100명의 사용자가 동시에 송금하도록 설정 int concurrentUsers = 100; ExecutorService executorService = Executors.newFixedThreadPool(concurrentUsers); @@ -200,7 +208,7 @@ void concurrentTransfersToSameWallet() throws InterruptedException { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { @@ -263,7 +271,7 @@ void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception latch.await(); Thread.sleep(20); // 🔥 실행 순서를 조정 System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); lockWalletFacade.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); @@ -314,7 +322,7 @@ void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행) diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java index 2f0af398..5dccaa74 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/WalletRedissonServiceTest.java @@ -3,17 +3,17 @@ 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.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.entity.WalletTransferType; 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; @@ -21,7 +21,6 @@ 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; @@ -151,7 +150,7 @@ private String generateRandomId() { void testTransferSuccess() { lockService.charge(sender.getUserId(), new WalletInOutRequest(CHARGE_AMOUNT, CURRENCY, CURRENCY, null)); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); lockService.transfer(sender.getUserId(), transferRequest); WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY); @@ -164,7 +163,7 @@ void testTransferSuccess() { @Test @DisplayName("잔액이 부족할 때 송금이 실패한다") void testTransferFailureDueToInsufficientFunds() { - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); Exception exception = assertThrows(RuntimeException.class, () -> lockService.transfer(sender.getUserId(), transferRequest)); assertThat(exception.getMessage()).contains("충전 금액이 부족합니다."); } @@ -200,7 +199,7 @@ void concurrentTransfersToSameWallet() throws InterruptedException { Long senderId = senderWallet.getWalletId(); Long receiverId = receiverWallet.getWalletId(); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); for (int i = 0; i < concurrentUsers; i++) { executorService.submit(() -> { @@ -266,7 +265,7 @@ void eitherTransferOrWithdrawalFailsDuringConcurrentExecution() throws Exception latch.await(); Thread.sleep(20); // 🔥 실행 순서를 조정 System.out.println("🚀 [송금 시작]"); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); lockService.transfer(sender.getUserId(), transferRequest); isTransferFirst.set(true); @@ -317,7 +316,7 @@ void chargeSucceedsAndTransferFailsOnConcurrentRequest() throws Exception { CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now() ); - WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null); + WalletTransferRequest transferRequest = new WalletTransferRequest(receiver.getUserName().getValue(), receiver.getUserPhoneNumber().getValue(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, null, WalletTransferType.GENERAL); CountDownLatch latch = new CountDownLatch(1); // 🔥 1로 설정 (이체 먼저 실행)