Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3a3fc9c
✨ feat: 가격 변동 알림 구현
rlawngjs0313 Feb 11, 2026
4950584
✨ feat: 업비트 주문 생성 테스트 API 에러 처리
seohyunk09 Feb 12, 2026
9dc58c4
Merge branch 'develop' into Fix/#111
seohyunk09 Feb 12, 2026
ab9f588
✨ feat: 가격 변동 알림 구현
rlawngjs0313 Feb 11, 2026
4411d34
Merge remote-tracking branch 'origin/Feat/#23' into Feat/#23
rlawngjs0313 Feb 12, 2026
f30a597
🔧 fix: 비밀번호 틀린 경우 멱등성 키 삭제 안되던 문제 수정 및 비밀번호 카운트 안되는 경우 수정
kingmingyu Feb 12, 2026
1d66bf5
✨ [Feat] 가격 변동 알림 구현 (1/2)
rlawngjs0313 Feb 12, 2026
5b86755
✨ feat: FCM 토큰 알림 테스트할 API 구현 (임시)
rlawngjs0313 Feb 12, 2026
df6a2d1
♻️ refactor: 필드명 수정
komascode Feb 12, 2026
a03d707
♻️ refactor: 틀린 주석 제거
komascode Feb 12, 2026
b7d92c7
♻️ refactor: 거래소 API키 등록을 필수로 변경
komascode Feb 12, 2026
4a6e597
✨ feat: FCM 토큰 알림 로직 추가
rlawngjs0313 Feb 12, 2026
dc71b50
🔥 fix: FCM 토큰 구독 빠져있는 문제 해결
rlawngjs0313 Feb 12, 2026
e688e36
🐛 [Bug] 출금 시 비밀번호 관련 문제
rlawngjs0313 Feb 12, 2026
501aa02
♻️ [Refactor] AuthService 필드명 수정 및 API Keys NotNull 설정
rlawngjs0313 Feb 12, 2026
33fc1ce
✨ [Feat] FCM 토큰 알림 로직 추가
rlawngjs0313 Feb 12, 2026
696b722
🔥 fix: 빌드되게끔 정상화
rlawngjs0313 Feb 12, 2026
b8b71e4
♻️ refactor: 간단한 스키마 추가
komascode Feb 12, 2026
9a9d458
Merge pull request #123 from UMCSCOI/Refactor/#117
komascode Feb 12, 2026
6333a93
♻️ refactor:주문 생성 테스트 API 엔드포인트 제거
seohyunk09 Feb 12, 2026
c8ff637
Merge branch 'develop' of https://github.com/UMCSCOI/Backend into dev…
seohyunk09 Feb 12, 2026
f882812
🚀 chore: README.md 수정
komascode Feb 12, 2026
2f38b87
🎯 chore: 리드미 수정
komascode Feb 12, 2026
bef8347
🚀 chore: README.md 수정
komascode Feb 12, 2026
320500a
🚀 chore: README.md 수정
komascode Feb 12, 2026
5d97b6f
Merge branch 'develop' into Fix/#111
seohyunk09 Feb 12, 2026
2995e5d
Merge pull request #125 from UMCSCOI/Chore-REAME.md
komascode Feb 12, 2026
178fcd8
Merge branch 'develop' into Fix/#111
seohyunk09 Feb 12, 2026
cf6a899
Merge pull request #126 from UMCSCOI/Fix/#111
seohyunk09 Feb 12, 2026
c003718
🔥 del: 알림 테스트용 API 제거
rlawngjs0313 Feb 12, 2026
a0c0331
♻️ refactor: Swagger 버전 변경 (0.3.2 -> 0.4.0)
rlawngjs0313 Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# 스코이 : 편리한 스테이블 코인 결제 플랫폼

## 💡 Project Overview

스코이는 스테이블코인을 일상적인 결제 수단으로 사용할 수 있도록 설계된 금융 플랫폼입니다.
결제부터 투자까지, 누구나 쉽게 사용할 수 있는 편리한 스테이블 코인 금융 환경을 제공하는 것을 목표로 합니다.

<img height="512" alt="image" src="https://github.com/user-attachments/assets/4e162831-0a8c-4223-afef-4e001737f43e" />
<img height="512" alt="image" src="https://github.com/user-attachments/assets/531acd4d-1c28-4912-b1a9-90adac60df05" />
<img height="512" alt="image" src="https://github.com/user-attachments/assets/766b5edd-cadf-4ab2-b840-09ea9e390c6c" />
<img height="512" alt="image" src="https://github.com/user-attachments/assets/71e5cb81-b3da-42e0-aaf8-91e9b52af392" />


## 🎯 주요 기능

### 🔐 **SMS 본인인증 & 간편 로그인**
- CoolSMS 기반 휴대폰 번호 인증으로 간편하게 회원가입
- 6자리 간편 비밀번호 + JWT 토큰 방식으로 빠르고 안전한 로그인
- 5회 실패 시 계정 잠금, SMS 재인증으로 해제

