From 3a3fc9c2396b033b3af64770fc0bbe0cd8061ca6 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 00:31:07 +0900 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 12 +- .../com/example/scoi/ScoiApplication.java | 2 + .../scoi/domain/member/entity/MemberFcm.java | 5 + .../domain/websocket/WebsocketConnect.java | 27 +++++ .../converter/WebSocketConverter.java | 48 ++++++++ .../domain/websocket/dto/BithumbReqDTO.java | 4 + .../domain/websocket/dto/UpbitReqDTO.java | 15 +++ .../domain/websocket/dto/UpbitResDTO.java | 45 +++++++ .../domain/websocket/dto/WebSocketReqDTO.java | 16 +++ .../domain/websocket/enums/RiseOrFall.java | 12 ++ .../websocket/handler/UpbitTickerHandler.java | 53 +++++++++ .../websocket/service/WebSocketService.java | 111 ++++++++++++++++++ .../apiPayload/code/GeneralErrorCode.java | 8 ++ .../scoi/global/config/WebSocketConfig.java | 30 +++++ .../com/example/scoi/global/util/FcmUtil.java | 98 +++++++++++++--- 15 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/converter/WebSocketConverter.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java create mode 100644 src/main/java/com/example/scoi/global/config/WebSocketConfig.java 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/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/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 payload = List.of(toTicket(), toTicker(codes) ,toFormat()); + return new TextMessage(objectMapper.writeValueAsString(payload)); + } + + private static WebSocketReqDTO.Ticket toTicket(){ + return WebSocketReqDTO.Ticket.builder() + .ticket(UUID.randomUUID().toString()) + .build(); + } + + private static WebSocketReqDTO.Format toFormat(){ + return WebSocketReqDTO.Format.builder() + .format("SIMPLE_LIST") + .build(); + } + + private static UpbitReqDTO.Ticker toTicker( + List codes + ){ + return UpbitReqDTO.Ticker.builder() + .type("ticker") + .codes(codes) + .build(); + } +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java new file mode 100644 index 0000000..a015725 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java @@ -0,0 +1,4 @@ +package com.example.scoi.domain.websocket.dto; + +public class BithumbReqDTO { +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java new file mode 100644 index 0000000..94d7c20 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java @@ -0,0 +1,15 @@ +package com.example.scoi.domain.websocket.dto; + +import lombok.Builder; + +import java.util.List; + +public class UpbitReqDTO { + + // 가격 실시간 조회 + @Builder + public record Ticker( + String type, + List codes + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java new file mode 100644 index 0000000..2bfdebc --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java @@ -0,0 +1,45 @@ +package com.example.scoi.domain.websocket.dto; + +import java.util.Date; + +public class UpbitResDTO { + + // 현제 코인 가격 + public record Ticker( + String ty, + String cd, + Double op, + Double hp, + Double lp, + Double tp, + Double pcp, + String c, + Double cp, + Double scp, + Double cr, + Double scr, + Double tv, + Double atv, + Double atv24h, + Double atp, + Double atp24h, + String tdt, + String ttm, + Long ttms, + String ab, + Double aav, + Double abv, + Double h52wp, + String h52wdt, + Double l52wp, + String l52wdt, + String ms, + Date dd, + Long tms, + String st, + + // Deprecated + Boolean its, + String mw + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java new file mode 100644 index 0000000..b4df74e --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java @@ -0,0 +1,16 @@ +package com.example.scoi.domain.websocket.dto; + +import lombok.Builder; + +public class WebSocketReqDTO { + + @Builder + public record Ticket( + String ticket + ){} + + @Builder + public record Format( + String format + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java new file mode 100644 index 0000000..3f11313 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java @@ -0,0 +1,12 @@ +package com.example.scoi.domain.websocket.enums; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum RiseOrFall { + RISE("올랐어요."), + FALL("떨어졌어요.") + ; + + private final String message; +} diff --git a/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java new file mode 100644 index 0000000..e9a9e3c --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java @@ -0,0 +1,53 @@ +package com.example.scoi.domain.websocket.handler; + +import com.example.scoi.domain.websocket.converter.WebSocketConverter; +import com.example.scoi.domain.websocket.dto.UpbitResDTO; +import com.example.scoi.domain.websocket.service.WebSocketService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UpbitTickerHandler extends BinaryWebSocketHandler { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + private final WebSocketService webSocketService; + + private static final ObjectMapper objectMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); + + @Override + public void afterConnectionEstablished( + WebSocketSession session + ) throws IOException { + session.sendMessage(WebSocketConverter.toGetCoinPrice(List.of("KRW-USDT","KRW-USDC"))); + } + + @Override + public void handleBinaryMessage( + WebSocketSession session, + BinaryMessage message + ) throws IOException { + String converted = new String(message.getPayload().array(), StandardCharsets.UTF_8); + converted = converted.replace("[","").replace("]",""); + publish(converted); + UpbitResDTO.Ticker dto = objectMapper.readValue(converted, UpbitResDTO.Ticker.class); + webSocketService.ticker(dto); + } + + @Async + protected void publish(String message) { + simpMessageSendingOperations.convertAndSend("/topic/ticker", message); + } +} diff --git a/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java new file mode 100644 index 0000000..770dcdc --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java @@ -0,0 +1,111 @@ +package com.example.scoi.domain.websocket.service; + +import com.example.scoi.domain.websocket.dto.UpbitResDTO; +import com.example.scoi.domain.websocket.enums.RiseOrFall; +import com.example.scoi.global.redis.RedisUtil; +import com.example.scoi.global.util.FcmUtil; +import com.google.firebase.messaging.FirebaseMessagingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WebSocketService { + + private final FcmUtil fcmUtil; + private final RedisUtil redisUtil; + + // 웹소켓 관련된 작업 Redis 접두사 + private static final String WEBSOCKET_PREFIX = "websocket:"; + private static final String BASELINE = "baseline:"; + private static final String PRICE = "price"; + private static final String TICK = "tick"; + private static final String COOLDOWN = "cooldown"; + private static final String DURATION = "duration"; + + private static final Float A = 0.01F; // a + private static final Float DEV_TH = 0.06F; // 6%: 가격 변화 민감도 + private static final Integer DUR_TH_SEC = 600; // 10분: 가격 급등, 급락 시 알림 전송 기준 + private static final Long COOLDOWN_SEC = 3600L; // 1시간: 재알림 주기 + private static final Double EPS = 1e9; + + // 실시간 가격 변동 체크 + public void ticker(UpbitResDTO.Ticker dto) { + + String code = dto.cd().replace("KRW-",""); + + // Redis에서 baseline, prevTs, duration 가져오기 + // BigDecimal로 부동 소수점 오차 해결 + Double baseline = (redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+PRICE))? + Double.valueOf(redisUtil.get(WEBSOCKET_PREFIX+code+BASELINE+PRICE)):dto.tp(); + + BigDecimal prevTs = (redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+TICK))? + new BigDecimal(redisUtil.get(WEBSOCKET_PREFIX+code+BASELINE+TICK)):new BigDecimal(dto.ttms()); + + Double duration = (redisUtil.exists(WEBSOCKET_PREFIX+code+DURATION))? + Double.valueOf(redisUtil.get(WEBSOCKET_PREFIX+code+DURATION)):0.0; + + BigDecimal nowTs = new BigDecimal(dto.ttms()); + + // deltaTs 계산 + BigDecimal deltaSec = (nowTs.subtract(prevTs)) + .divide(new BigDecimal(1000), 10, RoundingMode.HALF_UP); + + if (deltaSec.compareTo(BigDecimal.ZERO) < 0) deltaSec = BigDecimal.ZERO; + if (deltaSec.compareTo(new BigDecimal(180)) > 0) deltaSec = new BigDecimal(180); + + // 휴식 시간이 지났는가?: 있다면 진행 X + if (!redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+COOLDOWN) + ) { + BigDecimal devNumerator = new BigDecimal(Math.abs(dto.tp() - baseline)); + BigDecimal dev = devNumerator.divide(BigDecimal.valueOf(Math.max(baseline, EPS)), RoundingMode.HALF_UP); + + log.info("coin: {}, dev: {}, baseline: {}, tp: {}", code, dev, baseline, dto.tp()); + + // duration 누적/리셋 + if (dev.compareTo(BigDecimal.valueOf(DEV_TH)) >= 0){ + duration += deltaSec.doubleValue(); + } else { + duration = 0.0; + } + + redisUtil.set(WEBSOCKET_PREFIX+code+DURATION, String.valueOf(duration)); + + // 알림 조건 + if (duration >= DUR_TH_SEC){ + + // 기준치와 현재가 퍼센트 계산 + String percent = String.format("%.2f", (dto.tp()-baseline)/baseline*100); + RiseOrFall riseOrFall = (percent.compareTo("0.00") >= 0)? RiseOrFall.RISE : RiseOrFall.FALL; + + // 전체 사용자에게 알림 보내기 + try { + fcmUtil.sendNotificationForDepegging( + code+" 가격 변동 알림", + "평소보다 "+code+" 가격이 "+dto.tp()+"원으로 약 "+percent+"% "+riseOrFall.name() + ); + } catch (FirebaseMessagingException e){ + log.error("[ FcmUtil ]: 알림 전송 실패, 에러 코드: {}, 스택 트레이스: {}", e.getMessagingErrorCode(), e.getStackTrace()); + } + + // 쿨타임 저장: TTL 쿨타임 저장 시간 (1시간) + redisUtil.set( + WEBSOCKET_PREFIX+code+BASELINE+COOLDOWN, + String.valueOf(COOLDOWN_SEC), + COOLDOWN_SEC, + TimeUnit.SECONDS + ); + } + } + + // baseline 업데이트 + redisUtil.set(WEBSOCKET_PREFIX+code+BASELINE+PRICE, String.valueOf(A*dto.tp() + (1-A)*baseline)); + redisUtil.set(WEBSOCKET_PREFIX+code+BASELINE+TICK, String.valueOf(dto.ttms())); + } +} diff --git a/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java index 173d8d6..3e508c1 100644 --- a/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java @@ -46,6 +46,14 @@ public enum GeneralErrorCode implements BaseErrorCode{ NOT_SUPPORT_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "VALID415_1", "지원하지 않는 Content-Type입니다."), + + // FCM 토큰 관련 + UNSUBSCRIBE_FAILED(HttpStatus.BAD_GATEWAY, + "FCM502_1", + "FCM 서버에 의해 해당 디바이스 알림 구독 해제에 실패했습니다. 다시 시도해주세요."), + SUBSCRIBE_FAILED(HttpStatus.BAD_GATEWAY, + "FCM502_2", + "FCM 서버에 의해 해당 디바이스 알림 구독에 실패했습니다. FCM 토큰을 재확인하거나 다시 시도해주세요.") ; private final HttpStatus status; diff --git a/src/main/java/com/example/scoi/global/config/WebSocketConfig.java b/src/main/java/com/example/scoi/global/config/WebSocketConfig.java new file mode 100644 index 0000000..e3176a7 --- /dev/null +++ b/src/main/java/com/example/scoi/global/config/WebSocketConfig.java @@ -0,0 +1,30 @@ +package com.example.scoi.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer{ + + @Bean + public WebSocketClient webSocketClient() { + return new StandardWebSocketClient(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*"); + } +} diff --git a/src/main/java/com/example/scoi/global/util/FcmUtil.java b/src/main/java/com/example/scoi/global/util/FcmUtil.java index 148f606..64f347f 100644 --- a/src/main/java/com/example/scoi/global/util/FcmUtil.java +++ b/src/main/java/com/example/scoi/global/util/FcmUtil.java @@ -1,18 +1,23 @@ package com.example.scoi.global.util; -import com.example.scoi.domain.member.entity.Member; -import com.example.scoi.domain.member.entity.MemberFcm; -import com.example.scoi.domain.member.exception.MemberException; -import com.example.scoi.domain.member.exception.code.MemberErrorCode; import com.example.scoi.domain.member.repository.MemberFcmRepository; +import com.google.firebase.FirebaseException; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j @Component @RequiredArgsConstructor public class FcmUtil { @@ -20,22 +25,24 @@ public class FcmUtil { private final MemberFcmRepository memberFcmRepository; private final FirebaseMessaging firebaseMessaging; + private static final String DEPEGGING_TOPIC = "Depegging-all"; + /** - * 특정 유저에게 알림을 전송합니다. + * 디페깅 발생시 유저에게 알림을 전송합니다. * @param title 보낼 알림의 제목 * @param body 보낼 알림의 내용 - * @param member 특정 유저 - * @return null * @throws FirebaseMessagingException 실패시 발생 */ - public Void sendNotification( - @NotNull String title, - @NotNull String body, - @NotNull Member member + @Retryable( + recover = "sendRecover" + ) + @Async + public void sendNotificationForDepegging( + @NotNull String title, + @NotNull String body ) throws FirebaseMessagingException { - // 유저 FCM 토큰 찾기 - MemberFcm memberFcm = memberFcmRepository.findByMember(member) - .orElseThrow(() -> new MemberException(MemberErrorCode.FCM_TOKEN_NOT_FOUND)); + + log.info("[ FcmUtil ]: 디페깅 상황 발생, 알림 전송..."); // 안드로이드 설정 AndroidConfig androidConfig = AndroidConfig.builder() @@ -49,12 +56,69 @@ public Void sendNotification( .putData("body", body) // 우선순위는 high .setAndroidConfig(androidConfig) - .setToken(memberFcm.getFcmToken()) + .setTopic(DEPEGGING_TOPIC) .build(); - // 알림 전송: 실패시 + // 알림 전송 firebaseMessaging.send(message); + } + + /** + * 디페깅 알림을 위해 구독합니다. + * @param fcmTokenList 알림을 구독할 FCM 토큰 + */ + @Retryable( + recover = "subscriberRecover" + ) + @Async + public void subscribeNotificationForDepegging( + @NotNull List fcmTokenList + ) throws FirebaseMessagingException { + + log.info("[ FcmUtil ]: 디페깅 알고리즘 구독 중..."); + firebaseMessaging.subscribeToTopic(fcmTokenList, DEPEGGING_TOPIC); + } + + /** + * 디페깅 알고리즘 구독을 취소합니다. (로그아웃) + * @param fcmTokenList 구독 취소할 FCM 토큰 + */ + @Retryable( + recover = "unsubscriberRecover" + ) + @Async + public void unsubscribeNotificationForDepegging( + @NotNull List fcmTokenList + ) throws FirebaseMessagingException { + + log.info("[ FcmUtil ]: 디페깅 알고리즘 구독 해제 중..."); + firebaseMessaging.unsubscribeFromTopic(fcmTokenList, DEPEGGING_TOPIC); + + } + + @Recover + public void sendRecover( + FirebaseMessagingException e + ) throws FirebaseException { + log.warn("[ FcmUtil ]: 디페깅 알고리즘 알림 전송 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); + } + + @Recover + public void subscriberRecover( + FirebaseMessagingException e + ) throws FirebaseException { + + log.warn("[ FcmUtil ]: 디페깅 알고리즘 구독 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); + } + + @Recover + public void unsubscriberRecover( + FirebaseMessagingException e + ) throws FirebaseException { - return null; + log.warn("[ FcmUtil ]: 디페깅 알고리즘 구독 해제 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); } } From 49505840a2b2cf61cad6e110a6535dd8dd2f58c5 Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Thu, 12 Feb 2026 14:43:37 +0900 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=97=85=EB=B9=84?= =?UTF-8?q?=ED=8A=B8=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../invest/client/adapter/UpbitApiClient.java | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) 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 5a07570..5cb2a70 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 @@ -835,13 +835,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); From ab9f58859556d4fafadd5af7b489f9a2678222a5 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 00:31:07 +0900 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 12 +- .../com/example/scoi/ScoiApplication.java | 2 + .../scoi/domain/member/entity/MemberFcm.java | 5 + .../domain/websocket/WebsocketConnect.java | 27 +++++ .../converter/WebSocketConverter.java | 48 ++++++++ .../domain/websocket/dto/BithumbReqDTO.java | 4 + .../domain/websocket/dto/UpbitReqDTO.java | 15 +++ .../domain/websocket/dto/UpbitResDTO.java | 45 +++++++ .../domain/websocket/dto/WebSocketReqDTO.java | 16 +++ .../domain/websocket/enums/RiseOrFall.java | 12 ++ .../websocket/handler/UpbitTickerHandler.java | 53 +++++++++ .../websocket/service/WebSocketService.java | 111 ++++++++++++++++++ .../apiPayload/code/GeneralErrorCode.java | 8 ++ .../scoi/global/config/WebSocketConfig.java | 30 +++++ .../com/example/scoi/global/util/FcmUtil.java | 98 +++++++++++++--- 15 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/converter/WebSocketConverter.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java create mode 100644 src/main/java/com/example/scoi/global/config/WebSocketConfig.java 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/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/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 payload = List.of(toTicket(), toTicker(codes) ,toFormat()); + return new TextMessage(objectMapper.writeValueAsString(payload)); + } + + private static WebSocketReqDTO.Ticket toTicket(){ + return WebSocketReqDTO.Ticket.builder() + .ticket(UUID.randomUUID().toString()) + .build(); + } + + private static WebSocketReqDTO.Format toFormat(){ + return WebSocketReqDTO.Format.builder() + .format("SIMPLE_LIST") + .build(); + } + + private static UpbitReqDTO.Ticker toTicker( + List codes + ){ + return UpbitReqDTO.Ticker.builder() + .type("ticker") + .codes(codes) + .build(); + } +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java new file mode 100644 index 0000000..a015725 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/BithumbReqDTO.java @@ -0,0 +1,4 @@ +package com.example.scoi.domain.websocket.dto; + +public class BithumbReqDTO { +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java new file mode 100644 index 0000000..94d7c20 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitReqDTO.java @@ -0,0 +1,15 @@ +package com.example.scoi.domain.websocket.dto; + +import lombok.Builder; + +import java.util.List; + +public class UpbitReqDTO { + + // 가격 실시간 조회 + @Builder + public record Ticker( + String type, + List codes + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java new file mode 100644 index 0000000..2bfdebc --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/UpbitResDTO.java @@ -0,0 +1,45 @@ +package com.example.scoi.domain.websocket.dto; + +import java.util.Date; + +public class UpbitResDTO { + + // 현제 코인 가격 + public record Ticker( + String ty, + String cd, + Double op, + Double hp, + Double lp, + Double tp, + Double pcp, + String c, + Double cp, + Double scp, + Double cr, + Double scr, + Double tv, + Double atv, + Double atv24h, + Double atp, + Double atp24h, + String tdt, + String ttm, + Long ttms, + String ab, + Double aav, + Double abv, + Double h52wp, + String h52wdt, + Double l52wp, + String l52wdt, + String ms, + Date dd, + Long tms, + String st, + + // Deprecated + Boolean its, + String mw + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java b/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java new file mode 100644 index 0000000..b4df74e --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/dto/WebSocketReqDTO.java @@ -0,0 +1,16 @@ +package com.example.scoi.domain.websocket.dto; + +import lombok.Builder; + +public class WebSocketReqDTO { + + @Builder + public record Ticket( + String ticket + ){} + + @Builder + public record Format( + String format + ){} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java new file mode 100644 index 0000000..3f11313 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java @@ -0,0 +1,12 @@ +package com.example.scoi.domain.websocket.enums; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum RiseOrFall { + RISE("올랐어요."), + FALL("떨어졌어요.") + ; + + private final String message; +} diff --git a/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java new file mode 100644 index 0000000..e9a9e3c --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java @@ -0,0 +1,53 @@ +package com.example.scoi.domain.websocket.handler; + +import com.example.scoi.domain.websocket.converter.WebSocketConverter; +import com.example.scoi.domain.websocket.dto.UpbitResDTO; +import com.example.scoi.domain.websocket.service.WebSocketService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UpbitTickerHandler extends BinaryWebSocketHandler { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + private final WebSocketService webSocketService; + + private static final ObjectMapper objectMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); + + @Override + public void afterConnectionEstablished( + WebSocketSession session + ) throws IOException { + session.sendMessage(WebSocketConverter.toGetCoinPrice(List.of("KRW-USDT","KRW-USDC"))); + } + + @Override + public void handleBinaryMessage( + WebSocketSession session, + BinaryMessage message + ) throws IOException { + String converted = new String(message.getPayload().array(), StandardCharsets.UTF_8); + converted = converted.replace("[","").replace("]",""); + publish(converted); + UpbitResDTO.Ticker dto = objectMapper.readValue(converted, UpbitResDTO.Ticker.class); + webSocketService.ticker(dto); + } + + @Async + protected void publish(String message) { + simpMessageSendingOperations.convertAndSend("/topic/ticker", message); + } +} diff --git a/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java new file mode 100644 index 0000000..770dcdc --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java @@ -0,0 +1,111 @@ +package com.example.scoi.domain.websocket.service; + +import com.example.scoi.domain.websocket.dto.UpbitResDTO; +import com.example.scoi.domain.websocket.enums.RiseOrFall; +import com.example.scoi.global.redis.RedisUtil; +import com.example.scoi.global.util.FcmUtil; +import com.google.firebase.messaging.FirebaseMessagingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WebSocketService { + + private final FcmUtil fcmUtil; + private final RedisUtil redisUtil; + + // 웹소켓 관련된 작업 Redis 접두사 + private static final String WEBSOCKET_PREFIX = "websocket:"; + private static final String BASELINE = "baseline:"; + private static final String PRICE = "price"; + private static final String TICK = "tick"; + private static final String COOLDOWN = "cooldown"; + private static final String DURATION = "duration"; + + private static final Float A = 0.01F; // a + private static final Float DEV_TH = 0.06F; // 6%: 가격 변화 민감도 + private static final Integer DUR_TH_SEC = 600; // 10분: 가격 급등, 급락 시 알림 전송 기준 + private static final Long COOLDOWN_SEC = 3600L; // 1시간: 재알림 주기 + private static final Double EPS = 1e9; + + // 실시간 가격 변동 체크 + public void ticker(UpbitResDTO.Ticker dto) { + + String code = dto.cd().replace("KRW-",""); + + // Redis에서 baseline, prevTs, duration 가져오기 + // BigDecimal로 부동 소수점 오차 해결 + Double baseline = (redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+PRICE))? + Double.valueOf(redisUtil.get(WEBSOCKET_PREFIX+code+BASELINE+PRICE)):dto.tp(); + + BigDecimal prevTs = (redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+TICK))? + new BigDecimal(redisUtil.get(WEBSOCKET_PREFIX+code+BASELINE+TICK)):new BigDecimal(dto.ttms()); + + Double duration = (redisUtil.exists(WEBSOCKET_PREFIX+code+DURATION))? + Double.valueOf(redisUtil.get(WEBSOCKET_PREFIX+code+DURATION)):0.0; + + BigDecimal nowTs = new BigDecimal(dto.ttms()); + + // deltaTs 계산 + BigDecimal deltaSec = (nowTs.subtract(prevTs)) + .divide(new BigDecimal(1000), 10, RoundingMode.HALF_UP); + + if (deltaSec.compareTo(BigDecimal.ZERO) < 0) deltaSec = BigDecimal.ZERO; + if (deltaSec.compareTo(new BigDecimal(180)) > 0) deltaSec = new BigDecimal(180); + + // 휴식 시간이 지났는가?: 있다면 진행 X + if (!redisUtil.exists(WEBSOCKET_PREFIX+code+BASELINE+COOLDOWN) + ) { + BigDecimal devNumerator = new BigDecimal(Math.abs(dto.tp() - baseline)); + BigDecimal dev = devNumerator.divide(BigDecimal.valueOf(Math.max(baseline, EPS)), RoundingMode.HALF_UP); + + log.info("coin: {}, dev: {}, baseline: {}, tp: {}", code, dev, baseline, dto.tp()); + + // duration 누적/리셋 + if (dev.compareTo(BigDecimal.valueOf(DEV_TH)) >= 0){ + duration += deltaSec.doubleValue(); + } else { + duration = 0.0; + } + + redisUtil.set(WEBSOCKET_PREFIX+code+DURATION, String.valueOf(duration)); + + // 알림 조건 + if (duration >= DUR_TH_SEC){ + + // 기준치와 현재가 퍼센트 계산 + String percent = String.format("%.2f", (dto.tp()-baseline)/baseline*100); + RiseOrFall riseOrFall = (percent.compareTo("0.00") >= 0)? RiseOrFall.RISE : RiseOrFall.FALL; + + // 전체 사용자에게 알림 보내기 + try { + fcmUtil.sendNotificationForDepegging( + code+" 가격 변동 알림", + "평소보다 "+code+" 가격이 "+dto.tp()+"원으로 약 "+percent+"% "+riseOrFall.name() + ); + } catch (FirebaseMessagingException e){ + log.error("[ FcmUtil ]: 알림 전송 실패, 에러 코드: {}, 스택 트레이스: {}", e.getMessagingErrorCode(), e.getStackTrace()); + } + + // 쿨타임 저장: TTL 쿨타임 저장 시간 (1시간) + redisUtil.set( + WEBSOCKET_PREFIX+code+BASELINE+COOLDOWN, + String.valueOf(COOLDOWN_SEC), + COOLDOWN_SEC, + TimeUnit.SECONDS + ); + } + } + + // baseline 업데이트 + redisUtil.set(WEBSOCKET_PREFIX+code+BASELINE+PRICE, String.valueOf(A*dto.tp() + (1-A)*baseline)); + redisUtil.set(WEBSOCKET_PREFIX+code+BASELINE+TICK, String.valueOf(dto.ttms())); + } +} diff --git a/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java index 173d8d6..3e508c1 100644 --- a/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/example/scoi/global/apiPayload/code/GeneralErrorCode.java @@ -46,6 +46,14 @@ public enum GeneralErrorCode implements BaseErrorCode{ NOT_SUPPORT_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "VALID415_1", "지원하지 않는 Content-Type입니다."), + + // FCM 토큰 관련 + UNSUBSCRIBE_FAILED(HttpStatus.BAD_GATEWAY, + "FCM502_1", + "FCM 서버에 의해 해당 디바이스 알림 구독 해제에 실패했습니다. 다시 시도해주세요."), + SUBSCRIBE_FAILED(HttpStatus.BAD_GATEWAY, + "FCM502_2", + "FCM 서버에 의해 해당 디바이스 알림 구독에 실패했습니다. FCM 토큰을 재확인하거나 다시 시도해주세요.") ; private final HttpStatus status; diff --git a/src/main/java/com/example/scoi/global/config/WebSocketConfig.java b/src/main/java/com/example/scoi/global/config/WebSocketConfig.java new file mode 100644 index 0000000..e3176a7 --- /dev/null +++ b/src/main/java/com/example/scoi/global/config/WebSocketConfig.java @@ -0,0 +1,30 @@ +package com.example.scoi.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer{ + + @Bean + public WebSocketClient webSocketClient() { + return new StandardWebSocketClient(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*"); + } +} diff --git a/src/main/java/com/example/scoi/global/util/FcmUtil.java b/src/main/java/com/example/scoi/global/util/FcmUtil.java index 148f606..64f347f 100644 --- a/src/main/java/com/example/scoi/global/util/FcmUtil.java +++ b/src/main/java/com/example/scoi/global/util/FcmUtil.java @@ -1,18 +1,23 @@ package com.example.scoi.global.util; -import com.example.scoi.domain.member.entity.Member; -import com.example.scoi.domain.member.entity.MemberFcm; -import com.example.scoi.domain.member.exception.MemberException; -import com.example.scoi.domain.member.exception.code.MemberErrorCode; import com.example.scoi.domain.member.repository.MemberFcmRepository; +import com.google.firebase.FirebaseException; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j @Component @RequiredArgsConstructor public class FcmUtil { @@ -20,22 +25,24 @@ public class FcmUtil { private final MemberFcmRepository memberFcmRepository; private final FirebaseMessaging firebaseMessaging; + private static final String DEPEGGING_TOPIC = "Depegging-all"; + /** - * 특정 유저에게 알림을 전송합니다. + * 디페깅 발생시 유저에게 알림을 전송합니다. * @param title 보낼 알림의 제목 * @param body 보낼 알림의 내용 - * @param member 특정 유저 - * @return null * @throws FirebaseMessagingException 실패시 발생 */ - public Void sendNotification( - @NotNull String title, - @NotNull String body, - @NotNull Member member + @Retryable( + recover = "sendRecover" + ) + @Async + public void sendNotificationForDepegging( + @NotNull String title, + @NotNull String body ) throws FirebaseMessagingException { - // 유저 FCM 토큰 찾기 - MemberFcm memberFcm = memberFcmRepository.findByMember(member) - .orElseThrow(() -> new MemberException(MemberErrorCode.FCM_TOKEN_NOT_FOUND)); + + log.info("[ FcmUtil ]: 디페깅 상황 발생, 알림 전송..."); // 안드로이드 설정 AndroidConfig androidConfig = AndroidConfig.builder() @@ -49,12 +56,69 @@ public Void sendNotification( .putData("body", body) // 우선순위는 high .setAndroidConfig(androidConfig) - .setToken(memberFcm.getFcmToken()) + .setTopic(DEPEGGING_TOPIC) .build(); - // 알림 전송: 실패시 + // 알림 전송 firebaseMessaging.send(message); + } + + /** + * 디페깅 알림을 위해 구독합니다. + * @param fcmTokenList 알림을 구독할 FCM 토큰 + */ + @Retryable( + recover = "subscriberRecover" + ) + @Async + public void subscribeNotificationForDepegging( + @NotNull List fcmTokenList + ) throws FirebaseMessagingException { + + log.info("[ FcmUtil ]: 디페깅 알고리즘 구독 중..."); + firebaseMessaging.subscribeToTopic(fcmTokenList, DEPEGGING_TOPIC); + } + + /** + * 디페깅 알고리즘 구독을 취소합니다. (로그아웃) + * @param fcmTokenList 구독 취소할 FCM 토큰 + */ + @Retryable( + recover = "unsubscriberRecover" + ) + @Async + public void unsubscribeNotificationForDepegging( + @NotNull List fcmTokenList + ) throws FirebaseMessagingException { + + log.info("[ FcmUtil ]: 디페깅 알고리즘 구독 해제 중..."); + firebaseMessaging.unsubscribeFromTopic(fcmTokenList, DEPEGGING_TOPIC); + + } + + @Recover + public void sendRecover( + FirebaseMessagingException e + ) throws FirebaseException { + log.warn("[ FcmUtil ]: 디페깅 알고리즘 알림 전송 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); + } + + @Recover + public void subscriberRecover( + FirebaseMessagingException e + ) throws FirebaseException { + + log.warn("[ FcmUtil ]: 디페깅 알고리즘 구독 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); + } + + @Recover + public void unsubscriberRecover( + FirebaseMessagingException e + ) throws FirebaseException { - return null; + log.warn("[ FcmUtil ]: 디페깅 알고리즘 구독 해제 실패, {}", LocalDateTime.now()); + throw new FirebaseException(e.getErrorCode(), e.getMessage(), e.getCause()); } } From f30a5971d0b02d143a6e57eeccf396ce6302984e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Thu, 12 Feb 2026 16:06:17 +0900 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=94=A7=20fix:=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=ED=8B=80=EB=A6=B0=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=ED=82=A4=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transfer/service/TransferService.java | 310 +++++++++--------- 1 file changed, 161 insertions(+), 149 deletions(-) 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 { From 5b86755df13516d24e83246cffa766b05b7b95fa Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 16:35:35 +0900 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20feat:=20FCM=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=95=8C=EB=A6=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=95=A0=20API=20=EA=B5=AC=ED=98=84=20(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/TestController.java | 39 +++++++++++++++++++ .../scoi/global/config/SecurityConfig.java | 1 + 2 files changed, 40 insertions(+) create mode 100644 src/main/java/com/example/scoi/domain/TestController.java diff --git a/src/main/java/com/example/scoi/domain/TestController.java b/src/main/java/com/example/scoi/domain/TestController.java new file mode 100644 index 0000000..625e4cf --- /dev/null +++ b/src/main/java/com/example/scoi/domain/TestController.java @@ -0,0 +1,39 @@ +package com.example.scoi.domain; + +import com.example.scoi.domain.websocket.enums.RiseOrFall; +import com.example.scoi.global.util.FcmUtil; +import com.google.firebase.messaging.FirebaseMessagingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class TestController { + + private final FcmUtil fcmUtil; + + @GetMapping("/test") + public String test(){ + + // 테스트 + String code = "USDT"; + String current = "10000"; + String percent = "10"; + RiseOrFall riseOrFall = RiseOrFall.RISE; + + // 전체 사용자에게 알림 보내기 + try { + fcmUtil.sendNotificationForDepegging( + code+" 가격 변동 알림", + "평소보다 "+code+" 가격이 "+current+"원으로 약 "+percent+"% "+riseOrFall.name() + ); + } catch (FirebaseMessagingException e){ + log.error("[ FcmUtil ]: 알림 전송 실패, 에러 코드: {}, 스택 트레이스: {}", e.getMessagingErrorCode(), e.getStackTrace()); + } + + return "test"; + } +} diff --git a/src/main/java/com/example/scoi/global/config/SecurityConfig.java b/src/main/java/com/example/scoi/global/config/SecurityConfig.java index b017ef7..412a1d9 100644 --- a/src/main/java/com/example/scoi/global/config/SecurityConfig.java +++ b/src/main/java/com/example/scoi/global/config/SecurityConfig.java @@ -37,6 +37,7 @@ public class SecurityConfig { "/auth/login", // 로그인 "/auth/reissue", // 토큰 재발급 "/auth/password/reset", // 비인증 비밀번호 재설정 + "/test", // 임시 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", From df6a2d159c4a022d404a28933481ee2bb39071fa Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 12 Feb 2026 16:51:03 +0900 Subject: [PATCH 06/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..09296e5 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 @@ -117,7 +117,7 @@ public record ReissueRequest( public record ResetPassword( @NotNull(message = "SMS 인증 토큰은 필수입니다.") @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") - String verificationCode, + String verificationToken, @NotNull(message = "휴대전화 번호는 필수입니다.") @NotBlank(message = "휴대전화 번호는 빈칸일 수 없습니다.") From a03d7071288de6ae090a7084847717787e55f012 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 12 Feb 2026 16:51:33 +0900 Subject: [PATCH 07/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=ED=8B=80=EB=A6=B0=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/scoi/domain/auth/service/AuthService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..06a9285 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) @@ -283,7 +283,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()); From b7d92c71e0c766723cafab2f05ea043d00267fe8 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 12 Feb 2026 16:54:56 +0900 Subject: [PATCH 08/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=86=8C=20API=ED=82=A4=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=ED=95=84=EC=88=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/dto/AuthReqDTO.java | 6 +++- .../scoi/domain/auth/service/AuthService.java | 32 +++++++++---------- 2 files changed, 20 insertions(+), 18 deletions(-) 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 09296e5..7001d47 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 ) {} 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 06a9285..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 @@ -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()); From 4a6e5970ad4d2566df043584e827fa56e0981837 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 18:22:21 +0900 Subject: [PATCH 09/19] =?UTF-8?q?=E2=9C=A8=20feat:=20FCM=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=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 --- .../domain/websocket/enums/RiseOrFall.java | 2 ++ .../com/example/scoi/global/util/FcmUtil.java | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java index 3f11313..45c4a81 100644 --- a/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java +++ b/src/main/java/com/example/scoi/domain/websocket/enums/RiseOrFall.java @@ -1,7 +1,9 @@ package com.example.scoi.domain.websocket.enums; +import lombok.Getter; import lombok.RequiredArgsConstructor; +@Getter @RequiredArgsConstructor public enum RiseOrFall { RISE("올랐어요."), diff --git a/src/main/java/com/example/scoi/global/util/FcmUtil.java b/src/main/java/com/example/scoi/global/util/FcmUtil.java index 64f347f..65aa25c 100644 --- a/src/main/java/com/example/scoi/global/util/FcmUtil.java +++ b/src/main/java/com/example/scoi/global/util/FcmUtil.java @@ -63,6 +63,39 @@ public void sendNotificationForDepegging( firebaseMessaging.send(message); } + public void sendNotification( + String title, + String body, + List fcmTokenList + ){ + log.info("[ FcmUtil ]: 디페깅 상황 발생, 알림 전송..."); + + // 안드로이드 설정 + AndroidConfig androidConfig = AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) + .build(); + + for (String fcmToken : fcmTokenList) { + // 보낼 알림 구성 + Message message = Message.builder() + // payLoad는 Data만 + .putData("title", title) + .putData("body", body) + // 우선순위는 high + .setAndroidConfig(androidConfig) + .setToken(fcmToken) + .build(); + + // 알림 전송 + try{ + firebaseMessaging.send(message); + log.info("[ FcmUtil ]: 알림 전송 성공, 토큰: {}", fcmToken); + } catch (FirebaseMessagingException e){ + log.warn("[ FcmUtil ]: 알림 전송 실패, {}", e.getMessage()); + } + } + } + /** * 디페깅 알림을 위해 구독합니다. * @param fcmTokenList 알림을 구독할 FCM 토큰 From dc71b50558c511bbc1db23028b15b2c4b5616664 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 18:58:45 +0900 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=94=A5=20fix:=20FCM=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B5=AC=EB=8F=85=20=EB=B9=A0=EC=A0=B8=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.java | 18 ++++++++++++++---- .../websocket/service/WebSocketService.java | 13 ++++--------- .../com/example/scoi/global/util/FcmUtil.java | 10 +++++++--- 3 files changed, 25 insertions(+), 16 deletions(-) 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/websocket/service/WebSocketService.java b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java index 770dcdc..53bc654 100644 --- a/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java +++ b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java @@ -4,7 +4,6 @@ import com.example.scoi.domain.websocket.enums.RiseOrFall; import com.example.scoi.global.redis.RedisUtil; import com.example.scoi.global.util.FcmUtil; -import com.google.firebase.messaging.FirebaseMessagingException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -85,14 +84,10 @@ public void ticker(UpbitResDTO.Ticker dto) { RiseOrFall riseOrFall = (percent.compareTo("0.00") >= 0)? RiseOrFall.RISE : RiseOrFall.FALL; // 전체 사용자에게 알림 보내기 - try { - fcmUtil.sendNotificationForDepegging( - code+" 가격 변동 알림", - "평소보다 "+code+" 가격이 "+dto.tp()+"원으로 약 "+percent+"% "+riseOrFall.name() - ); - } catch (FirebaseMessagingException e){ - log.error("[ FcmUtil ]: 알림 전송 실패, 에러 코드: {}, 스택 트레이스: {}", e.getMessagingErrorCode(), e.getStackTrace()); - } + fcmUtil.sendNotificationForDepegging( + code+" 가격 변동 알림", + "평소보다 "+code+" 가격이 "+dto.tp()+"원으로 약 "+percent+"% "+riseOrFall.name() + ); // 쿨타임 저장: TTL 쿨타임 저장 시간 (1시간) redisUtil.set( diff --git a/src/main/java/com/example/scoi/global/util/FcmUtil.java b/src/main/java/com/example/scoi/global/util/FcmUtil.java index 64f347f..500107a 100644 --- a/src/main/java/com/example/scoi/global/util/FcmUtil.java +++ b/src/main/java/com/example/scoi/global/util/FcmUtil.java @@ -31,7 +31,6 @@ public class FcmUtil { * 디페깅 발생시 유저에게 알림을 전송합니다. * @param title 보낼 알림의 제목 * @param body 보낼 알림의 내용 - * @throws FirebaseMessagingException 실패시 발생 */ @Retryable( recover = "sendRecover" @@ -40,7 +39,7 @@ public class FcmUtil { public void sendNotificationForDepegging( @NotNull String title, @NotNull String body - ) throws FirebaseMessagingException { + ){ log.info("[ FcmUtil ]: 디페깅 상황 발생, 알림 전송..."); @@ -60,7 +59,12 @@ public void sendNotificationForDepegging( .build(); // 알림 전송 - firebaseMessaging.send(message); + try{ + firebaseMessaging.send(message); + log.info("[ FcmUtil ]: 알림 전송 성공, 전송 토픽: {}", DEPEGGING_TOPIC); + } catch (FirebaseMessagingException e){ + log.warn("[ FcmUtil ]: 알림 전송 실패"); + } } /** From 696b7229a0aae785c9ad4f3d963673cc93715b13 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 19:10:08 +0900 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=94=A5=20fix:=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=EB=90=98=EA=B2=8C=EB=81=94=20=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/TestController.java | 13 +++----- .../com/example/scoi/global/util/FcmUtil.java | 33 ------------------- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/TestController.java b/src/main/java/com/example/scoi/domain/TestController.java index 625e4cf..b706a49 100644 --- a/src/main/java/com/example/scoi/domain/TestController.java +++ b/src/main/java/com/example/scoi/domain/TestController.java @@ -2,7 +2,6 @@ import com.example.scoi.domain.websocket.enums.RiseOrFall; import com.example.scoi.global.util.FcmUtil; -import com.google.firebase.messaging.FirebaseMessagingException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -25,14 +24,10 @@ public String test(){ RiseOrFall riseOrFall = RiseOrFall.RISE; // 전체 사용자에게 알림 보내기 - try { - fcmUtil.sendNotificationForDepegging( - code+" 가격 변동 알림", - "평소보다 "+code+" 가격이 "+current+"원으로 약 "+percent+"% "+riseOrFall.name() - ); - } catch (FirebaseMessagingException e){ - log.error("[ FcmUtil ]: 알림 전송 실패, 에러 코드: {}, 스택 트레이스: {}", e.getMessagingErrorCode(), e.getStackTrace()); - } + fcmUtil.sendNotificationForDepegging( + code+" 가격 변동 알림", + "평소보다 "+code+" 가격이 "+current+"원으로 약 "+percent+"% "+riseOrFall.name() + ); return "test"; } diff --git a/src/main/java/com/example/scoi/global/util/FcmUtil.java b/src/main/java/com/example/scoi/global/util/FcmUtil.java index 7bace07..500107a 100644 --- a/src/main/java/com/example/scoi/global/util/FcmUtil.java +++ b/src/main/java/com/example/scoi/global/util/FcmUtil.java @@ -67,39 +67,6 @@ public void sendNotificationForDepegging( } } - public void sendNotification( - String title, - String body, - List fcmTokenList - ){ - log.info("[ FcmUtil ]: 디페깅 상황 발생, 알림 전송..."); - - // 안드로이드 설정 - AndroidConfig androidConfig = AndroidConfig.builder() - .setPriority(AndroidConfig.Priority.HIGH) - .build(); - - for (String fcmToken : fcmTokenList) { - // 보낼 알림 구성 - Message message = Message.builder() - // payLoad는 Data만 - .putData("title", title) - .putData("body", body) - // 우선순위는 high - .setAndroidConfig(androidConfig) - .setToken(fcmToken) - .build(); - - // 알림 전송 - try{ - firebaseMessaging.send(message); - log.info("[ FcmUtil ]: 알림 전송 성공, 토큰: {}", fcmToken); - } catch (FirebaseMessagingException e){ - log.warn("[ FcmUtil ]: 알림 전송 실패, {}", e.getMessage()); - } - } - } - /** * 디페깅 알림을 위해 구독합니다. * @param fcmTokenList 알림을 구독할 FCM 토큰 From b8b71e4c79ad2d6deefd59a441072fd80799a6f9 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 12 Feb 2026 19:21:47 +0900 Subject: [PATCH 12/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B0=84=EB=8B=A8=ED=95=9C=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java | 1 + 1 file changed, 1 insertion(+) 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 7001d47..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 @@ -121,6 +121,7 @@ public record ReissueRequest( public record ResetPassword( @NotNull(message = "SMS 인증 토큰은 필수입니다.") @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") + @Schema(description = "POST /auth/sms/verify 응답으로 받은 verificationToken") String verificationToken, @NotNull(message = "휴대전화 번호는 필수입니다.") From 6333a93ab2ed8145cf01ef468bee56828887cdfe Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Thu, 12 Feb 2026 19:46:17 +0900 Subject: [PATCH 13/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../invest/controller/InvestController.java | 23 ---------- .../controller/InvestControllerDocs.java | 11 ----- .../domain/invest/service/InvestService.java | 44 ------------------- 3 files changed, 78 deletions(-) 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( From f882812af57cb679f75bec8eaaf2c032ccdc86ff Mon Sep 17 00:00:00 2001 From: Koma <103755402+komascode@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:13:24 +0900 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=9A=80=20chore:=20README.md=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 --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 1660f85..33448e2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,40 @@ # 스코이 : 편리한 스테이블 코인 결제 플랫폼 +## 💡 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 From 2f38b87f766e61a525a873b461b7422771c7c663 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 12 Feb 2026 21:43:32 +0900 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=8E=AF=20chore:=20=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=AF=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 33448e2..45d9dcf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 스코이는 스테이블코인을 일상적인 결제 수단으로 사용할 수 있도록 설계된 금융 플랫폼입니다. 결제부터 투자까지, 누구나 쉽게 사용할 수 있는 편리한 스테이블 코인 금융 환경을 제공하는 것을 목표로 합니다. -스코이 송금 메인화면 +스코이 송금 메인화면 ## 🎯 주요 기능 @@ -31,9 +31,9 @@ ## 👥 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) | +| **마크/김주헌** | **호/원종호** | **띵/장명준** | **드로코드/김민규** | **희동/서희정** | +|:-------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------:| :---: | :---: | :---: | +| [
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 From bef8347fdd86d66959e7dadffce9f2a7d9e2fa69 Mon Sep 17 00:00:00 2001 From: Koma <103755402+komascode@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:50:01 +0900 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=9A=80=20chore:=20README.md=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 --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45d9dcf..f20d01b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 스코이는 스테이블코인을 일상적인 결제 수단으로 사용할 수 있도록 설계된 금융 플랫폼입니다. 결제부터 투자까지, 누구나 쉽게 사용할 수 있는 편리한 스테이블 코인 금융 환경을 제공하는 것을 목표로 합니다. -스코이 송금 메인화면 +image ## 🎯 주요 기능 @@ -33,7 +33,7 @@ | **마크/김주헌** | **호/원종호** | **띵/장명준** | **드로코드/김민규** | **희동/서희정** | |:-------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------:| :---: | :---: | :---: | -| [
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) | +| [
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 @@ -76,3 +76,6 @@ | └── redis # Redis └── ScoiApplication ``` +### 서버 아키텍처 + +서버 아키텍처 From 320500af6680c2aa38f2dc684a1bd224d9aa92ae Mon Sep 17 00:00:00 2001 From: Koma <103755402+komascode@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:17:09 +0900 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=9A=80=20chore:=20README.md=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 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f20d01b..6ecb84f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,11 @@ 스코이는 스테이블코인을 일상적인 결제 수단으로 사용할 수 있도록 설계된 금융 플랫폼입니다. 결제부터 투자까지, 누구나 쉽게 사용할 수 있는 편리한 스테이블 코인 금융 환경을 제공하는 것을 목표로 합니다. +image image +image +image + ## 🎯 주요 기능 From c00371875cf8c1a17b1f6b9fd7608675905c87db Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 22:51:45 +0900 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=94=A5=20del:=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20API=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/TestController.java | 34 ------------------- .../scoi/global/config/SecurityConfig.java | 1 - 2 files changed, 35 deletions(-) delete mode 100644 src/main/java/com/example/scoi/domain/TestController.java diff --git a/src/main/java/com/example/scoi/domain/TestController.java b/src/main/java/com/example/scoi/domain/TestController.java deleted file mode 100644 index b706a49..0000000 --- a/src/main/java/com/example/scoi/domain/TestController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.scoi.domain; - -import com.example.scoi.domain.websocket.enums.RiseOrFall; -import com.example.scoi.global.util.FcmUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@Slf4j -public class TestController { - - private final FcmUtil fcmUtil; - - @GetMapping("/test") - public String test(){ - - // 테스트 - String code = "USDT"; - String current = "10000"; - String percent = "10"; - RiseOrFall riseOrFall = RiseOrFall.RISE; - - // 전체 사용자에게 알림 보내기 - fcmUtil.sendNotificationForDepegging( - code+" 가격 변동 알림", - "평소보다 "+code+" 가격이 "+current+"원으로 약 "+percent+"% "+riseOrFall.name() - ); - - return "test"; - } -} diff --git a/src/main/java/com/example/scoi/global/config/SecurityConfig.java b/src/main/java/com/example/scoi/global/config/SecurityConfig.java index 412a1d9..b017ef7 100644 --- a/src/main/java/com/example/scoi/global/config/SecurityConfig.java +++ b/src/main/java/com/example/scoi/global/config/SecurityConfig.java @@ -37,7 +37,6 @@ public class SecurityConfig { "/auth/login", // 로그인 "/auth/reissue", // 토큰 재발급 "/auth/password/reset", // 비인증 비밀번호 재설정 - "/test", // 임시 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", From a0c0331f776aa6f5ec876fd87ce646f56992698b Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 22:52:27 +0900 Subject: [PATCH 19/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Swagger?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EB=B3=80=EA=B2=BD=20(0.3.2=20->=200.4.?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/scoi/global/config/SwaggerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/scoi/global/config/SwaggerConfig.java b/src/main/java/com/example/scoi/global/config/SwaggerConfig.java index 35594cd..4add088 100644 --- a/src/main/java/com/example/scoi/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/scoi/global/config/SwaggerConfig.java @@ -14,7 +14,7 @@ public class SwaggerConfig { @Bean public OpenAPI swagger() { - Info info = new Info().title("스코이").description("스코이 Swagger").version("0.3.2"); + Info info = new Info().title("스코이").description("스코이 Swagger").version("0.4.0"); // JWT 토큰 헤더 방식 String securityScheme = "JWT TOKEN";