From 6f9ccf6baf0a0e41f272df12d3c3ce11f652f8b8 Mon Sep 17 00:00:00 2001 From: usingjun Date: Thu, 3 Apr 2025 19:39:56 +0900 Subject: [PATCH 01/43] =?UTF-8?q?=F0=9F=94=A5=20Remove:=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/refresh/dto/RefreshTokenRequest.java | 5 ----- .../user/login/dto/response/LoginResponse.java | 16 ---------------- 2 files changed, 21 deletions(-) delete mode 100644 src/main/java/bumblebee/xchangepass/domain/refresh/dto/RefreshTokenRequest.java delete mode 100644 src/main/java/bumblebee/xchangepass/domain/user/login/dto/response/LoginResponse.java 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/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 { - } -} From f10fa0a57cb300a55ec1d8d4a8833ac15606dbde Mon Sep 17 00:00:00 2001 From: usingjun Date: Thu, 3 Apr 2025 19:46:00 +0900 Subject: [PATCH 02/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20token=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=A0=80=EC=9E=A5=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refresh/RefreshTokenController.java | 25 +++++++++++++---- .../domain/user/login/LoginController.java | 28 ++++++++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java b/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java index 2e7c6b7b..ed28168b 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenController.java @@ -1,6 +1,7 @@ package bumblebee.xchangepass.domain.refresh; -import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenRequest; +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; @@ -8,12 +9,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; @@ -42,9 +44,20 @@ public class RefreshTokenController { }) @ResponseStatus(HttpStatus.OK) @PostMapping("/token-refresh") - public void tokenRefresh(@RequestBody @Valid RefreshTokenRequest request) { - // token 재발급 - 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()) + .body(response); } } \ No newline at end of file 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..00d96c74 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,14 @@ 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 refreshCookie = loginService.saveRefreshToken(response); + + return ResponseEntity.ok() + .header("Set-Cookie", refreshCookie.toString()) + .body(response); } @Operation(summary = "로그아웃", description = "로그아웃합니다.") @@ -55,8 +65,18 @@ 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 expiredCookie = loginService.deleteRefreshToken(); + + return ResponseEntity.ok() + .header("Set-Cookie", expiredCookie.toString()) + .build(); } } From 53c468b932a2aefc9b9bb9fbc3fe83bfa41b5e4f Mon Sep 17 00:00:00 2001 From: usingjun Date: Thu, 3 Apr 2025 19:46:14 +0900 Subject: [PATCH 03/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20token=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=A0=80=EC=9E=A5=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/refresh/RefreshTokenService.java | 11 ++++++++ .../domain/user/login/LoginService.java | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java b/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java index a21a53eb..cd500795 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/RefreshTokenService.java @@ -4,6 +4,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 @@ -39,6 +40,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/login/LoginService.java b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginService.java index 97e70925..e33be243 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.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.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) { System.out.println("loginRequestDTO = " + request); // 사용자 정보 조회 UserLoginResponse userInfo = userService.readUserByUserEmail(request.userEmail()); @@ -41,12 +42,23 @@ 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 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 ")) { @@ -56,4 +68,14 @@ public void logout(String refreshToken) { Long userId = refreshTokenRepository.getUserIdFromRefreshToken(refreshToken); refreshTokenRepository.deleteRefreshToken(userId); } + + public ResponseCookie deleteRefreshToken() { + return ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) // 쿠키 만료 + .sameSite("Strict") + .build(); + } } From 494ed543a396f6cfaed4af64882a90fbc1f8fd92 Mon Sep 17 00:00:00 2001 From: usingjun Date: Thu, 3 Apr 2025 19:48:29 +0900 Subject: [PATCH 04/43] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Chore:=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/bumblebee/xchangepass/config/TestUserInitializer.java | 2 +- .../service/ExchangeTransactionServiceTest.java | 2 +- .../xchangepass/domain/user/service/UserLoginScenarioTest.java | 3 +-- .../xchangepass/domain/user/service/UserLoginUnitTest.java | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/test/java/bumblebee/xchangepass/config/TestUserInitializer.java b/src/test/java/bumblebee/xchangepass/config/TestUserInitializer.java index 4402179c..491391af 100644 --- a/src/test/java/bumblebee/xchangepass/config/TestUserInitializer.java +++ b/src/test/java/bumblebee/xchangepass/config/TestUserInitializer.java @@ -3,8 +3,8 @@ import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; import bumblebee.xchangepass.domain.user.entity.Sex; import bumblebee.xchangepass.domain.user.repository.UserRepository; +import bumblebee.xchangepass.domain.user.service.UserRegisterService; import bumblebee.xchangepass.domain.user.service.UserService; -import bumblebee.xchangepass.global.security.v1.login.UserRegisterService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.test.context.TestConfiguration; diff --git a/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java index fa4265ce..bffce45c 100644 --- a/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/exchangeTransaction/service/ExchangeTransactionServiceTest.java @@ -11,12 +11,12 @@ 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.user.service.UserRegisterService; import bumblebee.xchangepass.domain.wallet.entity.Wallet; import bumblebee.xchangepass.domain.wallet.service.WalletService; import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; import bumblebee.xchangepass.global.error.ErrorCode; -import bumblebee.xchangepass.global.security.v1.login.UserRegisterService; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java index 7e0edb3e..a14d0dd0 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java @@ -8,7 +8,6 @@ import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.user.login.LoginService; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; -import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; import bumblebee.xchangepass.domain.refresh.RefreshToken; import bumblebee.xchangepass.domain.refresh.RefreshTokenService; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; @@ -87,7 +86,7 @@ void setUp() { verify(userRepository, times(1)).save(any()); // 2️⃣ 로그인 → AccessToken, RefreshToken 발급 확인 - LoginResponse loginResponse = loginService.login(loginRequest); + RefreshTokenResponse loginResponse = loginService.login(loginRequest); assertNotNull(loginResponse); assertEquals("accessToken", loginResponse.accessToken()); diff --git a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java index 254860ee..944421ce 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java @@ -9,7 +9,6 @@ import bumblebee.xchangepass.global.security.jwt.JwtProvider; import bumblebee.xchangepass.domain.user.login.LoginService; import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest; -import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse; import bumblebee.xchangepass.domain.refresh.RefreshToken; import bumblebee.xchangepass.domain.refresh.RefreshTokenService; import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; @@ -90,7 +89,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()); From b6571c2677792b8ccff09f14729b8d99d1607f58 Mon Sep 17 00:00:00 2001 From: usingjun Date: Sun, 6 Apr 2025 22:05:39 +0900 Subject: [PATCH 05/43] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Chore:=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 48838540..7e39f2ca 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,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') { From 8b40fe5766ce1645845abbe4689c12ac54da6517 Mon Sep 17 00:00:00 2001 From: usingjun Date: Sun, 6 Apr 2025 22:08:25 +0900 Subject: [PATCH 06/43] =?UTF-8?q?=F0=9F=94=A8=20Build:=20docker-compose=20?= =?UTF-8?q?aof=20&=20rdb=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b019fc84..bcf0651d 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: From 4c23f33faa079a55fad375e7c986b4f6bf2df7e2 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 01:02:56 +0900 Subject: [PATCH 07/43] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Chore:=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserLoginScenarioTest.java | 5 ----- .../domain/user/service/UserLoginUnitTest.java | 18 +++++++----------- 2 files changed, 7 insertions(+), 16 deletions(-) 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 401b48d0..1f7564c2 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginScenarioTest.java @@ -6,7 +6,6 @@ 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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -69,10 +68,6 @@ void setUp() { userRegisterService.signupUser(registerRequest); assertEquals("test@example.com", userService.readUser("test", "010-1234-5678").getUserEmail().getValue()); - // 2. 로그인 → 토큰 확인 - LoginResponse loginResponse = loginService.login(loginRequest); - assertNotNull(loginResponse.accessToken()); - assertNotNull(loginResponse.refreshToken()); // 2️⃣ 로그인 → AccessToken, RefreshToken 발급 확인 RefreshTokenResponse loginResponse = loginService.login(loginRequest); 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 77e595bf..16b022f3 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java @@ -1,25 +1,21 @@ 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.RefreshToken; -import bumblebee.xchangepass.domain.refresh.RefreshTokenService; -import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; From ac134d53bda013dffad3f17e3558ee7bdd90b12e Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 01:03:56 +0900 Subject: [PATCH 08/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20accessTo?= =?UTF-8?q?ken=20=EC=BF=A0=ED=82=A4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/login/LoginController.java | 8 +++- .../domain/user/login/LoginService.java | 23 ++++++++++- .../global/security/jwt/JwtAuthFilter.java | 41 +++++++++++++------ 3 files changed, 56 insertions(+), 16 deletions(-) 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 00d96c74..2f344742 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java @@ -47,9 +47,11 @@ public class LoginController { 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()) .body(response); } @@ -73,10 +75,12 @@ public ResponseEntity logout(@RequestHeader("Authorization // Refresh Token 삭제 loginService.logout(refreshToken); - ResponseCookie expiredCookie = loginService.deleteRefreshToken(); + ResponseCookie expiredRefresh = loginService.deleteRefreshToken(); + ResponseCookie expiredAccess = loginService.deleteAccessToken(); return ResponseEntity.ok() - .header("Set-Cookie", expiredCookie.toString()) + .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 47acd46c..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,12 +1,12 @@ 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.refresh.RefreshTokenRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -47,6 +47,16 @@ public RefreshTokenResponse login(final LoginRequest request) { .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()) @@ -68,6 +78,17 @@ public void logout(String 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) 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 fddcc8f8..caff54c6 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 생성 * From bf21c863870e050bb3b7487739a8f0b0d28605ed Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 01:14:12 +0900 Subject: [PATCH 09/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20RefreshT?= =?UTF-8?q?oken=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EA=B0=90=EC=A7=80=20+=20R?= =?UTF-8?q?otation=20=EC=A0=84=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refresh/controller/RefreshTokenController.java | 1 - .../refresh/repository/RefreshTokenRepository.java | 5 +++++ .../domain/refresh/service/RefreshTokenService.java | 12 ++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) 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 f40b474c..55c1f208 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java @@ -1,6 +1,5 @@ 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; 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..e89b91fe 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,9 @@ public boolean isValidRefreshToken(Long userId, String refreshToken) { String storedToken = (String) jsonRedisTemplate.opsForValue().get("refresh_token:" + userId); return storedToken != null && storedToken.equals(refreshToken); } + + 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 a420364e..648e4c6e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/service/RefreshTokenService.java @@ -28,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); From 5d40a4ee4e40e130cf0d7acf278758d295d46533 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 10:01:57 +0900 Subject: [PATCH 10/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20RefreshT?= =?UTF-8?q?oken=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EA=B0=90=EC=A7=80=20+=20R?= =?UTF-8?q?otation=20=EC=A0=84=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xchangepass/global/security/jwt/JwtProvider.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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) From 0020ee09dcf2cfe77d3b60a062129637a9cab81a Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 10:02:15 +0900 Subject: [PATCH 11/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20controll?= =?UTF-8?q?er=20body=EB=A1=9C=20=ED=86=A0=ED=81=B0=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/refresh/controller/RefreshTokenController.java | 2 +- .../xchangepass/domain/user/login/LoginController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 55c1f208..a8b27a1c 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/controller/RefreshTokenController.java @@ -58,7 +58,7 @@ public ResponseEntity tokenRefresh(@CookieValue(value = "r return ResponseEntity.ok() .header("Set-Cookie", refreshCookie.toString()) - .body(response); + .build(); } } \ No newline at end of file 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 2f344742..c62a8f5d 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/login/LoginController.java @@ -53,7 +53,7 @@ public ResponseEntity memberLogin(@RequestBody @Valid Logi return ResponseEntity.ok() .header("Set-Cookie", accessCookie.toString()) .header("Set-Cookie", refreshCookie.toString()) - .body(response); + .build(); } @Operation(summary = "로그아웃", description = "로그아웃합니다.") From e33e2288bebf070a6f4f0330020aeb8bdfb631b9 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 10:02:27 +0900 Subject: [PATCH 12/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20RefreshT?= =?UTF-8?q?oken=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EA=B0=90=EC=A7=80=20+=20R?= =?UTF-8?q?otation=20=EC=A0=84=EB=9E=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserLoginScenarioTest.java | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) 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 1f7564c2..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,7 +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.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; @@ -17,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") @@ -36,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") @@ -53,31 +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️⃣ 로그인 → AccessToken, RefreshToken 발급 확인 RefreshTokenResponse loginResponse = loginService.login(loginRequest); + assertNotNull(loginResponse.accessToken()); + assertNotNull(loginResponse.refreshToken()); - assertNotNull(loginResponse); - assertEquals("accessToken", loginResponse.accessToken()); - assertEquals("refreshToken", 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); + }); + } } From c46f601d80f1ef59f388e212f39770254f712e84 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:17:23 +0900 Subject: [PATCH 13/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=86=A1=EA=B8=88=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=8B=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/lock/NamedLockWalletService.java | 92 ++++++++++--------- .../lock/PessimisticLockWalletService.java | 61 ++++++------ .../redisson/RedissonLockWalletService.java | 77 +++++++++------- 3 files changed, 124 insertions(+), 106 deletions(-) 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..262ed7f5 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,22 +3,21 @@ import bumblebee.xchangepass.domain.exchangeRate.service.ExchangeService; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.service.UserService; +import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance; +import bumblebee.xchangepass.domain.wallet.balance.service.WalletBalanceService; import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletInOutRequest; import bumblebee.xchangepass.domain.wallet.wallet.dto.request.WalletTransferRequest; import bumblebee.xchangepass.domain.wallet.wallet.dto.response.WalletBalanceResponse; import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; 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.scheduler.ScheduledTransferService; 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; @@ -32,6 +31,7 @@ public class NamedLockWalletService implements WalletService { private final WalletRepository walletRepository; private final WalletBalanceService balanceService; + private final ScheduledTransferService scheduledTransferService; private final NamedLockRepository namedLockRepository; private final ExchangeService exchangeService; private final UserService userService; @@ -88,7 +88,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); @@ -101,44 +101,48 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional public void transfer(Long senderId, WalletTransferRequest request) { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - - Wallet fromWallet = walletRepository.findByUserId(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet toWallet = walletRepository.findById(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - - // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 - long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); - long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); - - namedLockRepository.getLock(smallerId); - namedLockRepository.getLock(largerId); - - try { - WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); - - BigDecimal transferAmount = request.transferAmount(); - - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } - - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } finally { - Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); - if (!largeUnlockSuccess) { - log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", largerId); - } - - Boolean smallUnlockSuccess = namedLockRepository.releaseLock(smallerId); - if (!smallUnlockSuccess) { - log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); + if (request.transferType() == WalletTransferType.SCHEDULE) { + scheduledTransferService.saveSchedule(senderId, request); + } else { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + + Wallet fromWallet = walletRepository.findByUserId(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet toWallet = walletRepository.findById(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 + long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); + long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); + + namedLockRepository.getLock(smallerId); + namedLockRepository.getLock(largerId); + + try { + WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); + + BigDecimal transferAmount = request.transferAmount(); + + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } finally { + Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); + if (!largeUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", largerId); + } + + Boolean smallUnlockSuccess = namedLockRepository.releaseLock(smallerId); + if (!smallUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); + } } } } 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..e3665bcb 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 @@ -9,7 +9,9 @@ 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.entity.WalletTransferType; 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 +33,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; @@ -98,41 +101,45 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void transfer(Long senderId, WalletTransferRequest request) { - try { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + if (request.transferType() == WalletTransferType.SCHEDULE) { + scheduledTransferService.saveSchedule(senderId, request); + } else { + try { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - WalletBalance fromBalance = balanceService.findBalanceWithLock(senderWallet.getWalletId(), request.fromCurrency()); - if (fromBalance == null) { - throw ErrorCode.BALANCE_NOT_FOUND.commonException(); - } + WalletBalance fromBalance = balanceService.findBalanceWithLock(senderWallet.getWalletId(), request.fromCurrency()); + if (fromBalance == null) { + throw ErrorCode.BALANCE_NOT_FOUND.commonException(); + } - if (!balanceService.checkBalance(receiverWallet.getWalletId(), request.toCurrency())) { - Wallet wallet = walletRepository.findById(receiverWallet.getWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + if (!balanceService.checkBalance(receiverWallet.getWalletId(), request.toCurrency())) { + Wallet wallet = walletRepository.findById(receiverWallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - balanceService.createBalance(wallet, request.toCurrency()); - } + balanceService.createBalance(wallet, request.toCurrency()); + } - WalletBalance toBalance = balanceService.findBalanceWithLock(receiverWallet.getWalletId(), request.toCurrency()); + WalletBalance toBalance = balanceService.findBalanceWithLock(receiverWallet.getWalletId(), request.toCurrency()); - BigDecimal transferAmount = request.transferAmount(); - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } + BigDecimal transferAmount = request.transferAmount(); + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { - log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", senderId, e.getMessage()); - throw ErrorCode.LOCK_TIME_OUT.commonException(); + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", senderId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } } } 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..eb5c951b 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 @@ -9,7 +9,9 @@ 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.entity.WalletTransferType; 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 +33,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; @@ -103,49 +106,53 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void transfer(Long senderId, WalletTransferRequest request) { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + if (request.transferType() == WalletTransferType.SCHEDULE) { + scheduledTransferService.saveSchedule(senderId, request); + } else { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - String senderLockKey = "senderWallet:" + senderWallet.getWalletId(); - String receiverLockKey = "senderWallet:" + receiverWallet.getWalletId(); + String senderLockKey = "senderWallet:" + senderWallet.getWalletId(); + String receiverLockKey = "senderWallet:" + receiverWallet.getWalletId(); - RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); - RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); - RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); + RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); + RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); + RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); - boolean acquired = false; - try { - acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); - if (!acquired) { - throw ErrorCode.LOCK_TIME_OUT.commonException(); - } + boolean acquired = false; + try { + acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); + if (!acquired) { + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } - WalletBalance fromBalance = balanceService.findBalance(senderWallet.getWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(receiverWallet.getWalletId(), request.toCurrency()); + WalletBalance fromBalance = balanceService.findBalance(senderWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(receiverWallet.getWalletId(), request.toCurrency()); - BigDecimal transferAmount = request.transferAmount(); - if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } + BigDecimal transferAmount = request.transferAmount(); + if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw ErrorCode.THREAD_INTERRUPTED.commonException(); - } finally { - if (acquired) { - try { - multiLock.unlock(); // ✅ unlock 예외 처리 추가 - } catch (IllegalMonitorStateException e) { - log.error("⚠️ [MultiLock 해제 실패] senderId: {}, receiverId: {}", senderId, receiverWallet.getWalletId(), e); + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ErrorCode.THREAD_INTERRUPTED.commonException(); + } finally { + if (acquired) { + try { + multiLock.unlock(); // ✅ unlock 예외 처리 추가 + } catch (IllegalMonitorStateException e) { + log.error("⚠️ [MultiLock 해제 실패] senderId: {}, receiverId: {}", senderId, receiverWallet.getWalletId(), e); + } } } } From f52f1b168de27ee53a49ffd589346219bc0749a7 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:19:00 +0900 Subject: [PATCH 14/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=86=A1=EA=B8=88=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WalletIntegrationServiceTest.java | 11 ++++++----- .../service/WalletNamedLockServiceTest.java | 19 +++++++++---------- .../service/WalletRedissonServiceTest.java | 19 +++++++++---------- 3 files changed, 24 insertions(+), 25 deletions(-) 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..4f581208 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,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.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,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; @@ -152,7 +151,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 +164,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("충전 금액이 부족합니다."); } @@ -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(() -> { @@ -263,7 +262,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 +313,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로 설정 (이체 먼저 실행) From 1f57e96746f9fac49226fafdd1165ec9c6320dd5 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:19:28 +0900 Subject: [PATCH 15/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=86=A1=EA=B8=88=20controller=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/controller/WalletController.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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..3c2b5285 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 @@ -19,7 +19,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.*; @@ -116,6 +115,29 @@ public void transfer(@RequestBody @Valid WalletTransferRequest request, walletServiceFactory.getService("namedLock").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) { + walletServiceFactory.getService("namedLock").transfer(user.getUserId(), request); + } + @Operation(summary = "잔액 조회", description = "잔액을 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "잔액 조회 성공", content = @Content(mediaType = "application/json")), From 8507afba4246b4b93cf9275b26555d9da01e9cdf Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:20:26 +0900 Subject: [PATCH 16/43] =?UTF-8?q?:sparkles:=20Feat:=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=86=A1=EA=B8=88=20=ED=83=80=EC=9E=85,=20=EC=83=81?= =?UTF-8?q?=ED=83=9C,=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/entity/ScheduledTransfer.java | 54 +++++++++++++++++++ .../wallet/entity/WalletTransferStatus.java | 7 +++ .../wallet/entity/WalletTransferType.java | 6 +++ 3 files changed, 67 insertions(+) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/ScheduledTransfer.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferStatus.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/entity/WalletTransferType.java 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 // 예약 +} From b1c2267cd43139267386703baa8c79648cc05045 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:20:43 +0900 Subject: [PATCH 17/43] =?UTF-8?q?:sparkles:=20Feat:=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=86=A1=EA=B8=88=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ScheduledTransferRepository.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/repository/ScheduledTransferRepository.java 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); +} From 8f1d222882271d61c3bf0d8e6778178e63eb1b9c Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:21:04 +0900 Subject: [PATCH 18/43] =?UTF-8?q?:sparkles:=20Feat:=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=86=A1=EA=B8=88=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/ScheduledTransferService.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java 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..8788c695 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java @@ -0,0 +1,63 @@ +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.repository.ScheduledTransferRepository; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; +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.*; + +@Slf4j +@Service +public class ScheduledTransferService { + @Autowired + ScheduledTransferRepository repository; + + @Autowired + WalletServiceFactory walletServiceFactory; + + @Autowired + ScheduledTransferRepository scheduledTransferRepository; + + @Scheduled(fixedDelay = 60000) // 1분마다 실행 + @Transactional + public void processScheduledTransfers() { + LocalDateTime now = LocalDateTime.now(); + List pendingList = + repository.findByStatusAndScheduledAtBefore(PENDING, now); + + for (ScheduledTransfer scheduled : pendingList) { + try { + WalletTransferRequest request = new WalletTransferRequest( + scheduled.getReceiverName(), + scheduled.getReceiverPhoneNumber(), + scheduled.getTransferAmount(), + scheduled.getFromCurrency(), + scheduled.getToCurrency(), + scheduled.getScheduledAt(), + WalletTransferType.GENERAL + ); + walletServiceFactory.getService("namedLock").transfer(scheduled.getSenderId(), request); + scheduled.markSuccess(); + } 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); + } +} From 6b755c41397f42cd769734989741dcdc9301af6f Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 20:21:44 +0900 Subject: [PATCH 19/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=86=A1=EA=B8=88=20dto=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/wallet/dto/request/WalletTransferRequest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..21d2c20e 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,5 +1,6 @@ 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; @@ -34,6 +35,9 @@ public record WalletTransferRequest( Currency toCurrency, @Schema(description = "송금 날짜", example = "2024-02-20T12:34:56") - LocalDateTime transferDatetime + LocalDateTime transferDatetime, + + @Schema(description = "송금 타입", example = "GENERAL") + WalletTransferType transferType ) { } From 3a03bd4f81d444412a9b25a8d67b8af16d23f2bf Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:45:19 +0900 Subject: [PATCH 20/43] =?UTF-8?q?=F0=9F=94=A5=20Remove:=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xchangepass/domain/wallet/dto/request/WalletInOutRequest.java | 0 .../xchangepass/domain/wallet/service/WalletService.java | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/dto/request/WalletInOutRequest.java delete mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java 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/service/WalletService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/service/WalletService.java deleted file mode 100644 index e69de29b..00000000 From a36e277f7e99520ae2f5d4098b59e16d60243ab4 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:45:54 +0900 Subject: [PATCH 21/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20SlackNot?= =?UTF-8?q?ifier=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/transaction/SlackNotifierIntegrationTest.java | 4 ++-- .../wallet/transaction/WalletTransactionIntegrationTest.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From e3bd0b6391b25fe1d163b1f0aab57a102bd6ae28 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:46:07 +0900 Subject: [PATCH 22/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20SlackNot?= =?UTF-8?q?ifier=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transaction/consumer/SlackNotifier.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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..d87b32b1 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,15 @@ package bumblebee.xchangepass.domain.wallet.transaction.consumer; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectEvent; +import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectionService; 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 +26,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 +51,16 @@ public void send(String message) { log.error("Slack 전송 실패", e); } } + + public void notifyFraud(FraudDetectEvent event) { + Map body=Map.of( + "text", String.format("🚨 이상 거래 감지!\n사용자 ID: %d\n금액: %s", event.userId(), event.amount()) + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity request = new HttpEntity<>(body, headers); + restTemplate.postForEntity(webhookUrl, request, String.class); + } } \ No newline at end of file From 2cf67f48a01d141e46d28f9bbcd90e88fd443721 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:46:23 +0900 Subject: [PATCH 23/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20SlackNot?= =?UTF-8?q?ifier=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/wallet/transaction/consumer/DeadLetterConsumer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f23cb1d9e7c820a85941b7573d1f364e5d49a296 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:47:12 +0900 Subject: [PATCH 24/43] =?UTF-8?q?:sparkles:=20Feat:=20redis=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B4=EC=83=81=EA=B0=90=EC=A7=80=20Service=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fraud/service/FraudDetectionService.java | 24 ++++++++ .../fraud/service/FraudRuleEvaluator.java | 61 +++++++++++++++++++ .../service/RedisFraudStorageService.java | 34 +++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/RedisFraudStorageService.java 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..6becfa1f --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java @@ -0,0 +1,24 @@ +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) { + slackNotifier.notifyFraud(event); + } + } +} 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..f0e4b7ab --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java @@ -0,0 +1,61 @@ +package bumblebee.xchangepass.domain.wallet.fraud.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FraudRuleEvaluator { + + private final RedisFraudStorageService redisFraudStorage; + + public boolean isSuspicious(Long userId, BigDecimal latestAmount) { + List history = redisFraudStorage.getRecentTransactions(userId); + + return isOverTotalLimit(history, latestAmount) + || isTooFrequent(history) + || isRepeatedAmount(history, latestAmount) + || isNightTransaction(); + } + + // 🔸 룰 1: 10분 내 누적 금액 + private boolean isOverTotalLimit(List history, BigDecimal latestAmount) { + BigDecimal sum = history.stream() + .map(FraudRecord::amount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + return sum.add(latestAmount).compareTo(new BigDecimal("500000")) > 0; + } + + // 🔸 룰 2: 최근 5분간 5건 이상 + private boolean isTooFrequent(List history) { + LocalDateTime fiveMinAgo = LocalDateTime.now().minusMinutes(5); + long recentCount = history.stream() + .filter(r -> r.timestamp().isAfter(fiveMinAgo)) + .count(); + return recentCount >= 5; + } + + // 🔸 룰 3: 최근 거래 3건이 동일 금액 + private boolean isRepeatedAmount(List history, BigDecimal latestAmount) { + List recentAmounts = history.stream() + .map(FraudRecord::amount) + .sorted(Comparator.reverseOrder()) + .limit(3) + .toList(); + + return recentAmounts.size() == 3 && recentAmounts.stream().allMatch(latestAmount::equals); + } + + // 🔸 룰 4: 심야 시간대 거래 (02:30~03:30) + private boolean isNightTransaction() { + 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(); + } +} From 409ad86db757ea85a9a6a7e5e70f41c54ab4dbe7 Mon Sep 17 00:00:00 2001 From: usingjun Date: Mon, 7 Apr 2025 22:47:23 +0900 Subject: [PATCH 25/43] =?UTF-8?q?:sparkles:=20Feat:=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/fraud/service/FraudDetectEvent.java | 11 +++++++++++ .../wallet/fraud/service/FraudRecord.java | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectEvent.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRecord.java 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/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 From eece890587c5e02be425d581a741b57339514525 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 02:24:10 +0900 Subject: [PATCH 26/43] =?UTF-8?q?:sparkles:=20Feat:=20lua=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/lua/fraud_check.lua | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/main/resources/lua/fraud_check.lua 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" From e1fc08a7f5b5674d137bd9c887df4154edb5626d Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 02:32:30 +0900 Subject: [PATCH 27/43] =?UTF-8?q?:sparkles:=20Feat:=20lua=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EC=83=81?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fraud/service/FraudRuleEvaluator.java | 73 ++++++++++--------- 1 file changed, 38 insertions(+), 35 deletions(-) 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 index f0e4b7ab..117bf7bd 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java @@ -1,60 +1,63 @@ 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.LocalDateTime; import java.time.LocalTime; -import java.util.Comparator; import java.util.List; @Service @RequiredArgsConstructor public class FraudRuleEvaluator { - private final RedisFraudStorageService redisFraudStorage; + private final RedisTemplate redisTemplate; - public boolean isSuspicious(Long userId, BigDecimal latestAmount) { - List history = redisFraudStorage.getRecentTransactions(userId); + private String lastDetectedReason; - return isOverTotalLimit(history, latestAmount) - || isTooFrequent(history) - || isRepeatedAmount(history, latestAmount) - || isNightTransaction(); - } + public Boolean isSuspicious(Long userId, BigDecimal amount) { + this.lastDetectedReason = null; - // 🔸 룰 1: 10분 내 누적 금액 - private boolean isOverTotalLimit(List history, BigDecimal latestAmount) { - BigDecimal sum = history.stream() - .map(FraudRecord::amount) - .reduce(BigDecimal.ZERO, BigDecimal::add); + Long nowEpoch = System.currentTimeMillis() / 1000; + Boolean isNight = isNightTime(); - return sum.add(latestAmount).compareTo(new BigDecimal("500000")) > 0; - } + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/fraud_check.lua"))); + script.setResultType(Boolean.class); - // 🔸 룰 2: 최근 5분간 5건 이상 - private boolean isTooFrequent(List history) { - LocalDateTime fiveMinAgo = LocalDateTime.now().minusMinutes(5); - long recentCount = history.stream() - .filter(r -> r.timestamp().isAfter(fiveMinAgo)) - .count(); - return recentCount >= 5; - } + Boolean result = redisTemplate.execute( + script, + List.of("fraud:user:" + userId), + amount.toString(), + String.valueOf(nowEpoch), + "500000", // 누적 한도 + "5", // 5분 내 최대 거래 수 + "true", // 기록 저장 여부 + isNight ? "1" : "0" + ); - // 🔸 룰 3: 최근 거래 3건이 동일 금액 - private boolean isRepeatedAmount(List history, BigDecimal latestAmount) { - List recentAmounts = history.stream() - .map(FraudRecord::amount) - .sorted(Comparator.reverseOrder()) - .limit(3) - .toList(); + if ("1".equals(result)) { + lastDetectedReason = "누적 금액 초과"; + } else if ("2".equals(result)) { + lastDetectedReason = "5분 내 거래 횟수 초과"; + } else if ("3".equals(result)) { + lastDetectedReason = "동일 금액 반복"; + } else if ("4".equals(result)) { + lastDetectedReason = "심야 시간대 거래"; + } + + return Boolean.TRUE.equals(result); + } - return recentAmounts.size() == 3 && recentAmounts.stream().allMatch(latestAmount::equals); + public String getLastDetectedReason() { + return lastDetectedReason != null ? lastDetectedReason : "알 수 없음"; } - // 🔸 룰 4: 심야 시간대 거래 (02:30~03:30) - private boolean isNightTransaction() { + private Boolean isNightTime() { LocalTime now = LocalTime.now(); return now.isAfter(LocalTime.of(2, 30)) && now.isBefore(LocalTime.of(3, 30)); } From c04202056c2939afd9c71f29684e02d8ba0de868 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 09:23:32 +0900 Subject: [PATCH 28/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20FraudDet?= =?UTF-8?q?ectEvent=20=EA=B0=92=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balance/service/WalletBalanceService.java | 11 +++++ .../fraud/service/FraudDetectionService.java | 8 +++- .../transaction/consumer/SlackNotifier.java | 43 +++++++++++++++---- 3 files changed, 53 insertions(+), 9 deletions(-) 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..d0dbdf80 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; @@ -20,6 +23,7 @@ public class WalletBalanceService { private final WalletBalanceRepository balanceRepository; private final WalletTransactionService transactionService; + private final FraudDetectionService fraudDetectionService; public void createBalance(Wallet wallet, Currency currency) { if (balanceRepository.existsByCurrency(wallet.getWalletId(), currency)) { @@ -80,6 +84,13 @@ public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, balanceRepository.save(fromBalance); balanceRepository.save(toBalance); + fraudDetectionService.detect(new FraudDetectEvent( + fromBalance.getWallet().getUser().getUserId(), + amount, + LocalDateTime.now(), + null + )); + transactionService.saveTransaction(fromBalance.getWallet().getWalletId(), toBalance.getWallet().getWalletId(), amount, fromBalance.getCurrency(), toBalance.getCurrency(), WalletTransactionType.TRANSFER); } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java index 6becfa1f..550abd92 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudDetectionService.java @@ -18,7 +18,13 @@ public void detect(FraudDetectEvent event) { boolean isFraud = ruleEvaluator.isSuspicious(event.userId(), event.amount()); if (isFraud) { - slackNotifier.notifyFraud(event); + 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/transaction/consumer/SlackNotifier.java b/src/main/java/bumblebee/xchangepass/domain/wallet/transaction/consumer/SlackNotifier.java index d87b32b1..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,7 +1,6 @@ package bumblebee.xchangepass.domain.wallet.transaction.consumer; import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectEvent; -import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -53,14 +52,42 @@ public void failToSaveTransaction(String message) { } public void notifyFraud(FraudDetectEvent event) { - Map body=Map.of( - "text", String.format("🚨 이상 거래 감지!\n사용자 ID: %d\n금액: %s", event.userId(), event.amount()) - ); + Map payload = new HashMap<>(); + payload.put("text", "🚨 이상 거래 감지"); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); + 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: 이상 거래는 자동으로 기록되고 있습니다.") + ) + ) + )); - HttpEntity request = new HttpEntity<>(body, headers); - restTemplate.postForEntity(webhookUrl, request, String.class); + 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 From dfde3ecb0a13fc5c9b8111882ca8b742fa79e513 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 10:55:18 +0900 Subject: [PATCH 29/43] =?UTF-8?q?=F0=9F=94=A7=20Fix:=20=EC=88=9C=ED=99=98?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balance/service/WalletBalanceService.java | 10 +-- .../dto/request/WalletTransferRequest.java | 2 + .../scheduler/ScheduledTransferExecutor.java | 29 +++++++ .../scheduler/ScheduledTransferService.java | 25 ++---- .../service/impl/WalletFacadeService.java | 26 ++++++ .../impl/lock/NamedLockWalletService.java | 82 +++++++++---------- 6 files changed, 107 insertions(+), 67 deletions(-) create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferExecutor.java create mode 100644 src/main/java/bumblebee/xchangepass/domain/wallet/wallet/service/impl/WalletFacadeService.java 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 d0dbdf80..a057054a 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 @@ -79,11 +79,6 @@ public void withdrawBalance(WalletBalance balance, BigDecimal amount) { } public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, BigDecimal amount) { - fromBalance.subtractBalance(amount); - toBalance.addBalance(amount); - balanceRepository.save(fromBalance); - balanceRepository.save(toBalance); - fraudDetectionService.detect(new FraudDetectEvent( fromBalance.getWallet().getUser().getUserId(), amount, @@ -91,6 +86,11 @@ public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, null )); + fromBalance.subtractBalance(amount); + toBalance.addBalance(amount); + balanceRepository.save(fromBalance); + balanceRepository.save(toBalance); + transactionService.saveTransaction(fromBalance.getWallet().getWalletId(), toBalance.getWallet().getWalletId(), amount, fromBalance.getCurrency(), toBalance.getCurrency(), WalletTransactionType.TRANSFER); } diff --git a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletTransferRequest.java b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/dto/request/WalletTransferRequest.java index 21d2c20e..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 @@ -5,6 +5,7 @@ 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; @@ -35,6 +36,7 @@ public record WalletTransferRequest( Currency toCurrency, @Schema(description = "송금 날짜", example = "2024-02-20T12:34:56") + @FutureOrPresent LocalDateTime transferDatetime, @Schema(description = "송금 타입", example = "GENERAL") 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 index 8788c695..3fd8d0d1 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java @@ -4,7 +4,6 @@ import bumblebee.xchangepass.domain.wallet.wallet.entity.ScheduledTransfer; import bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferType; import bumblebee.xchangepass.domain.wallet.wallet.repository.ScheduledTransferRepository; -import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -15,7 +14,7 @@ import java.time.LocalDateTime; import java.util.List; -import static bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferStatus.*; +import static bumblebee.xchangepass.domain.wallet.wallet.entity.WalletTransferStatus.PENDING; @Slf4j @Service @@ -24,31 +23,19 @@ public class ScheduledTransferService { ScheduledTransferRepository repository; @Autowired - WalletServiceFactory walletServiceFactory; + ScheduledTransferRepository scheduledTransferRepository; @Autowired - ScheduledTransferRepository scheduledTransferRepository; + ScheduledTransferExecutor executor; @Scheduled(fixedDelay = 60000) // 1분마다 실행 @Transactional public void processScheduledTransfers() { - LocalDateTime now = LocalDateTime.now(); - List pendingList = - repository.findByStatusAndScheduledAtBefore(PENDING, now); + List pending = repository.findByStatusAndScheduledAtBefore(PENDING, LocalDateTime.now()); - for (ScheduledTransfer scheduled : pendingList) { + for (ScheduledTransfer scheduled : pending) { try { - WalletTransferRequest request = new WalletTransferRequest( - scheduled.getReceiverName(), - scheduled.getReceiverPhoneNumber(), - scheduled.getTransferAmount(), - scheduled.getFromCurrency(), - scheduled.getToCurrency(), - scheduled.getScheduledAt(), - WalletTransferType.GENERAL - ); - walletServiceFactory.getService("namedLock").transfer(scheduled.getSenderId(), request); - scheduled.markSuccess(); + executor.execute(scheduled); } catch (Exception e) { scheduled.markFailed(); log.error("예약 송금 실패: {}", scheduled.getScheduledTransferId(), e); 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 262ed7f5..8064df29 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 @@ -31,7 +31,6 @@ public class NamedLockWalletService implements WalletService { private final WalletRepository walletRepository; private final WalletBalanceService balanceService; - private final ScheduledTransferService scheduledTransferService; private final NamedLockRepository namedLockRepository; private final ExchangeService exchangeService; private final UserService userService; @@ -101,49 +100,46 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional public void transfer(Long senderId, WalletTransferRequest request) { - if (request.transferType() == WalletTransferType.SCHEDULE) { - scheduledTransferService.saveSchedule(senderId, request); - } else { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - - Wallet fromWallet = walletRepository.findByUserId(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet toWallet = walletRepository.findById(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - - // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 - long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); - long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); - - namedLockRepository.getLock(smallerId); - namedLockRepository.getLock(largerId); - - try { - WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); - - BigDecimal transferAmount = request.transferAmount(); - - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } - - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } - - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } finally { - Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); - if (!largeUnlockSuccess) { - log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", largerId); - } - - Boolean smallUnlockSuccess = namedLockRepository.releaseLock(smallerId); - if (!smallUnlockSuccess) { - log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); - } + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + + Wallet fromWallet = walletRepository.findByUserId(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet toWallet = walletRepository.findById(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + + // Deadlock 방지를 위해 ID 크기 순으로 Advisory Lock을 획득 + long smallerId = Math.min(fromWallet.getWalletId(), toWallet.getWalletId()); + long largerId = Math.max(fromWallet.getWalletId(), toWallet.getWalletId()); + + namedLockRepository.getLock(smallerId); + namedLockRepository.getLock(largerId); + + try { + WalletBalance fromBalance = balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency()); + + BigDecimal transferAmount = request.transferAmount(); + + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); } + + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } finally { + Boolean largeUnlockSuccess = namedLockRepository.releaseLock(largerId); + if (!largeUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", largerId); + } + + Boolean smallUnlockSuccess = namedLockRepository.releaseLock(smallerId); + if (!smallUnlockSuccess) { + log.error("⚠️ [Named Lock 해제 실패] Wallet ID: {}", smallerId); + } + } } From 5594ef45e74f9a216a1fa8ed379dc0cee4655fcb Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 10:55:41 +0900 Subject: [PATCH 30/43] =?UTF-8?q?:sparkles:=20Feat:=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=86=A1=EA=B8=88,=20=EC=9D=B4=EC=83=81=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/fraud/FraudRuleEvaluatorTest.java | 80 +++++++++++++++++++ .../wallet/ScheduledTransferServiceTest.java | 80 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java create mode 100644 src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java 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..f64e9f10 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java @@ -0,0 +1,80 @@ +package bumblebee.xchangepass.domain.wallet.fraud; + +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 java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class FraudRuleEvaluatorTest { + + @Autowired + private FraudRuleEvaluator fraudRuleEvaluator; + + @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"); + + // 5건 연속 트랜잭션으로 빈도 룰 유도 + for (int i = 0; i < 5; i++) { + fraudRuleEvaluator.isSuspicious(userId, amount); + } + + boolean 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/wallet/ScheduledTransferServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java new file mode 100644 index 00000000..2e799970 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java @@ -0,0 +1,80 @@ +package bumblebee.xchangepass.domain.wallet.wallet; + +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.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.repository.ScheduledTransferRepository; +import bumblebee.xchangepass.domain.wallet.wallet.scheduler.ScheduledTransferService; +import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; +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 java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class ScheduledTransferServiceTest { + + @Autowired + private ScheduledTransferService scheduledTransferService; + + @Autowired + private ScheduledTransferRepository scheduledTransferRepository; + + @Autowired + private WalletServiceFactory walletServiceFactory; + + @Autowired + private UserRegisterService userRegisterService; + + @Autowired + private UserService userService; + + private Long senderId; + + @BeforeEach + void setUp() { + // 간단한 사용자 등록 + userRegisterService.signupUser(new bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest( + "sender@example.com", "Password123!", "보내는사람", "010-1111-1111", Sex.MALE, "1234" + )); + + senderId = userService.readUser("보내는사람", "010-1111-1111").getUserId(); + } + + @Test + void 예약송금_등록_및_처리_정상동작() { + // 🔹 예약 시간: 현재보다 5초 전 + WalletTransferRequest request = new WalletTransferRequest( + "받는사람", + "010-2222-2222", + BigDecimal.valueOf(1000), + Currency.getInstance("KRW"), + Currency.getInstance("KRW"), + LocalDateTime.now().minusSeconds(5), + WalletTransferType.SCHEDULE + ); + + // 🔹 예약 송금 등록 + walletServiceFactory.getService("namedLock").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"); + } +} From f335bb2945ff421ceae05b949b4ea5385c5b0484 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 11:48:44 +0900 Subject: [PATCH 31/43] =?UTF-8?q?=F0=9F=94=A7=20Fix:=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/bumblebee/xchangepass/domain/user/entity/User.java | 2 +- .../bumblebee/xchangepass/domain/user/service/UserService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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); } From fcb8e9749371b4ff568123c5856e4bec0ed92ceb Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 11:48:51 +0900 Subject: [PATCH 32/43] =?UTF-8?q?=F0=9F=94=A7=20Fix:=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/card/service/CardPaymentService.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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; From 1b96ef86ee2e02e7ab7b2dcc39050f3d4867121a Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 11:50:06 +0900 Subject: [PATCH 33/43] =?UTF-8?q?=F0=9F=94=A7=20Fix:=20=EC=88=9C=ED=99=98?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lock/PessimisticLockWalletService.java | 59 +++++++------- .../redisson/RedissonLockWalletService.java | 76 +++++++++---------- 2 files changed, 63 insertions(+), 72 deletions(-) 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 e3665bcb..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 @@ -9,7 +9,6 @@ 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.entity.WalletTransferType; import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; import bumblebee.xchangepass.domain.wallet.wallet.scheduler.ScheduledTransferService; import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; @@ -101,45 +100,41 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void transfer(Long senderId, WalletTransferRequest request) { - if (request.transferType() == WalletTransferType.SCHEDULE) { - scheduledTransferService.saveSchedule(senderId, request); - } else { - try { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - - Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + try { + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - WalletBalance fromBalance = balanceService.findBalanceWithLock(senderWallet.getWalletId(), request.fromCurrency()); - if (fromBalance == null) { - throw ErrorCode.BALANCE_NOT_FOUND.commonException(); - } + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - if (!balanceService.checkBalance(receiverWallet.getWalletId(), request.toCurrency())) { - Wallet wallet = walletRepository.findById(receiverWallet.getWalletId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + WalletBalance fromBalance = balanceService.findBalanceWithLock(senderWallet.getWalletId(), request.fromCurrency()); + if (fromBalance == null) { + throw ErrorCode.BALANCE_NOT_FOUND.commonException(); + } - balanceService.createBalance(wallet, request.toCurrency()); - } + if (!balanceService.checkBalance(receiverWallet.getWalletId(), request.toCurrency())) { + Wallet wallet = walletRepository.findById(receiverWallet.getWalletId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - WalletBalance toBalance = balanceService.findBalanceWithLock(receiverWallet.getWalletId(), request.toCurrency()); + balanceService.createBalance(wallet, request.toCurrency()); + } - BigDecimal transferAmount = request.transferAmount(); - if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } + WalletBalance toBalance = balanceService.findBalanceWithLock(receiverWallet.getWalletId(), request.toCurrency()); - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } + BigDecimal transferAmount = request.transferAmount(); + if (transferAmount.compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { - log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", senderId, e.getMessage()); - throw ErrorCode.LOCK_TIME_OUT.commonException(); + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); } + + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (LockTimeoutException | PessimisticLockException | CannotAcquireLockException e) { + log.error("⚠️ [Lock 획득 실패] 사용자 ID: {}, 이유: {}", senderId, e.getMessage()); + throw ErrorCode.LOCK_TIME_OUT.commonException(); } } 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 eb5c951b..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 @@ -9,7 +9,6 @@ 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.entity.WalletTransferType; import bumblebee.xchangepass.domain.wallet.wallet.repository.WalletRepository; import bumblebee.xchangepass.domain.wallet.wallet.scheduler.ScheduledTransferService; import bumblebee.xchangepass.domain.wallet.wallet.service.WalletService; @@ -106,56 +105,53 @@ public BigDecimal withdrawal(Long userId, WalletInOutRequest request) { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void transfer(Long senderId, WalletTransferRequest request) { - if (request.transferType() == WalletTransferType.SCHEDULE) { - scheduledTransferService.saveSchedule(senderId, request); - } else { - User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); + User receiver = userService.readUser(request.receiverName(), request.receiverPhoneNumber()); - Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) - .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet senderWallet = walletRepository.findByUserIdWithLock(senderId) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); + Wallet receiverWallet = walletRepository.findByUserIdWithLock(receiver.getUserId()) + .orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException); - String senderLockKey = "senderWallet:" + senderWallet.getWalletId(); - String receiverLockKey = "senderWallet:" + receiverWallet.getWalletId(); + String senderLockKey = "senderWallet:" + senderWallet.getWalletId(); + String receiverLockKey = "senderWallet:" + receiverWallet.getWalletId(); - RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); - RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); - RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); + RLock senderLock = redissonLock.getRedissonClient().getLock(senderLockKey); + RLock receiverLock = redissonLock.getRedissonClient().getLock(receiverLockKey); + RedissonMultiLock multiLock = new RedissonMultiLock(senderLock, receiverLock); - boolean acquired = false; - try { - acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); - if (!acquired) { - throw ErrorCode.LOCK_TIME_OUT.commonException(); - } + boolean acquired = false; + try { + acquired = multiLock.tryLock(10, 30, TimeUnit.SECONDS); + if (!acquired) { + throw ErrorCode.LOCK_TIME_OUT.commonException(); + } - WalletBalance fromBalance = balanceService.findBalance(senderWallet.getWalletId(), request.fromCurrency()); - WalletBalance toBalance = balanceService.findBalance(receiverWallet.getWalletId(), request.toCurrency()); + WalletBalance fromBalance = balanceService.findBalance(senderWallet.getWalletId(), request.fromCurrency()); + WalletBalance toBalance = balanceService.findBalance(receiverWallet.getWalletId(), request.toCurrency()); - BigDecimal transferAmount = request.transferAmount(); - if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { - throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); - } + BigDecimal transferAmount = request.transferAmount(); + if (request.transferAmount().compareTo(fromBalance.getBalance()) > 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } - if (!request.toCurrency().equals(request.fromCurrency())) { - transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); - } + if (!request.toCurrency().equals(request.fromCurrency())) { + transferAmount = exchangeService.getExchangeMoney(request.fromCurrency(), request.toCurrency(), request.transferAmount()); + } - balanceService.transferBalance(fromBalance, toBalance, transferAmount); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw ErrorCode.THREAD_INTERRUPTED.commonException(); - } finally { - if (acquired) { - try { - multiLock.unlock(); // ✅ unlock 예외 처리 추가 - } catch (IllegalMonitorStateException e) { - log.error("⚠️ [MultiLock 해제 실패] senderId: {}, receiverId: {}", senderId, receiverWallet.getWalletId(), e); - } + balanceService.transferBalance(fromBalance, toBalance, transferAmount); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ErrorCode.THREAD_INTERRUPTED.commonException(); + } finally { + if (acquired) { + try { + multiLock.unlock(); // ✅ unlock 예외 처리 추가 + } catch (IllegalMonitorStateException e) { + log.error("⚠️ [MultiLock 해제 실패] senderId: {}, receiverId: {}", senderId, receiverWallet.getWalletId(), e); } } } + } @Override From b9c93937bfd28c84d8ec8d7736c5264379af1463 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 12:10:15 +0900 Subject: [PATCH 34/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20WalletFa?= =?UTF-8?q?cade=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/controller/WalletController.java | 6 +- .../scheduler/ScheduledTransferService.java | 2 - .../wallet/ScheduledTransferServiceTest.java | 92 +++++++++++++++---- 3 files changed, 76 insertions(+), 24 deletions(-) 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 3c2b5285..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; @@ -34,6 +35,7 @@ public class WalletController { private final WalletServiceFactory walletServiceFactory; private final WalletTransactionService transactionService; + private final WalletFacadeService walletFacadeService; @Operation(summary = "거래내역 조회", description = "거래내역을 조회합니다.") @ApiResponses(value = { @@ -112,7 +114,7 @@ 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 = "돈을 송금합니다.") @@ -135,7 +137,7 @@ public void transfer(@RequestBody @Valid WalletTransferRequest request, @ResponseStatus(HttpStatus.NO_CONTENT) public void scheduleTransfer(@RequestBody @Valid WalletTransferRequest request, @AuthenticationPrincipal CustomUserDetails user) { - walletServiceFactory.getService("namedLock").transfer(user.getUserId(), request); + walletFacadeService.transfer(user.getUserId(), request); } @Operation(summary = "잔액 조회", description = "잔액을 조회합니다.") 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 index 3fd8d0d1..9d33f961 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/wallet/scheduler/ScheduledTransferService.java @@ -2,9 +2,7 @@ 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.repository.ScheduledTransferRepository; -import bumblebee.xchangepass.domain.wallet.wallet.service.WalletServiceFactory; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java index 2e799970..cba1b736 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java @@ -1,18 +1,32 @@ package bumblebee.xchangepass.domain.wallet.wallet; +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.WalletServiceFactory; +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; @@ -22,38 +36,78 @@ 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 ScheduledTransferService scheduledTransferService; - - @Autowired - private ScheduledTransferRepository scheduledTransferRepository; - - @Autowired - private WalletServiceFactory walletServiceFactory; - - @Autowired - private UserRegisterService userRegisterService; - + private WalletRepository walletRepository; @Autowired - private UserService userService; + 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 bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest( + 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(); - senderId = userService.readUser("보내는사람", "010-1111-1111").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 예약송금_등록_및_처리_정상동작() { - // 🔹 예약 시간: 현재보다 5초 전 WalletTransferRequest request = new WalletTransferRequest( "받는사람", "010-2222-2222", @@ -64,14 +118,12 @@ void setUp() { WalletTransferType.SCHEDULE ); - // 🔹 예약 송금 등록 - walletServiceFactory.getService("namedLock").transfer(senderId, request); + 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(); From 723313e48ade5fdc10846c0bf503c45e5670fe79 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 12:10:30 +0900 Subject: [PATCH 35/43] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Chore:=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 36f05d3e..4d98f9db 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ dependencies { testImplementation "org.testcontainers:testcontainers:1.19.2" // Docker 기반 통합 테스트 지원 (Testcontainers 기본 라이브러리) testImplementation "org.testcontainers:junit-jupiter:1.19.2" // JUnit 5(Testcontainers 연동) 지원 testImplementation 'org.testcontainers:postgresql:1.19.2' + testImplementation "org.testcontainers:redis:1.19.1" // 데이터베이스 runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스a H2 (테스트 및 개발 환경용) From fdbaf5710033b307917772060575974bb7aaecb1 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 12:24:05 +0900 Subject: [PATCH 36/43] =?UTF-8?q?=E2=9C=85=20Test:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/fraud/FraudRuleEvaluatorTest.java | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java index f64e9f10..211f2c35 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java @@ -1,20 +1,51 @@ 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") +// .withInitScript("init.sql") + .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; @@ -31,12 +62,13 @@ void test_Lua_script_detects_high_frequency_transactions() { Long userId = 1002L; BigDecimal amount = new BigDecimal("20000"); - // 5건 연속 트랜잭션으로 빈도 룰 유도 - for (int i = 0; i < 5; i++) { - fraudRuleEvaluator.isSuspicious(userId, amount); - } + Boolean suspicious = false; - boolean suspicious = fraudRuleEvaluator.isSuspicious(userId, amount); + // 10건 연속 트랜잭션으로 빈도 룰 유도 + for (int i = 0; i < 10; i++) { + if(suspicious) break; + suspicious = fraudRuleEvaluator.isSuspicious(userId, amount); + } assertThat(suspicious).isTrue(); } From d578af0f253753ec54d4e70899ecb9c7f3f0bd9c Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 12:24:26 +0900 Subject: [PATCH 37/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EB=A3=B0=20=EA=B8=B0=EB=B0=98=20=EC=8B=A4=ED=96=89=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fraud/service/FraudRuleEvaluator.java | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 index 117bf7bd..8e34ea77 100644 --- a/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java +++ b/src/main/java/bumblebee/xchangepass/domain/wallet/fraud/service/FraudRuleEvaluator.java @@ -25,11 +25,11 @@ public Boolean isSuspicious(Long userId, BigDecimal amount) { Long nowEpoch = System.currentTimeMillis() / 1000; Boolean isNight = isNightTime(); - DefaultRedisScript script = new DefaultRedisScript<>(); + DefaultRedisScript script = new DefaultRedisScript<>(); script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/fraud_check.lua"))); - script.setResultType(Boolean.class); + script.setResultType(String.class); - Boolean result = redisTemplate.execute( + String result = redisTemplate.execute( script, List.of("fraud:user:" + userId), amount.toString(), @@ -40,17 +40,15 @@ public Boolean isSuspicious(Long userId, BigDecimal amount) { isNight ? "1" : "0" ); - if ("1".equals(result)) { - lastDetectedReason = "누적 금액 초과"; - } else if ("2".equals(result)) { - lastDetectedReason = "5분 내 거래 횟수 초과"; - } else if ("3".equals(result)) { - lastDetectedReason = "동일 금액 반복"; - } else if ("4".equals(result)) { - lastDetectedReason = "심야 시간대 거래"; + switch (result) { + case "1" -> lastDetectedReason = "누적 금액 초과"; + case "2" -> lastDetectedReason = "5분 내 거래 횟수 초과"; + case "3" -> lastDetectedReason = "동일 금액 반복"; + case "4" -> lastDetectedReason = "심야 시간대 거래"; + default -> lastDetectedReason = null; } - return Boolean.TRUE.equals(result); + return !"0".equals(result); } public String getLastDetectedReason() { From 7c282d5ed46c30749ceb8ada3eec513763447a53 Mon Sep 17 00:00:00 2001 From: usingjun Date: Tue, 8 Apr 2025 23:58:53 +0900 Subject: [PATCH 38/43] =?UTF-8?q?=F0=9F=8E=A8=20Style:=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/refresh/repository/RefreshTokenRepository.java | 3 +++ 1 file changed, 3 insertions(+) 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 e89b91fe..15d9dd76 100644 --- a/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/refresh/repository/RefreshTokenRepository.java @@ -52,6 +52,9 @@ public boolean isValidRefreshToken(Long userId, String refreshToken) { 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); From 449f2a3f758b288dc293136643807176c552ddc8 Mon Sep 17 00:00:00 2001 From: usingjun Date: Wed, 9 Apr 2025 00:27:21 +0900 Subject: [PATCH 39/43] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Chore:=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4d98f9db..36f05d3e 100644 --- a/build.gradle +++ b/build.gradle @@ -49,7 +49,6 @@ dependencies { testImplementation "org.testcontainers:testcontainers:1.19.2" // Docker 기반 통합 테스트 지원 (Testcontainers 기본 라이브러리) testImplementation "org.testcontainers:junit-jupiter:1.19.2" // JUnit 5(Testcontainers 연동) 지원 testImplementation 'org.testcontainers:postgresql:1.19.2' - testImplementation "org.testcontainers:redis:1.19.1" // 데이터베이스 runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스a H2 (테스트 및 개발 환경용) From 73671885d61b53abae49d72228c02a1afd155b5b Mon Sep 17 00:00:00 2001 From: usingjun Date: Wed, 9 Apr 2025 00:28:32 +0900 Subject: [PATCH 40/43] =?UTF-8?q?=E2=9C=85=20Test:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java index 211f2c35..4003d6b0 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/fraud/FraudRuleEvaluatorTest.java @@ -28,7 +28,6 @@ class FraudRuleEvaluatorTest { @Container static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16") -// .withInitScript("init.sql") .withDatabaseName("xcp_test") .withUsername("postgres") .withPassword("postgres"); From 29115a5e4102a400e444caee2d461d56fa6018b5 Mon Sep 17 00:00:00 2001 From: usingjun Date: Wed, 9 Apr 2025 00:28:47 +0900 Subject: [PATCH 41/43] =?UTF-8?q?=F0=9F=8E=A8=20Style:=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balance/service/WalletBalanceService.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 a057054a..5efa135e 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 @@ -25,6 +25,11 @@ public class WalletBalanceService { private final WalletTransactionService transactionService; private final FraudDetectionService fraudDetectionService; + /** + * 화폐별 잔액 생성 + * @param wallet + * @param currency + */ public void createBalance(Wallet wallet, Currency currency) { if (balanceRepository.existsByCurrency(wallet.getWalletId(), currency)) { return; @@ -34,32 +39,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); @@ -67,6 +105,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(); @@ -78,6 +121,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) { fraudDetectionService.detect(new FraudDetectEvent( fromBalance.getWallet().getUser().getUserId(), From 29911706fb090cd36769d786013e8e56e2d58dea Mon Sep 17 00:00:00 2001 From: usingjun Date: Wed, 9 Apr 2025 09:32:07 +0900 Subject: [PATCH 42/43] =?UTF-8?q?=F0=9F=94=A7=20Fix:=20Lazy=20Loading=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balance/service/WalletBalanceService.java | 8 -------- .../service/impl/lock/NamedLockWalletService.java | 13 +++++++++++-- 2 files changed, 11 insertions(+), 10 deletions(-) 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 5efa135e..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 @@ -23,7 +23,6 @@ public class WalletBalanceService { private final WalletBalanceRepository balanceRepository; private final WalletTransactionService transactionService; - private final FraudDetectionService fraudDetectionService; /** * 화폐별 잔액 생성 @@ -128,13 +127,6 @@ public void withdrawBalance(WalletBalance balance, BigDecimal amount) { * @param amount */ public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, BigDecimal amount) { - fraudDetectionService.detect(new FraudDetectEvent( - fromBalance.getWallet().getUser().getUserId(), - amount, - LocalDateTime.now(), - null - )); - fromBalance.subtractBalance(amount); toBalance.addBalance(amount); balanceRepository.save(fromBalance); 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 8064df29..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 @@ -5,14 +5,14 @@ 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.entity.WalletTransferType; import bumblebee.xchangepass.domain.wallet.wallet.repository.NamedLockRepository; 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; @@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; @Slf4j @@ -32,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; @@ -128,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); From 5399d58ac6bbcd9af915905ac0eed915add80666 Mon Sep 17 00:00:00 2001 From: usingjun Date: Wed, 9 Apr 2025 09:32:45 +0900 Subject: [PATCH 43/43] =?UTF-8?q?=E2=9C=85=20Test:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserLoginUnitTest.java | 4 +--- .../{ => service}/ScheduledTransferServiceTest.java | 2 +- .../wallet/service/WalletNamedLockServiceTest.java | 9 +++++++++ 3 files changed, 11 insertions(+), 4 deletions(-) rename src/test/java/bumblebee/xchangepass/domain/wallet/wallet/{ => service}/ScheduledTransferServiceTest.java (98%) 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 16b022f3..04c70fa9 100644 --- a/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/user/service/UserLoginUnitTest.java @@ -13,7 +13,6 @@ 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.impl.WalletServiceImpl; import bumblebee.xchangepass.global.exception.CommonException; import bumblebee.xchangepass.global.security.jwt.JwtProvider; import org.junit.jupiter.api.BeforeEach; @@ -41,8 +40,6 @@ class UserLoginUnitTest extends RedisTestBase { @Mock private BCryptPasswordEncoder bCryptPasswordEncoder; - @Mock - private WalletServiceImpl walletService; @Mock private JwtProvider jwtProvider; @@ -132,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/wallet/ScheduledTransferServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java similarity index 98% rename from src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java rename to src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java index cba1b736..6bc87d38 100644 --- a/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/ScheduledTransferServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/wallet/wallet/service/ScheduledTransferServiceTest.java @@ -1,4 +1,4 @@ -package bumblebee.xchangepass.domain.wallet.wallet; +package bumblebee.xchangepass.domain.wallet.wallet.service; import bumblebee.xchangepass.config.TestUserInitializer; import bumblebee.xchangepass.domain.user.dto.request.UserRegisterRequest; 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 4f581208..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 @@ -6,6 +6,7 @@ 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; @@ -22,6 +23,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; @@ -44,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 @@ -65,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"); @@ -190,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);