### 🏦 **멀티 거래소 API 연동**
- 업비트·빗썸 API 키를 등록해 두 거래소를 하나의 앱에서 통합 관리
- 거래소별 연동 상태 확인 및 API 키 등록·수정·삭제

### 💰 **원화 충전 & 자산 조회**
- 원화(KRW) 충전 요청 및 USDT/USDC 입금 주소 생성·조회
- 보유 자산(KRW, BTC, ETH 등) 전체 조회
- 주문 체결 시 FCM 푸시 알림 및 실시간 웹소켓 연동

### 📋 **내 지갑 & 거래 내역**
- 입출금·충전 거래 내역을 기간·유형별로 통합 조회
- 원화 출금 (카카오·네이버·하나 2차 인증 지원)
- 거래 UUID 기반 상세 내역 조회


## 👥 Contributors

| **마크/김주헌** | **호/원종호** | **띵/장명준** | **드로코드/김민규** | **희동/서희정** |
|:-------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------:| :---: | :---: | :---: |
| [<img src="https://avatars.githubusercontent.com/u/75869755?v=4" width="150"><br/>rlawngjs0313](https://github.com/rlawngjs0313) | [<img src="https://avatars.githubusercontent.com/u/133846600?v=4" width="150"><br/>yee2know](https://github.com/yee2know) | [<img src="https://avatars.githubusercontent.com/u/103755402?v=4" width="150"><br/>komascode](https://github.com/komascode) | [<img src="https://avatars.githubusercontent.com/u/90828383?v=4" width="150"><br/>kingmingyu](https://github.com/kingmingyu) | [<img src="https://avatars.githubusercontent.com/u/180945392?v=4" width="150"><br/>seohyunk09](https://github.com/seohyunk09) |

### ⚙️ 기술 스택
- Java 21
- Spring Boot 4.0.1
Expand Down Expand Up @@ -41,3 +80,6 @@
| └── redis # Redis
└── ScoiApplication
```
### 서버 아키텍처

<img width="1159" height="729" alt="서버 아키텍처" src="https://github.com/user-attachments/assets/527e386d-9f2c-47b7-a27c-3c8ae5e4ec3f" />
12 changes: 10 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand All @@ -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') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/example/scoi/ScoiApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ApiKeyRequest> apiKeys
) {}

Expand Down Expand Up @@ -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 = "휴대전화 번호는 빈칸일 수 없습니다.")
Expand Down
36 changes: 17 additions & 19 deletions src/main/java/com/example/scoi/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -253,24 +253,22 @@ public AuthResDTO.SignupResponse signup(AuthReqDTO.SignupRequest request) {

memberRepository.save(member);

// 5. API 키 등록 (있는 경우에만)
if (request.apiKeys() != null && !request.apiKeys().isEmpty()) {
List<MemberReqDTO.PostPatchApiKey> apiKeyRequests = new ArrayList<>();
for (AuthReqDTO.ApiKeyRequest apiKey : request.apiKeys()) {
apiKeyRequests.add(new MemberReqDTO.PostPatchApiKey(
apiKey.exchangeType(),
apiKey.publicKey(),
apiKey.secretKey()
));
}
// 5. API 키 등록
List<MemberReqDTO.PostPatchApiKey> apiKeyRequests = new ArrayList<>();
for (AuthReqDTO.ApiKeyRequest apiKey : request.apiKeys()) {
apiKeyRequests.add(new MemberReqDTO.PostPatchApiKey(
apiKey.exchangeType(),
apiKey.publicKey(),
apiKey.secretKey()
));
}

try {
List<String> 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<String> 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());
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,29 +79,6 @@ public ApiResponse<Void> checkOrderAvailability(
return ApiResponse.onSuccess(InvestSuccessCode.ORDER_AVAILABLE);
}

@PostMapping("/orders/test-create")
@Override
@SecurityRequirement(name = "JWT TOKEN")
public ApiResponse<InvestResDTO.OrderDTO> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,6 @@ ApiResponse<Void> checkOrderAvailability(
@AuthenticationPrincipal CustomUserDetails user
);

@Operation(
summary = "주문 생성 테스트 By 강서현",
description = "실제 주문을 생성하지 않고 주문 요청 형식과 주문 가능 여부를 검증합니다. " +
"업비트 API의 주문 생성 테스트 엔드포인트(/v1/orders/test)를 사용하여 거래 수수료 없이 검증할 수 있습니다. " +
"password는 필요하지 않습니다."
)
ApiResponse<InvestResDTO.OrderDTO> testCreateOrder(
@RequestBody InvestReqDTO.TestOrderDTO request,
@AuthenticationPrincipal CustomUserDetails user
);

@Operation(
summary = "코인 주문하기 By 강서현",
description = "코인 주문을 생성합니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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:";
Expand Down Expand Up @@ -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;
}
Expand Down
Loading