diff --git a/README.md b/README.md
index 1660f85..6ecb84f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,44 @@
# 스코이 : 편리한 스테이블 코인 결제 플랫폼
+## 💡 Project Overview
+
+스코이는 스테이블코인을 일상적인 결제 수단으로 사용할 수 있도록 설계된 금융 플랫폼입니다.
+결제부터 투자까지, 누구나 쉽게 사용할 수 있는 편리한 스테이블 코인 금융 환경을 제공하는 것을 목표로 합니다.
+
+
+
+
+
+
+
+## 🎯 주요 기능
+
+### 🔐 **SMS 본인인증 & 간편 로그인**
+- CoolSMS 기반 휴대폰 번호 인증으로 간편하게 회원가입
+- 6자리 간편 비밀번호 + JWT 토큰 방식으로 빠르고 안전한 로그인
+- 5회 실패 시 계정 잠금, SMS 재인증으로 해제
+
+### 🏦 **멀티 거래소 API 연동**
+- 업비트·빗썸 API 키를 등록해 두 거래소를 하나의 앱에서 통합 관리
+- 거래소별 연동 상태 확인 및 API 키 등록·수정·삭제
+
+### 💰 **원화 충전 & 자산 조회**
+- 원화(KRW) 충전 요청 및 USDT/USDC 입금 주소 생성·조회
+- 보유 자산(KRW, BTC, ETH 등) 전체 조회
+- 주문 체결 시 FCM 푸시 알림 및 실시간 웹소켓 연동
+
+### 📋 **내 지갑 & 거래 내역**
+- 입출금·충전 거래 내역을 기간·유형별로 통합 조회
+- 원화 출금 (카카오·네이버·하나 2차 인증 지원)
+- 거래 UUID 기반 상세 내역 조회
+
+
+## 👥 Contributors
+
+| **마크/김주헌** | **호/원종호** | **띵/장명준** | **드로코드/김민규** | **희동/서희정** |
+|:-------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------:| :---: | :---: | :---: |
+| [
rlawngjs0313](https://github.com/rlawngjs0313) | [
yee2know](https://github.com/yee2know) | [
komascode](https://github.com/komascode) | [
kingmingyu](https://github.com/kingmingyu) | [
seohyunk09](https://github.com/seohyunk09) |
+
### ⚙️ 기술 스택
- Java 21
- Spring Boot 4.0.1
@@ -41,3 +80,6 @@
| └── redis # Redis
└── ScoiApplication
```
+### 서버 아키텍처
+
+
diff --git a/build.gradle b/build.gradle
index cb2bb06..8bdb235 100644
--- a/build.gradle
+++ b/build.gradle
@@ -42,7 +42,6 @@ dependencies {
// MySQL
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
-
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -65,7 +64,7 @@ dependencies {
// Firebase
implementation 'com.google.firebase:firebase-admin:9.7.0'
-
+
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -74,6 +73,15 @@ dependencies {
// Dotenv (환경변수)
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
+
+ // WebSocket
+ implementation 'org.springframework.boot:spring-boot-starter-websocket'
+ implementation 'org.webjars:stomp-websocket:2.3.3'
+ implementation 'org.webjars:webjars-locator-core'
+ implementation 'org.webjars:sockjs-client:1.0.2'
+
+ // Retry
+ implementation 'org.springframework.retry:spring-retry'
}
tasks.named('test') {
diff --git a/src/main/java/com/example/scoi/ScoiApplication.java b/src/main/java/com/example/scoi/ScoiApplication.java
index 622d316..2917228 100644
--- a/src/main/java/com/example/scoi/ScoiApplication.java
+++ b/src/main/java/com/example/scoi/ScoiApplication.java
@@ -4,10 +4,12 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.retry.annotation.EnableRetry;
@SpringBootApplication
@EnableJpaAuditing
@EnableFeignClients
+@EnableRetry
public class ScoiApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java b/src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java
index 663d559..665c1fa 100644
--- a/src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java
+++ b/src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java
@@ -3,6 +3,7 @@
import com.example.scoi.domain.member.enums.ExchangeType;
import com.example.scoi.domain.member.enums.MemberType;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@@ -73,7 +74,10 @@ public record SignupRequest(
@Schema(description = "바이오 인증 등록 여부 (true: 등록, false: 나중에)", example = "false")
Boolean isBioRegistered,
- @Schema(description = "거래소 API 키 목록 (선택사항)")
+ @Valid
+ @NotNull(message = "거래소 API 키 목록은 필수입니다.")
+ @Size(min = 1, message = "거래소 API 키는 최소 1개 이상이어야 합니다.")
+ @Schema(description = "거래소 API 키 목록")
List apiKeys
) {}
@@ -117,7 +121,8 @@ public record ReissueRequest(
public record ResetPassword(
@NotNull(message = "SMS 인증 토큰은 필수입니다.")
@NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.")
- String verificationCode,
+ @Schema(description = "POST /auth/sms/verify 응답으로 받은 verificationToken")
+ String verificationToken,
@NotNull(message = "휴대전화 번호는 필수입니다.")
@NotBlank(message = "휴대전화 번호는 빈칸일 수 없습니다.")
diff --git a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java
index 3a6c466..fe1f329 100644
--- a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java
+++ b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java
@@ -180,7 +180,7 @@ public Void resetPassword(
AuthReqDTO.ResetPassword dto
) {
// Verification Token 검증 및 소멸 (SMS 인증 완료 확인)
- String phoneNumber = validateVerificationToken(dto.verificationCode(), dto.phoneNumber());
+ String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber());
// 사용자 가져오기
Member member = memberRepository.findByPhoneNumber(phoneNumber)
@@ -253,24 +253,22 @@ public AuthResDTO.SignupResponse signup(AuthReqDTO.SignupRequest request) {
memberRepository.save(member);
- // 5. API 키 등록 (있는 경우에만)
- if (request.apiKeys() != null && !request.apiKeys().isEmpty()) {
- List apiKeyRequests = new ArrayList<>();
- for (AuthReqDTO.ApiKeyRequest apiKey : request.apiKeys()) {
- apiKeyRequests.add(new MemberReqDTO.PostPatchApiKey(
- apiKey.exchangeType(),
- apiKey.publicKey(),
- apiKey.secretKey()
- ));
- }
+ // 5. API 키 등록
+ List apiKeyRequests = new ArrayList<>();
+ for (AuthReqDTO.ApiKeyRequest apiKey : request.apiKeys()) {
+ apiKeyRequests.add(new MemberReqDTO.PostPatchApiKey(
+ apiKey.exchangeType(),
+ apiKey.publicKey(),
+ apiKey.secretKey()
+ ));
+ }
- try {
- List registeredExchanges = memberService.postPatchApiKey(request.phoneNumber(), apiKeyRequests);
- log.info("회원가입 시 API 키 등록 성공: memberId={}, exchanges={}", member.getId(), registeredExchanges);
- } catch (Exception e) {
- log.warn("회원가입 시 API 키 등록 실패 (회원가입은 성공): memberId={}, error={}", member.getId(), e.getMessage());
- // API 키 등록 실패해도 회원가입은 성공으로 처리
- }
+ try {
+ List registeredExchanges = memberService.postPatchApiKey(request.phoneNumber(), apiKeyRequests);
+ log.info("회원가입 시 API 키 등록 성공: memberId={}, exchanges={}", member.getId(), registeredExchanges);
+ } catch (Exception e) {
+ log.warn("회원가입 시 API 키 등록 실패 (회원가입은 성공): memberId={}, error={}", member.getId(), e.getMessage());
+ // API 키 등록 실패해도 회원가입은 성공으로 처리
}
log.info("회원가입 성공: memberId={}, phoneNumber={}", member.getId(), member.getPhoneNumber());
@@ -283,7 +281,7 @@ public AuthResDTO.LoginResponse login(AuthReqDTO.LoginRequest request) {
Member member = memberRepository.findByPhoneNumber(request.phoneNumber())
.orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND));
- // 2. verificationToken 사전 검증 및 소멸 (일회성 보장)
+ // 2. verificationToken 사전 검증
boolean smsVerified = false;
if (request.verificationToken() != null) {
validateVerificationToken(request.verificationToken(), request.phoneNumber());
diff --git a/src/main/java/com/example/scoi/domain/invest/client/adapter/UpbitApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/UpbitApiClient.java
index 41a4146..1000dc3 100644
--- a/src/main/java/com/example/scoi/domain/invest/client/adapter/UpbitApiClient.java
+++ b/src/main/java/com/example/scoi/domain/invest/client/adapter/UpbitApiClient.java
@@ -955,13 +955,46 @@ public InvestResDTO.OrderDTO testCreateOrder(
ObjectMapper objectMapper = new ObjectMapper();
ClientErrorDTO.Errors error = objectMapper.readValue(errorBody, ClientErrorDTO.Errors.class);
if (error != null && error.error() != null) {
+ String errorName = error.error().name();
+ String errorMessage = error.error().message();
+
log.error("=== 업비트 주문 생성 테스트 실패 (400) ===");
- log.error("에러 이름: {}", error.error().name());
- log.error("에러 메시지: {}", error.error().message());
+ log.error("에러 이름: {}", errorName);
+ log.error("에러 메시지: {}", errorMessage);
log.error("전체 응답: {}", errorBody);
+
+ // 업비트 API 에러 이름에 따라 구체적인 에러 코드로 변환
+ if ("insufficient_funds".equals(errorName) ||
+ (errorMessage != null && errorMessage.contains("잔고"))) {
+ // 매수 시 잔고 부족
+ if ("bid".equals(side)) {
+ throw new InvestException(InvestErrorCode.INSUFFICIENT_BALANCE);
+ }
+ // 매도 시 보유 수량 부족
+ else if ("ask".equals(side)) {
+ throw new InvestException(InvestErrorCode.INSUFFICIENT_COIN_AMOUNT);
+ }
+ } else if ("invalid_min_total".equals(errorName) ||
+ "under_min_total".equals(errorName) ||
+ (errorMessage != null && (errorMessage.contains("최소") || errorMessage.contains("minimum")))) {
+ // 최소 주문 금액 미만
+ throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT);
+ } else if ("over_price_limit_bid".equals(errorName) ||
+ "over_price_limit_ask".equals(errorName) ||
+ (errorMessage != null && errorMessage.contains("현재가"))) {
+ // 호가 제한 초과 (현재가의 300% 이내에서만 주문 가능)
+ Map errorDetails = Map.of(
+ "errorName", errorName,
+ "errorMessage", errorMessage != null ? errorMessage : ""
+ );
+ throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR, errorDetails);
+ }
} else {
log.error("업비트 주문 생성 테스트 실패 (400) - responseBody: {}", errorBody);
}
+ } catch (InvestException investEx) {
+ // 구체적인 InvestException은 그대로 전파
+ throw investEx;
} catch (Exception parseException) {
log.error("업비트 주문 생성 테스트 실패 (400) - JSON 파싱 실패: {}, responseBody: {}",
parseException.getMessage(), errorBody);
diff --git a/src/main/java/com/example/scoi/domain/invest/controller/InvestController.java b/src/main/java/com/example/scoi/domain/invest/controller/InvestController.java
index ba008b4..9104aec 100644
--- a/src/main/java/com/example/scoi/domain/invest/controller/InvestController.java
+++ b/src/main/java/com/example/scoi/domain/invest/controller/InvestController.java
@@ -79,29 +79,6 @@ public ApiResponse checkOrderAvailability(
return ApiResponse.onSuccess(InvestSuccessCode.ORDER_AVAILABLE);
}
- @PostMapping("/orders/test-create")
- @Override
- @SecurityRequirement(name = "JWT TOKEN")
- public ApiResponse testCreateOrder(
- @RequestBody InvestReqDTO.TestOrderDTO request,
- @AuthenticationPrincipal CustomUserDetails user
- ) {
- String phoneNumber = user.getUsername();
-
- // 주문 생성 테스트 (password 불필요)
- InvestResDTO.OrderDTO result = investService.testCreateOrder(
- phoneNumber,
- request.exchangeType(),
- request.market(),
- request.side(),
- request.orderType(),
- request.price(),
- request.volume()
- );
-
- return ApiResponse.onSuccess(InvestSuccessCode.ORDER_SUCCESS, result);
- }
-
@PostMapping("/orders")
@Override
@SecurityRequirement(name = "JWT TOKEN")
diff --git a/src/main/java/com/example/scoi/domain/invest/controller/InvestControllerDocs.java b/src/main/java/com/example/scoi/domain/invest/controller/InvestControllerDocs.java
index bf3f6c9..826c81e 100644
--- a/src/main/java/com/example/scoi/domain/invest/controller/InvestControllerDocs.java
+++ b/src/main/java/com/example/scoi/domain/invest/controller/InvestControllerDocs.java
@@ -37,17 +37,6 @@ ApiResponse checkOrderAvailability(
@AuthenticationPrincipal CustomUserDetails user
);
- @Operation(
- summary = "주문 생성 테스트 By 강서현",
- description = "실제 주문을 생성하지 않고 주문 요청 형식과 주문 가능 여부를 검증합니다. " +
- "업비트 API의 주문 생성 테스트 엔드포인트(/v1/orders/test)를 사용하여 거래 수수료 없이 검증할 수 있습니다. " +
- "password는 필요하지 않습니다."
- )
- ApiResponse testCreateOrder(
- @RequestBody InvestReqDTO.TestOrderDTO request,
- @AuthenticationPrincipal CustomUserDetails user
- );
-
@Operation(
summary = "코인 주문하기 By 강서현",
description = "코인 주문을 생성합니다."
diff --git a/src/main/java/com/example/scoi/domain/invest/service/InvestService.java b/src/main/java/com/example/scoi/domain/invest/service/InvestService.java
index e1db220..aa01d18 100644
--- a/src/main/java/com/example/scoi/domain/invest/service/InvestService.java
+++ b/src/main/java/com/example/scoi/domain/invest/service/InvestService.java
@@ -103,50 +103,6 @@ public void checkOrderAvailability(
}
}
-
- // 주문 생성 테스트
- public InvestResDTO.OrderDTO testCreateOrder(
- String phoneNumber,
- ExchangeType exchangeType,
- String market,
- String side,
- String orderType,
- String price,
- String volume
- ) {
- // 사용자 존재 여부 확인
- Member member = memberRepository.findByPhoneNumber(phoneNumber)
- .orElseThrow(() -> new InvestException(InvestErrorCode.API_KEY_NOT_FOUND));
-
- // 거래소별 분기
- ExchangeApiClient apiClient = getApiClient(exchangeType);
-
- try {
- // 주문 생성 테스트
- return apiClient.testCreateOrder(
- phoneNumber,
- exchangeType,
- market,
- side,
- orderType,
- price,
- volume
- );
- } catch (InvestException e) {
- throw e;
- } catch (FeignException e) {
- // FeignException은 FeignErrorDecoder에서 이미 로깅되었으므로, 여기서는 추가 정보만 로깅
- String errorBody = e.contentUTF8();
- log.error("거래소 주문 생성 테스트 실패 (FeignException) - exchangeType: {}, phoneNumber: {}, market: {}, side: {}, status: {}, responseBody: {}",
- exchangeType, phoneNumber, market, side, e.status(), errorBody);
- throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR);
- } catch (Exception e) {
- log.error("거래소 주문 생성 테스트 실패 - exchangeType: {}, phoneNumber: {}, market: {}, side: {}, error: {}",
- exchangeType, phoneNumber, market, side, e.getMessage(), e);
- throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR);
- }
- }
-
// 주문 생성
@Transactional
public InvestResDTO.OrderDTO createOrder(
diff --git a/src/main/java/com/example/scoi/domain/member/entity/MemberFcm.java b/src/main/java/com/example/scoi/domain/member/entity/MemberFcm.java
index d356086..e3bb828 100644
--- a/src/main/java/com/example/scoi/domain/member/entity/MemberFcm.java
+++ b/src/main/java/com/example/scoi/domain/member/entity/MemberFcm.java
@@ -31,4 +31,9 @@ public class MemberFcm {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
+
+ // 업데이트
+ public void updateFcmToken(String fcmToken){
+ this.fcmToken = fcmToken;
+ }
}
diff --git a/src/main/java/com/example/scoi/domain/member/service/MemberService.java b/src/main/java/com/example/scoi/domain/member/service/MemberService.java
index 8c1fb2d..0677ace 100644
--- a/src/main/java/com/example/scoi/domain/member/service/MemberService.java
+++ b/src/main/java/com/example/scoi/domain/member/service/MemberService.java
@@ -5,7 +5,6 @@
import com.example.scoi.domain.member.dto.MemberResDTO;
import com.example.scoi.domain.member.entity.Member;
import com.example.scoi.domain.member.entity.MemberApiKey;
-import com.example.scoi.domain.member.entity.MemberFcm;
import com.example.scoi.domain.member.enums.ExchangeType;
import com.example.scoi.domain.member.exception.MemberException;
import com.example.scoi.domain.member.exception.code.MemberErrorCode;
@@ -16,8 +15,10 @@
import com.example.scoi.global.client.BithumbClient;
import com.example.scoi.global.client.UpbitClient;
import com.example.scoi.global.redis.RedisUtil;
+import com.example.scoi.global.util.FcmUtil;
import com.example.scoi.global.util.HashUtil;
import com.example.scoi.global.util.JwtApiUtil;
+import com.google.firebase.messaging.FirebaseMessagingException;
import feign.FeignException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@@ -42,6 +43,7 @@ public class MemberService {
private final UpbitClient upbitClient;
private final MemberFcmRepository memberFcmRepository;
private final RedisUtil redisUtil;
+ private final FcmUtil fcmUtil;
// 인증 완료된 전화번호 접두사
private static final String VERIFICATION_PREFIX = "verification:";
@@ -246,9 +248,17 @@ public Void postFcmToken(
Member member = memberRepository.findByPhoneNumber(phoneNumber)
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
- // FCM 토큰 저장 (로그인 -> 추가, 디바이스당 추가)
- MemberFcm memberFcm = MemberConverter.toMemberFcm(dto.token(), member);
- memberFcmRepository.save(memberFcm);
+ // FCM 토큰 저장 (로그인 -> 추가, 현재 로그인 된 디바이스)
+ memberFcmRepository.findByMember(member)
+ .orElse(memberFcmRepository.save(MemberConverter.toMemberFcm(dto.token(), member)))
+ .updateFcmToken(dto.token());
+
+ try {
+ fcmUtil.subscribeNotificationForDepegging(List.of(dto.token()));
+ log.info("[ MemberService ]: 구독 완료, 토큰: {}", dto.token());
+ } catch (FirebaseMessagingException e){
+ log.warn("[ MemberService ]: 구독 실패, 토큰: {}", dto.token());
+ }
return null;
}
diff --git a/src/main/java/com/example/scoi/domain/transfer/service/TransferService.java b/src/main/java/com/example/scoi/domain/transfer/service/TransferService.java
index 25978ab..c6f4e0d 100644
--- a/src/main/java/com/example/scoi/domain/transfer/service/TransferService.java
+++ b/src/main/java/com/example/scoi/domain/transfer/service/TransferService.java
@@ -1,5 +1,6 @@
package com.example.scoi.domain.transfer.service;
+import com.example.scoi.domain.auth.service.LoginFailCountManager;
import com.example.scoi.domain.member.entity.Member;
import com.example.scoi.domain.member.enums.ExchangeType;
import com.example.scoi.domain.member.exception.MemberException;
@@ -63,6 +64,7 @@ public class TransferService {
private static final ObjectMapper objectMapper = new ObjectMapper();
private final RedisTemplate redisTemplate;
+ private final LoginFailCountManager loginFailCountManager;
// 최근 수취인 조회 메서드
public TransferResDTO.RecipientListDTO findRecentRecipients(String phoneNumber, String cursor, int limit) {
@@ -318,164 +320,174 @@ public TransferResDTO.WithdrawResult executeWithdraw(String phoneNumber, Transfe
}
boolean isSuccess = false;
- // 1. 간편 비밀번호 검증
- Member member = memberRepository.findByPhoneNumber(phoneNumber)
- .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
-
- String rawPassword;
- try {
- rawPassword = new String(hashUtil.decryptAES(request.simplePassword()));
- } catch (GeneralSecurityException e) {
- log.error("AES 복호화 실패: phoneNumber={}", phoneNumber, e);
- member.increaseLoginFailCount();
- int failCount = member.getLoginFailCount();
- int remainingAttempts = Math.max(5 - failCount, 0);
- throw new AuthException(
- AuthErrorCode.INVALID_PASSWORD,
- Map.of(
- "loginFailCount", String.valueOf(failCount),
- "remainingAttempts", String.valueOf(remainingAttempts)
- )
- );
- }
- if (!rawPassword.matches("^\\d{6}$")) {
- log.warn("간편비밀번호 형식 오류: phoneNumber={}", phoneNumber);
- member.increaseLoginFailCount();
- int failCount = member.getLoginFailCount();
- int remainingAttempts = Math.max(5 - failCount, 0);
- throw new AuthException(
- AuthErrorCode.INVALID_PASSWORD,
- Map.of(
- "loginFailCount", String.valueOf(failCount),
- "remainingAttempts", String.valueOf(remainingAttempts)
- )
- );
- }
-
- if (!passwordEncoder.matches(rawPassword, member.getSimplePassword())) {
- member.increaseLoginFailCount();
- int failCount = member.getLoginFailCount();
- int remainingAttempts = Math.max(5 - failCount, 0);
- log.warn("비밀번호 인증 실패: phoneNumber={}, failCount={}, remainingAttempts={}",
- phoneNumber, failCount, remainingAttempts);
- throw new AuthException(
- AuthErrorCode.INVALID_PASSWORD,
- Map.of(
- "loginFailCount", String.valueOf(failCount),
- "remainingAttempts", String.valueOf(remainingAttempts)
- )
- );
- }
- // 비밀번호 일치 시 실패 횟수 초기화
- member.resetLoginFailCount();
-
- // 2. 이체하기
- String token;
- TransferResDTO.WithdrawResult result;
-
try {
- switch (request.exchangeType()) {
- case UPBIT:
- TransferReqDTO.UpbitWithdrawRequest upbitDTO = TransferConverter.toUpbitWithdrawRequest(request);
- token = jwtApiUtil.createUpBitJwt(phoneNumber, null, upbitDTO);
-
- UpbitResDTO.WithdrawResDTO upbitRes = upbitClient.withdrawCoin(token, upbitDTO);
- result = TransferConverter.toWithdrawResult(upbitRes);
- log.info("UP DTO: {}", upbitRes);
- break;
-
- case BITHUMB:
- TransferReqDTO.BithumbWithdrawRequest bithumbDTO = TransferConverter.toBithumbWithdrawRequest(request);
- token = jwtApiUtil.createBithumbJwt(phoneNumber, null, bithumbDTO);
-
- BithumbResDTO.WithdrawResDTO bithumRes = bithumbClient.withdrawCoin(token, bithumbDTO);
- result = TransferConverter.toWithdrawResult(bithumRes);
- log.info("BIT DTO: {}", bithumRes);
- break;
-
- default:
- throw new TransferException(TransferErrorCode.UNSUPPORTED_EXCHANGE);
+ // 1. 간편 비밀번호 검증
+ Member member = memberRepository.findByPhoneNumber(phoneNumber)
+ .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
+
+ // 비밀번호 5회 이상 틀린 경우
+ if (member.getLoginFailCount() >= 5) {
+ throw new AuthException(
+ AuthErrorCode.ACCOUNT_LOCKED,
+ Map.of("smsRequired", "true")
+ );
}
- // 성공한 경우
- isSuccess = true;
-
- // 수취인 저장
- Recipient recipient = TransferConverter.toRecipient(request, member);
- recipientRepository.save(recipient);
- // 이체내역 저장
- TradeHistory tradeHistory = TransferConverter.toTradeHistory(request, result, recipient, member);
- tradeHistoryRepository.save(tradeHistory);
-
- // 결과 반환
- return result;
-
- } catch (GeneralSecurityException e) {
- throw new RuntimeException(e);
- }
- catch (FeignException.BadRequest | FeignException.NotFound e) {
- String rawBody = e.contentUTF8(); // 원본 응답 저장
- log.error(">>>> 거래소 응답 원본: {}", rawBody); // 에러 로그 원본
- if (rawBody == null || rawBody.isBlank()) {
-
- // 상태 코드에 따른 예외
- if (e.status() == 401) {
- // 인증 실패 (JWT 서명 오류, 만료 등)
- throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
- } else {
- // 권한 없음 (IP 차단 등)
- throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
- }
+ String rawPassword;
+ try {
+ rawPassword = new String(hashUtil.decryptAES(request.simplePassword()));
+ } catch (GeneralSecurityException e) {
+ log.error("AES 복호화 실패: phoneNumber={}", phoneNumber, e);
+ member.increaseLoginFailCount();
+ int failCount = loginFailCountManager.increaseFailCount(member.getId());
+ int remainingAttempts = Math.max(5 - failCount, 0);
+ throw new AuthException(
+ AuthErrorCode.INVALID_PASSWORD,
+ Map.of(
+ "loginFailCount", String.valueOf(failCount),
+ "remainingAttempts", String.valueOf(remainingAttempts)
+ )
+ );
+ }
+ if (!rawPassword.matches("^\\d{6}$")) {
+ log.warn("간편비밀번호 형식 오류: phoneNumber={}", phoneNumber);
+ member.increaseLoginFailCount();
+ int failCount = loginFailCountManager.increaseFailCount(member.getId());
+ int remainingAttempts = Math.max(5 - failCount, 0);
+ throw new AuthException(
+ AuthErrorCode.INVALID_PASSWORD,
+ Map.of(
+ "loginFailCount", String.valueOf(failCount),
+ "remainingAttempts", String.valueOf(remainingAttempts)
+ )
+ );
}
- ClientErrorDTO.Errors error = objectMapper.readValue(rawBody, ClientErrorDTO.Errors.class);
- String errorName = error.error().name();
- log.error(">>>> 거래소 에러 코드명: {}", errorName); // 파싱된 거래소 에러 코드명 확인
-
- switch (errorName) {
- // 파라미터가 잘못된 경우
- case "validation_error" -> throw new TransferException(TransferErrorCode.INVALID_INPUT);
- // 네트워크가 잘못된 경우
- case "invalid_network_type" -> throw new TransferException(TransferErrorCode.INVALID_NETWORK_TYPE);
- // 지갑 주소가 올바르지 않은 경우
- case "invalid_withdraw_address" -> throw new TransferException(TransferErrorCode.INVALID_WALLET_ADDRESS);
- // 거래소에서 요청을 처리하지 못한 경우
- case "request_fail" -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
- //등록된 출금주소가 아닌 경우
- case "withdraw_address_not_registered" -> throw new TransferException(TransferErrorCode.UNREGISTERED_WALLET_ADDRESS);
- // 출금 시스템이 점검 중인 경우
- case "withdraw_maintain" -> throw new TransferException(TransferErrorCode.TRANSFER_CHECK);
- // 나머지 400 에러
- default -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ if (!passwordEncoder.matches(rawPassword, member.getSimplePassword())) {
+ member.increaseLoginFailCount();
+ int failCount = loginFailCountManager.increaseFailCount(member.getId());
+ int remainingAttempts = Math.max(5 - failCount, 0);
+ log.warn("비밀번호 인증 실패: phoneNumber={}, failCount={}, remainingAttempts={}",
+ phoneNumber, failCount, remainingAttempts);
+ throw new AuthException(
+ AuthErrorCode.INVALID_PASSWORD,
+ Map.of(
+ "loginFailCount", String.valueOf(failCount),
+ "remainingAttempts", String.valueOf(remainingAttempts)
+ )
+ );
}
- // 권한이 부족한 경우
- } catch (FeignException.Unauthorized | FeignException.Forbidden e) {
- String rawBody = e.contentUTF8(); // 원본 응답 저장
- log.error(">>>> 거래소 응답 원본: {}", rawBody); // 에러 로그 원본
- if (rawBody == null || rawBody.isBlank()) {
-
- // 상태 코드에 따른 예외
- if (e.status() == 401) {
- // 인증 실패 (JWT 서명 오류, 만료 등)
- throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
- } else {
- // 권한 없음 (IP 차단 등)
- throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
+ // 비밀번호 일치 시 실패 횟수 초기화
+ member.resetLoginFailCount();
+
+ // 2. 이체하기
+ String token;
+ TransferResDTO.WithdrawResult result;
+
+ try {
+ switch (request.exchangeType()) {
+ case UPBIT:
+ TransferReqDTO.UpbitWithdrawRequest upbitDTO = TransferConverter.toUpbitWithdrawRequest(request);
+ token = jwtApiUtil.createUpBitJwt(phoneNumber, null, upbitDTO);
+
+ UpbitResDTO.WithdrawResDTO upbitRes = upbitClient.withdrawCoin(token, upbitDTO);
+ result = TransferConverter.toWithdrawResult(upbitRes);
+ log.info("UP DTO: {}", upbitRes);
+ break;
+
+ case BITHUMB:
+ TransferReqDTO.BithumbWithdrawRequest bithumbDTO = TransferConverter.toBithumbWithdrawRequest(request);
+ token = jwtApiUtil.createBithumbJwt(phoneNumber, null, bithumbDTO);
+
+ BithumbResDTO.WithdrawResDTO bithumRes = bithumbClient.withdrawCoin(token, bithumbDTO);
+ result = TransferConverter.toWithdrawResult(bithumRes);
+ log.info("BIT DTO: {}", bithumRes);
+ break;
+
+ default:
+ throw new TransferException(TransferErrorCode.UNSUPPORTED_EXCHANGE);
+ }
+ // 성공한 경우
+ isSuccess = true;
+
+ // 수취인 저장
+ Recipient recipient = TransferConverter.toRecipient(request, member);
+ recipientRepository.save(recipient);
+
+ // 이체내역 저장
+ TradeHistory tradeHistory = TransferConverter.toTradeHistory(request, result, recipient, member);
+ tradeHistoryRepository.save(tradeHistory);
+
+ // 결과 반환
+ return result;
+ } catch (GeneralSecurityException e) {
+ throw new RuntimeException(e);
+ } catch (FeignException.BadRequest | FeignException.NotFound e) {
+ String rawBody = e.contentUTF8(); // 원본 응답 저장
+ log.error(">>>> 거래소 응답 원본: {}", rawBody); // 에러 로그 원본
+ if (rawBody == null || rawBody.isBlank()) {
+
+ // 상태 코드에 따른 예외
+ if (e.status() == 401) {
+ // 인증 실패 (JWT 서명 오류, 만료 등)
+ throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ } else {
+ // 권한 없음 (IP 차단 등)
+ throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
+ }
}
- }
-
- ClientErrorDTO.Errors error = objectMapper.readValue(rawBody, ClientErrorDTO.Errors.class);
- String errorName = error.error().name();
- log.error(">>>> 거래소 에러 코드명: {}", errorName); // 파싱된 거래소 에러 코드명 확인
- switch (errorName){
+ ClientErrorDTO.Errors error = objectMapper.readValue(rawBody, ClientErrorDTO.Errors.class);
+ String errorName = error.error().name();
+ log.error(">>>> 거래소 에러 코드명: {}", errorName); // 파싱된 거래소 에러 코드명 확인
+
+ switch (errorName) {
+ // 파라미터가 잘못된 경우
+ case "validation_error" -> throw new TransferException(TransferErrorCode.INVALID_INPUT);
+ // 네트워크가 잘못된 경우
+ case "invalid_network_type" -> throw new TransferException(TransferErrorCode.INVALID_NETWORK_TYPE);
+ // 지갑 주소가 올바르지 않은 경우
+ case "invalid_withdraw_address" ->
+ throw new TransferException(TransferErrorCode.INVALID_WALLET_ADDRESS);
+ // 거래소에서 요청을 처리하지 못한 경우
+ case "request_fail" -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ //등록된 출금주소가 아닌 경우
+ case "withdraw_address_not_registered" ->
+ throw new TransferException(TransferErrorCode.UNREGISTERED_WALLET_ADDRESS);
+ // 출금 시스템이 점검 중인 경우
+ case "withdraw_maintain" -> throw new TransferException(TransferErrorCode.TRANSFER_CHECK);
+ // 나머지 400 에러
+ default -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ }
// 권한이 부족한 경우
- case "out_of_scope" -> throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
- // 인증되지 않은 ip에서 요청을 보낸 경우
- case "no_authorization_ip" -> throw new TransferException(TransferErrorCode.NOT_ALLOW_IP);
- case "NotAllowIP" -> throw new TransferException(TransferErrorCode.NOT_ALLOW_IP);
- // 나머지 jwt 관련 오류
- default -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ } catch (FeignException.Unauthorized | FeignException.Forbidden e) {
+ String rawBody = e.contentUTF8(); // 원본 응답 저장
+ log.error(">>>> 거래소 응답 원본: {}", rawBody); // 에러 로그 원본
+ if (rawBody == null || rawBody.isBlank()) {
+
+ // 상태 코드에 따른 예외
+ if (e.status() == 401) {
+ // 인증 실패 (JWT 서명 오류, 만료 등)
+ throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ } else {
+ // 권한 없음 (IP 차단 등)
+ throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
+ }
+ }
+
+ ClientErrorDTO.Errors error = objectMapper.readValue(rawBody, ClientErrorDTO.Errors.class);
+ String errorName = error.error().name();
+ log.error(">>>> 거래소 에러 코드명: {}", errorName); // 파싱된 거래소 에러 코드명 확인
+
+ switch (errorName) {
+ // 권한이 부족한 경우
+ case "out_of_scope" -> throw new TransferException(TransferErrorCode.EXCHANGE_FORBIDDEN);
+ // 인증되지 않은 ip에서 요청을 보낸 경우
+ case "no_authorization_ip" -> throw new TransferException(TransferErrorCode.NOT_ALLOW_IP);
+ case "NotAllowIP" -> throw new TransferException(TransferErrorCode.NOT_ALLOW_IP);
+ // 나머지 jwt 관련 오류
+ default -> throw new TransferException(TransferErrorCode.EXCHANGE_BAD_REQUEST);
+ }
}
}
finally {
diff --git a/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java b/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java
new file mode 100644
index 0000000..3bebb0b
--- /dev/null
+++ b/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java
@@ -0,0 +1,27 @@
+package com.example.scoi.domain.websocket;
+
+import com.example.scoi.domain.websocket.handler.UpbitTickerHandler;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.web.socket.client.WebSocketClient;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class WebsocketConnect {
+
+ private final WebSocketClient webSocketClient;
+ private final UpbitTickerHandler upbitTickerHandler;
+
+ private static final String PUBLIC_URL = "wss://api.upbit.com/websocket/v1";
+
+ // 실시간 가격 변동 체크
+ @EventListener(ApplicationReadyEvent.class)
+ public void connect(){
+ log.info("[ Websocket ]: 디페깅 알고리즘 구동 시작...");
+ webSocketClient.execute(upbitTickerHandler, PUBLIC_URL);
+ }
+}
diff --git a/src/main/java/com/example/scoi/domain/websocket/converter/WebSocketConverter.java b/src/main/java/com/example/scoi/domain/websocket/converter/WebSocketConverter.java
new file mode 100644
index 0000000..68c0873
--- /dev/null
+++ b/src/main/java/com/example/scoi/domain/websocket/converter/WebSocketConverter.java
@@ -0,0 +1,48 @@
+package com.example.scoi.domain.websocket.converter;
+
+import com.example.scoi.domain.websocket.dto.UpbitReqDTO;
+import com.example.scoi.domain.websocket.dto.WebSocketReqDTO;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.socket.TextMessage;
+
+import java.util.List;
+import java.util.UUID;
+
+@RequiredArgsConstructor
+public class WebSocketConverter {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper()
+ .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
+
+ // 코인 가격 조회: 업비트
+ public static TextMessage toGetCoinPrice(
+ List codes
+ ) throws JsonProcessingException {
+ List