From c073e3c72bbeadf24ee4dfd5325dab5040d4e890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Tue, 10 Feb 2026 15:10:34 +0900 Subject: [PATCH 01/37] =?UTF-8?q?=F0=9F=94=A5=20fix:=20converter=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98=20=EB=B2=84=EA=B7=B8=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 --- .../scoi/domain/transfer/converter/TransferConverter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java index cfe8998..a198ff8 100644 --- a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java +++ b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java @@ -276,7 +276,7 @@ public static List toWithdrawRecipientsUpbit( .recipientKoName(item.beneficiary_name()) .recipientEnName(null) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.net_type())) + .exchangeType(ExchangeType.valueOf(item.exchange_name().toUpperCase())) .currency(CoinType.valueOf(item.currency())) .netType(NetworkType.valueOf(item.net_type())) .build()).toList(); @@ -293,7 +293,7 @@ public static List toWithdrawRecipientsBithum .recipientKoName(item.owner_ko_name()) .recipientEnName(item.owner_en_name()) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.net_type())) + .exchangeType(ExchangeType.valueOf(item.exchange_name().toUpperCase())) .currency(CoinType.valueOf(item.currency())) .netType(NetworkType.valueOf(item.net_type())) .build()).toList(); From 6c4fa258b999f5c08f724f94f886806c2a3a2262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Tue, 10 Feb 2026 16:21:07 +0900 Subject: [PATCH 02/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=88=98=EC=B7=A8?= =?UTF-8?q?=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EC=BD=94=EC=9D=B8=20=ED=83=80=EC=9E=85=EB=A7=8C=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/transfer/controller/TransferController.java | 6 ++++-- .../domain/transfer/controller/TransferControllerDocs.java | 4 +++- .../scoi/domain/transfer/converter/TransferConverter.java | 6 ++++-- .../scoi/domain/transfer/service/TransferService.java | 7 ++++--- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java b/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java index 592fbf5..5474c53 100644 --- a/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java +++ b/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java @@ -3,6 +3,7 @@ import com.example.scoi.domain.member.enums.ExchangeType; import com.example.scoi.domain.transfer.dto.TransferReqDTO; import com.example.scoi.domain.transfer.dto.TransferResDTO; +import com.example.scoi.domain.transfer.enums.CoinType; import com.example.scoi.domain.transfer.exception.code.TransferSuccessCode; import com.example.scoi.domain.transfer.service.TransferService; import com.example.scoi.global.apiPayload.ApiResponse; @@ -73,10 +74,11 @@ public ApiResponse changeToNotFavorite( @GetMapping("/recipients") public ApiResponse> getRecipients( @AuthenticationPrincipal CustomUserDetails user, - @RequestParam(name = "exchangeType")ExchangeType exchangeType + @RequestParam(name = "exchangeType")ExchangeType exchangeType, + @RequestParam(name = "coinType")CoinType coinType ) { return ApiResponse.onSuccess(TransferSuccessCode.TRANSFER200_1, - transferService.getRecipients(user.getUsername(), exchangeType)); + transferService.getRecipients(user.getUsername(), exchangeType, coinType)); } @PostMapping("/recipients/validate") diff --git a/src/main/java/com/example/scoi/domain/transfer/controller/TransferControllerDocs.java b/src/main/java/com/example/scoi/domain/transfer/controller/TransferControllerDocs.java index ba6aa97..ce77c3d 100644 --- a/src/main/java/com/example/scoi/domain/transfer/controller/TransferControllerDocs.java +++ b/src/main/java/com/example/scoi/domain/transfer/controller/TransferControllerDocs.java @@ -3,6 +3,7 @@ import com.example.scoi.domain.member.enums.ExchangeType; import com.example.scoi.domain.transfer.dto.TransferReqDTO; import com.example.scoi.domain.transfer.dto.TransferResDTO; +import com.example.scoi.domain.transfer.enums.CoinType; import com.example.scoi.global.apiPayload.ApiResponse; import com.example.scoi.global.security.userdetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -82,6 +83,7 @@ ApiResponse executeWithdraw( description = "사용자가 사전에 거래소에 등록한 수취인 목록을 조회합니다.") ApiResponse> getRecipients( @AuthenticationPrincipal CustomUserDetails user, - @RequestParam(name = "exchangeType")ExchangeType exchangeType + @RequestParam(name = "exchangeType")ExchangeType exchangeType, + @RequestParam(name = "coinType") CoinType coinType ); } diff --git a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java index a198ff8..974c8db 100644 --- a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java +++ b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java @@ -264,13 +264,14 @@ private static String calculateTotalAmount(String amountStr, String feeStr) { } } - public static List toWithdrawRecipientsUpbit(List upbitResult) { + public static List toWithdrawRecipientsUpbit(List upbitResult, CoinType coinType) { // 수취인이 없는 경우 빈 리스트 반환 if (upbitResult == null) { return Collections.emptyList(); } return upbitResult.stream() + .filter(item -> item.currency().equals(String.valueOf(coinType))) .map(item -> TransferResDTO.WithdrawRecipients.builder() .memberType(MemberType.from(item.beneficiary_type())) .recipientKoName(item.beneficiary_name()) @@ -281,13 +282,14 @@ public static List toWithdrawRecipientsUpbit( .netType(NetworkType.valueOf(item.net_type())) .build()).toList(); } - public static List toWithdrawRecipientsBithumb(List bithumbResult) { + public static List toWithdrawRecipientsBithumb(List bithumbResult, CoinType coinType) { // 수취인이 없는 경우 빈 리스트 반환 if (bithumbResult == null) { return Collections.emptyList(); } return bithumbResult.stream() + .filter(item -> item.currency().equals(String.valueOf(coinType))) .map(item -> TransferResDTO.WithdrawRecipients.builder() .memberType(MemberType.from(item.owner_type())) .recipientKoName(item.owner_ko_name()) 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 11e84ca..25978ab 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 @@ -12,6 +12,7 @@ import com.example.scoi.domain.transfer.dto.TransferResDTO; import com.example.scoi.domain.transfer.entity.Recipient; import com.example.scoi.domain.transfer.entity.TradeHistory; +import com.example.scoi.domain.transfer.enums.CoinType; import com.example.scoi.domain.transfer.exception.TransferException; import com.example.scoi.domain.transfer.exception.code.TransferErrorCode; import com.example.scoi.domain.transfer.repository.RecipientRepository; @@ -518,7 +519,7 @@ private void validateRecipient(TransferReqDTO.RecipientInformation recipient) { } } - public List getRecipients(String phoneNumber, ExchangeType exchangeType) { + public List getRecipients(String phoneNumber, ExchangeType exchangeType, CoinType coinType) { String token; List result; try{ @@ -527,13 +528,13 @@ public List getRecipients(String phoneNumber, token = jwtApiUtil.createUpBitJwt(phoneNumber, null, null); List upbitResult = upbitClient.getRecipients(token); - result = TransferConverter.toWithdrawRecipientsUpbit(upbitResult); + result = TransferConverter.toWithdrawRecipientsUpbit(upbitResult, coinType); break; case BITHUMB: token = jwtApiUtil.createBithumbJwt(phoneNumber, null, null); List bithumbResult = bithumbClient.getRecipients(token); - result = TransferConverter.toWithdrawRecipientsBithumb(bithumbResult); + result = TransferConverter.toWithdrawRecipientsBithumb(bithumbResult, coinType); break; default: throw new TransferException(TransferErrorCode.UNSUPPORTED_EXCHANGE); From 084a0822bf44030c1587201ef4d4787d990b15e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Tue, 10 Feb 2026 20:29:56 +0900 Subject: [PATCH 03/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=88=98=EC=B7=A8?= =?UTF-8?q?=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=20=EC=BD=94=EC=9D=B8=20=EC=A7=80=EA=B0=91=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EB=A7=8C=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/enums/ExchangeType.java | 11 +++++---- .../controller/TransferController.java | 2 ++ .../transfer/converter/TransferConverter.java | 4 ++-- .../scoi/global/client/dto/BithumbResDTO.java | 23 ++++++++++--------- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/member/enums/ExchangeType.java b/src/main/java/com/example/scoi/domain/member/enums/ExchangeType.java index e2b0655..f4f5aab 100644 --- a/src/main/java/com/example/scoi/domain/member/enums/ExchangeType.java +++ b/src/main/java/com/example/scoi/domain/member/enums/ExchangeType.java @@ -4,13 +4,16 @@ import com.example.scoi.domain.charge.exception.code.ChargeErrorCode; public enum ExchangeType { - BITHUMB("Bithumb"), - UPBIT("Upbit"); + BITHUMB("Bithumb", "빗썸"), + UPBIT("Upbit", "업비트"); private final String displayName; + private final String koreanName; + + ExchangeType(String displayName, String koreanName) { - ExchangeType(String displayName) { this.displayName = displayName; + this.koreanName = koreanName; } public String getDisplayName() { @@ -22,7 +25,7 @@ public String getDisplayName() { public static ExchangeType fromString(String value) { for (ExchangeType type : values()) { - if (type.displayName.equalsIgnoreCase(value)) { + if (type.displayName.equalsIgnoreCase(value) || type.koreanName.equals(value)) { return type; } } diff --git a/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java b/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java index 5474c53..53631df 100644 --- a/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java +++ b/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java @@ -10,6 +10,7 @@ import com.example.scoi.global.security.userdetails.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -18,6 +19,7 @@ @RestController @RequestMapping("/api/transfers") @RequiredArgsConstructor +@Slf4j public class TransferController implements TransferControllerDocs{ private final TransferService transferService; diff --git a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java index 974c8db..233bd63 100644 --- a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java +++ b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java @@ -277,7 +277,7 @@ public static List toWithdrawRecipientsUpbit( .recipientKoName(item.beneficiary_name()) .recipientEnName(null) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.exchange_name().toUpperCase())) + .exchangeType(ExchangeType.fromString(item.exchange_name().toUpperCase())) .currency(CoinType.valueOf(item.currency())) .netType(NetworkType.valueOf(item.net_type())) .build()).toList(); @@ -295,7 +295,7 @@ public static List toWithdrawRecipientsBithum .recipientKoName(item.owner_ko_name()) .recipientEnName(item.owner_en_name()) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.exchange_name().toUpperCase())) + .exchangeType(ExchangeType.fromString(item.exchange_name().toUpperCase())) .currency(CoinType.valueOf(item.currency())) .netType(NetworkType.valueOf(item.net_type())) .build()).toList(); diff --git a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java index 813edae..1611725 100644 --- a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java +++ b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java @@ -1,6 +1,7 @@ package com.example.scoi.global.client.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @@ -301,17 +302,17 @@ public record CancelOrder( // 출금 허용 주소 리스트 조회 (수취인 조회) public record WithdrawalAddressResponse( - String currency, - String net_type, - String network_name, - String withdraw_address, - String secondary_address, - String exchange_name, - String owner_type, - String owner_ko_name, - String owner_en_name, - String owner_corp_ko_name, - String owner_corp_en_name + @JsonProperty("currency") String currency, + @JsonProperty("net_type") String net_type, + @JsonProperty("network_name") String network_name, + @JsonProperty("withdraw_address") String withdraw_address, + @JsonProperty("secondary_address") String secondary_address, + @JsonProperty("exchange_name") String exchange_name, + @JsonProperty("owner_type") String owner_type, + @JsonProperty("owner_ko_name") String owner_ko_name, + @JsonProperty("owner_en_name") String owner_en_name, + @JsonProperty("owner_corp_ko_name") String owner_corp_ko_name, + @JsonProperty("owner_corp_en_name") String owner_corp_en_name ) {} // 현재가 조회 (Ticker) From c9a811b648d0dde36129f1636f7a200e2beb0221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Tue, 10 Feb 2026 20:45:14 +0900 Subject: [PATCH 04/37] =?UTF-8?q?=E2=9C=A8=20feat:=20DTO=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/scoi/domain/transfer/dto/TransferReqDTO.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java b/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java index bf9cdc7..50db7d8 100644 --- a/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java +++ b/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java @@ -55,10 +55,12 @@ public record RecipientInformation( public record Quote( @Schema(description = "출금 가능 금액", example = "10") @NotBlank(message = "출금 가능 금액은 필수입니다.") + @Pattern(regexp = "^[0-9]+$", message = "정수만 입력 가능합니다.") String available, @Schema(description = "출금할 금액", example = "5") @NotBlank(message = "출금할 금액은 필수입니다.") + @Pattern(regexp = "^[0-9]+$", message = "정수만 입력 가능합니다.") String amount, @Schema(description = "화폐 코드 (대문자)", example = "USDT") @@ -69,6 +71,7 @@ public record Quote( @Schema(description = "네트워크 수수료", example = "1") @NotBlank(message = "네트워크 수수료는 필수입니다.") + @Pattern(regexp = "^[0-9]+$", message = "정수만 입력 가능합니다.") String networkFee ) {} From edf432fc26509baf9972242218458c91bc84d103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Tue, 10 Feb 2026 20:45:55 +0900 Subject: [PATCH 05/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/global/client/dto/BithumbResDTO.java | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java index 1611725..813edae 100644 --- a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java +++ b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java @@ -1,7 +1,6 @@ package com.example.scoi.global.client.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @@ -302,17 +301,17 @@ public record CancelOrder( // 출금 허용 주소 리스트 조회 (수취인 조회) public record WithdrawalAddressResponse( - @JsonProperty("currency") String currency, - @JsonProperty("net_type") String net_type, - @JsonProperty("network_name") String network_name, - @JsonProperty("withdraw_address") String withdraw_address, - @JsonProperty("secondary_address") String secondary_address, - @JsonProperty("exchange_name") String exchange_name, - @JsonProperty("owner_type") String owner_type, - @JsonProperty("owner_ko_name") String owner_ko_name, - @JsonProperty("owner_en_name") String owner_en_name, - @JsonProperty("owner_corp_ko_name") String owner_corp_ko_name, - @JsonProperty("owner_corp_en_name") String owner_corp_en_name + String currency, + String net_type, + String network_name, + String withdraw_address, + String secondary_address, + String exchange_name, + String owner_type, + String owner_ko_name, + String owner_en_name, + String owner_corp_ko_name, + String owner_corp_en_name ) {} // 현재가 조회 (Ticker) From a086745a384e7af45ba176b18281c4f99e05f57f Mon Sep 17 00:00:00 2001 From: JuHeon Date: Tue, 10 Feb 2026 21:31:50 +0900 Subject: [PATCH 06/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../charge/controller/ChargeController.java | 7 ++++--- .../controller/ChargeControllerDocs.java | 17 +++++++++++++---- .../scoi/domain/charge/dto/ChargeReqDTO.java | 13 +++++++++++++ .../domain/charge/service/ChargeService.java | 1 + .../member/controller/MemberController.java | 12 +++++++----- .../controller/MemberControllerDocs.java | 15 +++++++++------ .../scoi/domain/member/dto/MemberReqDTO.java | 18 ++++++++++++++++++ 7 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/charge/controller/ChargeController.java b/src/main/java/com/example/scoi/domain/charge/controller/ChargeController.java index 70335be..03f141b 100644 --- a/src/main/java/com/example/scoi/domain/charge/controller/ChargeController.java +++ b/src/main/java/com/example/scoi/domain/charge/controller/ChargeController.java @@ -9,6 +9,7 @@ import com.example.scoi.global.apiPayload.ApiResponse; import com.example.scoi.global.apiPayload.code.BaseSuccessCode; import com.example.scoi.global.security.userdetails.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -28,7 +29,7 @@ public class ChargeController implements ChargeControllerDocs{ @PostMapping("/deposits/krw") public ApiResponse chargeKrw( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody ChargeReqDTO.ChargeKrw dto + @Valid @RequestBody ChargeReqDTO.ChargeKrw dto ){ BaseSuccessCode code = ChargeSuccessCode.OK; return ApiResponse.onSuccess(code, chargeService.chargeKrw(user.getUsername(),dto)); @@ -38,7 +39,7 @@ public ApiResponse chargeKrw( @PostMapping("/deposits") public ApiResponse getOrders( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody ChargeReqDTO.GetOrder dto + @Valid @RequestBody ChargeReqDTO.GetOrder dto ){ BaseSuccessCode code = ChargeSuccessCode.OK; return ApiResponse.onSuccess(code, chargeService.getOrders(user.getUsername(), dto)); @@ -78,7 +79,7 @@ public ApiResponse getDepositAddress( @PostMapping("/deposits/address") public ApiResponse> createDepositAddress( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody ChargeReqDTO.CreateDepositAddress dto + @Valid @RequestBody ChargeReqDTO.CreateDepositAddress dto ){ BaseSuccessCode code = ChargeSuccessCode.OK; return ApiResponse.onSuccess(code, chargeService.createDepositAddress(user.getUsername(), dto)); diff --git a/src/main/java/com/example/scoi/domain/charge/controller/ChargeControllerDocs.java b/src/main/java/com/example/scoi/domain/charge/controller/ChargeControllerDocs.java index ed9e869..dbace4d 100644 --- a/src/main/java/com/example/scoi/domain/charge/controller/ChargeControllerDocs.java +++ b/src/main/java/com/example/scoi/domain/charge/controller/ChargeControllerDocs.java @@ -8,6 +8,7 @@ import com.example.scoi.global.security.userdetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -21,13 +22,13 @@ public interface ChargeControllerDocs { summary = "원화 충전 요청하기 API By 김주헌", description = "코인을 구매하기 위한 원화 충전을 요청합니다. 반드시 인증서 발급을 한 뒤 호출해주세요." ) - ApiResponse chargeKrw(@AuthenticationPrincipal CustomUserDetails user, @RequestBody ChargeReqDTO.ChargeKrw dto); + ApiResponse chargeKrw(@AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody ChargeReqDTO.ChargeKrw dto); @Operation( summary = "특정 주문 확인하기 API By 김주헌", description = "특정 주문을 UUID로 스냅샷 형태로 확인합니다. 주문 체결 알림은 웹소켓 이용해서 실시간 추적, 체결 되면 FCM 토큰으로 알림이 갑니다." ) - ApiResponse getOrders(@AuthenticationPrincipal CustomUserDetails user, @RequestBody ChargeReqDTO.GetOrder dto); + ApiResponse getOrders(@AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody ChargeReqDTO.GetOrder dto); @Operation( summary = "보유 자산 조회 API By 강서현", @@ -52,10 +53,18 @@ ApiResponse getDepositAddress( description = """ 코인의 입금 주소를 생성합니다. 각 거래소에 생성 요청을 보내기때문에 입금 주소가 즉시 안 올 수 있습니다. (비동기) - 따라서 주소가 필요하면 생성 → 조회 순으로 요청을 보내주세요""" + 따라서 주소가 필요하면 생성 → 조회 순으로 요청을 보내주세요 + + ** 거래소 별 가능한 코인 심볼 - 네트워크 타입 + 업비트 USDT: [ETH, TRX, APT, KAIA] + 업비트 USDC: [ETH, SOL] + 빗썸 USDT: [ETH, TRX, APT, KAIA] + 빗썸 USDC: [ETH] + ** + """ ) ApiResponse> createDepositAddress( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody ChargeReqDTO.CreateDepositAddress dto + @Valid @RequestBody ChargeReqDTO.CreateDepositAddress dto ); } diff --git a/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java b/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java index 61d83a6..aadd6db 100644 --- a/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java +++ b/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java @@ -3,6 +3,9 @@ import com.example.scoi.domain.charge.enums.DepositType; import com.example.scoi.domain.charge.enums.MFAType; import com.example.scoi.domain.member.enums.ExchangeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.checkerframework.checker.units.qual.N; import java.util.List; @@ -10,22 +13,32 @@ public class ChargeReqDTO { // 원화 입금 public record ChargeKrw( + @NotNull(message = "거래소 타입은 필수입니다. (BITHUMB, UPBIT)") ExchangeType exchangeType, + @NotNull(message = "충전할 금액은 필수입니다.") Long amount, + @NotNull(message = "인증서 타입은 필수입니다. (KAKAO, NAVER, HANA)") MFAType MFA ){} // 특정 주문 확인하기 public record GetOrder( + @NotNull(message = "거래소 타입은 필수입니다.") ExchangeType exchangeType, + @NotNull(message = "UUID는 필수입니다.") + @NotBlank(message = "UUID가 빈칸일 수 없습니다.") String uuid, + @NotNull(message = "거래 타입은 필수입니다.") DepositType depositType ){} // 입금 주소 생성하기 public record CreateDepositAddress( + @NotNull(message = "거래소 타입은 필수입니다.") ExchangeType exchangeType, + @NotNull(message = "코인 타입은 필수입니다.") List coinType, + @NotNull(message = "네트워크 타입은 필수입니다.") List netType ){} } diff --git a/src/main/java/com/example/scoi/domain/charge/service/ChargeService.java b/src/main/java/com/example/scoi/domain/charge/service/ChargeService.java index 3ecd534..f7ab203 100644 --- a/src/main/java/com/example/scoi/domain/charge/service/ChargeService.java +++ b/src/main/java/com/example/scoi/domain/charge/service/ChargeService.java @@ -47,6 +47,7 @@ public ChargeResDTO.ChargeKrw chargeKrw( if (dto.exchangeType().equals(ExchangeType.BITHUMB) && !dto.MFA().equals(MFAType.KAKAO)){ throw new ChargeException(ChargeErrorCode.INVALIDED_TWO_FACTOR_AUTH); } + // 거래소별 분기 String token; String uuid, txid; diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java index 7860e6f..d14adf8 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java @@ -9,6 +9,8 @@ import com.example.scoi.global.apiPayload.code.BaseSuccessCode; import com.example.scoi.global.apiPayload.code.GeneralErrorCode; import com.example.scoi.global.security.userdetails.CustomUserDetails; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -36,7 +38,7 @@ public ApiResponse getMemberInfo( // 간편 비밀번호 변경 @PatchMapping("/members/me/password") public ApiResponse> changePassword( - @RequestBody MemberReqDTO.ChangePassword dto, + @Valid @RequestBody MemberReqDTO.ChangePassword dto, @AuthenticationPrincipal CustomUserDetails user ){ Optional> result = memberService.changePassword(dto, user.getUsername()); @@ -52,7 +54,7 @@ public ApiResponse> changePassword( // 간편 비밀번호 재설정 @PostMapping("/members/me/password/reset") public ApiResponse resetPassword( - @RequestBody MemberReqDTO.ResetPassword dto, + @Valid @RequestBody MemberReqDTO.ResetPassword dto, @AuthenticationPrincipal CustomUserDetails user ){ BaseSuccessCode code = MemberSuccessCode.RESET_SIMPLE_PASSWORD; @@ -85,7 +87,7 @@ public ApiResponse> getApiKeyList( @PostMapping("/members/me/api-keys") public ApiResponse> postPatchApiKey( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody List dto + @Valid @RequestBody List dto ){ BaseSuccessCode code = MemberSuccessCode.POST_PATCH_API_KEY; List result = memberService.postPatchApiKey(user.getUsername(), dto); @@ -99,7 +101,7 @@ public ApiResponse> postPatchApiKey( @DeleteMapping("/members/me/api-keys") public ApiResponse deleteApiKey( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody MemberReqDTO.DeleteApiKey dto + @Valid @RequestBody MemberReqDTO.DeleteApiKey dto ){ BaseSuccessCode code = MemberSuccessCode.DELETE_API_KEY; return ApiResponse.onSuccess(code, memberService.deleteApiKey(user.getUsername(), dto)); @@ -109,7 +111,7 @@ public ApiResponse deleteApiKey( @PostMapping("/members/me/fcm") public ApiResponse postFcmToken( @AuthenticationPrincipal CustomUserDetails user, - @RequestBody MemberReqDTO.PostFcmToken dto + @Valid @RequestBody MemberReqDTO.PostFcmToken dto ){ BaseSuccessCode code = MemberSuccessCode.POST_PATCH_FCM_TOKEN; return ApiResponse.onSuccess(code, memberService.postFcmToken(user.getUsername(), dto)); diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java index be7a2ae..c23cbd4 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java @@ -6,6 +6,8 @@ import com.example.scoi.global.security.userdetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; @@ -26,13 +28,13 @@ public interface MemberControllerDocs { summary = "간편 비밀번호 변경 API By 김주헌", description = "간편 비밀번호를 변경합니다." ) - ApiResponse> changePassword(@RequestBody MemberReqDTO.ChangePassword dto, @AuthenticationPrincipal CustomUserDetails user) throws GeneralSecurityException; + ApiResponse> changePassword(@Valid @RequestBody MemberReqDTO.ChangePassword dto, @AuthenticationPrincipal CustomUserDetails user) throws GeneralSecurityException; @Operation( summary = "간편 비밀번호 재설정 API By 김주헌", description = "비밀번호 분실 또는 5회 실패 시 재설정을 합니다." ) - ApiResponse resetPassword(@RequestBody MemberReqDTO.ResetPassword dto, @AuthenticationPrincipal CustomUserDetails user); + ApiResponse resetPassword(@Valid @RequestBody MemberReqDTO.ResetPassword dto, @AuthenticationPrincipal CustomUserDetails user); @Operation( summary = "거래소 목록 조회 API By 김주헌", @@ -48,19 +50,20 @@ public interface MemberControllerDocs { @Operation( summary = "API키 등록 및 수정 API By 김주헌", - description = "연동된 거래소의 API키를 등록 및 수정을 합니다." + description = "연동된 거래소의 API키를 등록 및 수정을 합니다. 등록, 수정 적용이 되었을 경우 해당 거래소 타입을 result에 담아 보냅니다." ) - ApiResponse> postPatchApiKey(@AuthenticationPrincipal CustomUserDetails user, @RequestBody List dto); + ApiResponse> postPatchApiKey(@AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody List dto); @Operation( summary = "API키 삭제 API By 김주헌", description = "연동된 거래소의 API키를 삭제합니다." ) - ApiResponse deleteApiKey(@AuthenticationPrincipal CustomUserDetails user, @RequestBody MemberReqDTO.DeleteApiKey dto); + ApiResponse deleteApiKey(@AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody MemberReqDTO.DeleteApiKey dto); + @Operation( summary = "FCM 토큰 등록 API By 김주헌", description = "FCM 토큰을 등록합니다." ) - ApiResponse postFcmToken(@AuthenticationPrincipal CustomUserDetails user, @RequestBody MemberReqDTO.PostFcmToken dto); + ApiResponse postFcmToken(@AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody MemberReqDTO.PostFcmToken dto); } diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index 7edb298..bb759d2 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -1,35 +1,53 @@ package com.example.scoi.domain.member.dto; import com.example.scoi.domain.member.enums.ExchangeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public class MemberReqDTO { // 간편 비밀번호 변경 public record ChangePassword( + @NotNull(message = "기존 간편 비밀번호는 필수입니다.") + @NotBlank(message = "기존 간편 비밀번호는 빈칸일 수 없습니다.") String oldPassword, + @NotNull(message = "신규 간편 비밀번호는 필수입니다.") + @NotBlank(message = "신규 간편 비밀번호는 빈칸일 수 없습니다.") String newPassword ){} // 간편 비밀번호 재설정 public record ResetPassword( + @NotNull(message = "SMS 인증 토큰은 필수입니다.") + @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") String verificationCode, + @NotNull(message = "신규 간편 비밀번호는 필수입니다.") + @NotBlank(message = "신규 간편 비밀번호는 빈칸일 수 없습니다.") String newPassword ){} // API키 등록 및 수정 public record PostPatchApiKey( + @NotNull(message = "거래소 타입은 빈칸일 수 없습니다.") ExchangeType exchangeType, + @NotNull(message = "거래소 퍼블릭 키는 필수입니다.") + @NotBlank(message = "거래소 퍼블릭 키는 빈칸일 수 없습니다.") String publicKey, + @NotNull(message = "거래소 시크릿 키는 필수입니다.") + @NotBlank(message = "거래소 시크릿 키는 빈칸일 수 없습니다.") String secretKey ){} // API키 삭제 public record DeleteApiKey( + @NotNull(message = "거래소 타입은 빈칸일 수 없습니다.") ExchangeType exchangeType ){} // FCM 토큰 등록 public record PostFcmToken( + @NotNull(message = "FCM 토큰은 필수입니다.") + @NotBlank(message = "FCM 토큰은 빈칸일 수 없습니다.") String token ){} } From 3c491f5074e97abffb00c94bf94d054f2a520e40 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Tue, 10 Feb 2026 21:32:30 +0900 Subject: [PATCH 07/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20GeneralE?= =?UTF-8?q?xceptionAdvice=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(HttpMessageNotReadableException)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java | 1 - .../example/scoi/domain/member/controller/MemberController.java | 1 - .../scoi/domain/member/controller/MemberControllerDocs.java | 1 - .../scoi/global/apiPayload/handler/GeneralExceptionAdvice.java | 2 +- 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java b/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java index aadd6db..4f2e749 100644 --- a/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java +++ b/src/main/java/com/example/scoi/domain/charge/dto/ChargeReqDTO.java @@ -5,7 +5,6 @@ import com.example.scoi.domain.member.enums.ExchangeType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import org.checkerframework.checker.units.qual.N; import java.util.List; diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java index d14adf8..76627ef 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java @@ -10,7 +10,6 @@ import com.example.scoi.global.apiPayload.code.GeneralErrorCode; import com.example.scoi.global.security.userdetails.CustomUserDetails; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java index c23cbd4..8f1cd47 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java @@ -7,7 +7,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; diff --git a/src/main/java/com/example/scoi/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/example/scoi/global/apiPayload/handler/GeneralExceptionAdvice.java index 32ea3cf..5c6a45f 100644 --- a/src/main/java/com/example/scoi/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/example/scoi/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -161,7 +161,7 @@ public ResponseEntity> handleHttpMessageNotReadableException( ); return ResponseEntity.status(GeneralErrorCode.JSON_PARSE_FAIL.getStatus()).body(errorResponse); } else if (ex.getMessage().contains("JSON parse error:")){ - log.warn("[ ]: Request Body 파싱에 실패했습니다."); + log.warn("[ HttpMessageNotReadableException ]: Request Body 파싱에 실패했습니다."); ApiResponse errorResponse = ApiResponse.onFailure( GeneralErrorCode.JSON_PARSE_FAIL, From 5c631552082072367dfcdc0ebe20402b9117d6fd Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 00:15:31 +0900 Subject: [PATCH 08/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20=EC=A0=95=EB=B3=B4=20DTO=EC=97=90=20mar?= =?UTF-8?q?ket.bid/ask=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/scoi/global/client/dto/BithumbResDTO.java | 4 +++- .../java/com/example/scoi/global/client/dto/UpbitResDTO.java | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java index 813edae..0c127b6 100644 --- a/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java +++ b/src/main/java/com/example/scoi/global/client/dto/BithumbResDTO.java @@ -157,7 +157,9 @@ public record Market( List order_types, // deprecated List order_sides, List bid_types, - List ask_types + List ask_types, + Bid bid, // market.bid.min_total에 최소 매수 금액 + Ask ask // market.ask.min_total에 최소 매도 금액 ){} // 주문 가능 정보 조회 - Bid diff --git a/src/main/java/com/example/scoi/global/client/dto/UpbitResDTO.java b/src/main/java/com/example/scoi/global/client/dto/UpbitResDTO.java index 5d38537..3e17dae 100644 --- a/src/main/java/com/example/scoi/global/client/dto/UpbitResDTO.java +++ b/src/main/java/com/example/scoi/global/client/dto/UpbitResDTO.java @@ -177,7 +177,10 @@ public record Market( List order_types, // deprecated List order_sides, List bid_types, - List ask_types + List ask_types, + Bid bid, // market.bid.min_total에 최소 매수 금액 + Ask ask, // market.ask.min_total에 최소 매도 금액 + String max_total // market.max_total에 최대 주문 금액 ){} // 주문 가능 정보 조회 - Bid From ef0c2a9f29368185f115a492821f69a5540066c5 Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 00:16:11 +0900 Subject: [PATCH 09/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=20=EB=B9=97=EC=8D=B8?= =?UTF-8?q?=20&=20=EC=97=85=EB=B9=84=ED=8A=B8=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EA=B8=88=EC=95=A1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A7=88=EC=BC=93=EB=B3=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/adapter/BithumbApiClient.java | 145 +++++++++++++- .../invest/client/adapter/UpbitApiClient.java | 185 +++++++++++++++++- 2 files changed, 326 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java index f0e7ec4..e740296 100644 --- a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java +++ b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java @@ -374,11 +374,41 @@ private BithumbResDTO.OrderChance getOrderChance(String phoneNumber, String mark log.info("빗썸 API 응답 수신 완료"); + // 원본 JSON 응답 확인을 위해 ObjectMapper로 직렬화 (디버깅용) + try { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + String jsonResponse = objectMapper.writeValueAsString(orderChance); + log.info("빗썸 API 응답 원본 JSON: {}", jsonResponse); + } catch (Exception e) { + log.warn("빗썸 API 응답 JSON 직렬화 실패: {}", e.getMessage()); + } + + // 전체 응답 구조 확인 (디버깅용) + log.info("빗썸 API 응답 전체 구조 - orderChance: {}", orderChance); + log.info("빗썸 API 응답 - bid_fee: {}, ask_fee: {}, market: {}, bid: {}, ask: {}, bid_account: {}, ask_account: {}", + orderChance.bid_fee(), orderChance.ask_fee(), orderChance.market(), + orderChance.bid(), orderChance.ask(), orderChance.bid_account(), orderChance.ask_account()); + // API 응답에서 실제 마켓 형식 확인 if (orderChance.market() != null && orderChance.market().id() != null) { log.info("빗썸 API 응답 market.id: {} (요청한 market: {})", orderChance.market().id(), convertedMarket); } + // bid 객체 확인 (최소 주문 금액 검증용) + if (orderChance.bid() != null) { + log.info("빗썸 API 응답 bid 객체 존재 - currency: {}, min_total: {}", + orderChance.bid().currency(), orderChance.bid().min_total()); + } else { + log.warn("빗썸 API 응답 bid 객체가 null입니다! - 최소 주문 금액 검증 불가"); + // ask 객체도 확인 + if (orderChance.ask() != null) { + log.info("빗썸 API 응답 ask 객체 존재 - currency: {}, min_total: {}", + orderChance.ask().currency(), orderChance.ask().min_total()); + } else { + log.warn("빗썸 API 응답 ask 객체도 null입니다!"); + } + } + if (orderChance.bid_account() != null || orderChance.ask_account() != null) { return orderChance; } @@ -498,6 +528,62 @@ private void validateOrderAvailability( requiredAmountStr = requiredAmount.toPlainString(); + // 1단계: 최소 주문 금액 검증 (주문 금액이 최소 주문 금액을 넘는지 확인) + log.info("빗썸 최소 주문 금액 검증 시작 - 주문 금액: {}", requiredAmount); + + // 빗썸 API 문서에 따르면 market.bid.min_total 또는 market.ask.min_total에 최소 주문 금액이 있음 + BithumbResDTO.Bid bid = null; + String minTotalStr = null; + + // 1순위: orderChance.bid() 확인 + if (orderChance.bid() != null && orderChance.bid().min_total() != null && !orderChance.bid().min_total().isEmpty()) { + bid = orderChance.bid(); + minTotalStr = bid.min_total(); + log.info("빗썸 최소 주문 금액 검증 - orderChance.bid()에서 조회: {}", minTotalStr); + } + // 2순위: orderChance.market().bid() 확인 + else if (orderChance.market() != null && orderChance.market().bid() != null + && orderChance.market().bid().min_total() != null + && !orderChance.market().bid().min_total().isEmpty()) { + bid = orderChance.market().bid(); + minTotalStr = bid.min_total(); + log.info("빗썸 최소 주문 금액 검증 - orderChance.market().bid()에서 조회: {}", minTotalStr); + } + + log.info("빗썸 최소 주문 금액 검증 - bid 객체: {}, min_total: {}", + bid != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null"); + + BigDecimal minTotal; + String minTotalSource; + + if (minTotalStr != null && !minTotalStr.isEmpty()) { + // API에서 제공하는 최소 주문 금액 사용 + minTotal = new BigDecimal(minTotalStr); + minTotalSource = "API 응답"; + log.info("빗썸 최소 주문 금액 검증 - API 응답에서 최소 주문 금액 조회: {}", minTotal); + } else { + // 빗썸 API가 최소 주문 금액을 제공하지 않으므로 기본값 사용 (5000원) + minTotal = new BigDecimal("5000"); + minTotalSource = "기본값"; + log.warn("빗썸 API 응답에 최소 주문 금액 정보가 없어 기본값(5000원) 사용 - bid: {}, min_total: {}", + bid != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null 또는 빈 문자열"); + } + + log.info("빗썸 최소 주문 금액 검증 - 주문 금액: {}, 최소 주문 금액: {} ({})", requiredAmount, minTotal, minTotalSource); + if (requiredAmount.compareTo(minTotal) < 0) { + // 주문 금액이 최소 주문 금액보다 낮으면 에러 발생 + log.warn("주문 금액이 최소 주문 금액보다 낮음 - 주문 금액: {}, 최소 주문 금액: {}", requiredAmount, minTotal); + Map errorDetails = Map.of( + "requiredAmount", requiredAmountStr, + "minTotal", minTotal.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + log.info("빗썸 최소 주문 금액 검증 통과 - 주문 금액: {} >= 최소 주문 금액: {}", requiredAmount, minTotal); + + // 2단계: 잔고 검증 (최소 주문 금액을 넘는다면, 잔고로 살 수 있는지 확인) if (balanceDecimal.compareTo(requiredAmount) < 0) { // 잔고 부족 시 400 에러 반환 BigDecimal shortage = requiredAmount.subtract(balanceDecimal); @@ -517,20 +603,75 @@ private void validateOrderAvailability( } // 매도 주문 타입 검증 + BigDecimal volumeDecimal = new BigDecimal(volume); + BigDecimal orderAmount = null; // 주문 금액 (최소 주문 금액 검증용) + if ("limit".equals(orderType)) { // 지정가 매도: volume과 price 필요 if (price == null || price.isEmpty()) { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } + BigDecimal priceDecimal = new BigDecimal(price); + orderAmount = priceDecimal.multiply(volumeDecimal); } else if ("market".equals(orderType)) { // 시장가 매도: volume만 필요 (price 불필요) + // 시장가 매도는 실제 체결 금액을 알 수 없으므로 최소 주문 금액 검증은 생략 } else { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } - - BigDecimal volumeDecimal = new BigDecimal(volume); + requiredAmountStr = volume; // 매도 시 필요한 수량 + // 지정가 매도: 최소 주문 금액 검증 + if ("limit".equals(orderType) && orderAmount != null) { + // 빗썸 API 문서에 따르면 market.ask.min_total에 최소 주문 금액이 있음 + BithumbResDTO.Ask ask = null; + String minTotalStr = null; + + // 1순위: orderChance.ask() 확인 + if (orderChance.ask() != null && orderChance.ask().min_total() != null && !orderChance.ask().min_total().isEmpty()) { + ask = orderChance.ask(); + minTotalStr = ask.min_total(); + log.info("빗썸 매도 최소 주문 금액 검증 - orderChance.ask()에서 조회: {}", minTotalStr); + } + // 2순위: orderChance.market().ask() 확인 + else if (orderChance.market() != null && orderChance.market().ask() != null + && orderChance.market().ask().min_total() != null + && !orderChance.market().ask().min_total().isEmpty()) { + ask = orderChance.market().ask(); + minTotalStr = ask.min_total(); + log.info("빗썸 매도 최소 주문 금액 검증 - orderChance.market().ask()에서 조회: {}", minTotalStr); + } + + BigDecimal minTotal; + String minTotalSource; + + if (minTotalStr != null && !minTotalStr.isEmpty()) { + // API에서 제공하는 최소 주문 금액 사용 + minTotal = new BigDecimal(minTotalStr); + minTotalSource = "API 응답"; + log.info("빗썸 매도 최소 주문 금액 검증 - API 응답에서 최소 주문 금액 조회: {}", minTotal); + } else { + // 빗썸 API가 최소 주문 금액을 제공하지 않으므로 기본값 사용 (5000원) + minTotal = new BigDecimal("5000"); + minTotalSource = "기본값"; + log.warn("빗썸 API 응답에 최소 주문 금액 정보가 없어 기본값(5000원) 사용 - ask: {}, min_total: {}", + ask != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null 또는 빈 문자열"); + } + + log.info("빗썸 매도 최소 주문 금액 검증 - 주문 금액: {}, 최소 주문 금액: {} ({})", orderAmount, minTotal, minTotalSource); + if (orderAmount.compareTo(minTotal) < 0) { + log.warn("주문 금액이 최소 주문 금액보다 낮음 - 주문 금액: {}, 최소 주문 금액: {}", orderAmount, minTotal); + Map errorDetails = Map.of( + "orderAmount", orderAmount.toPlainString(), + "minTotal", minTotal.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + log.info("빗썸 매도 최소 주문 금액 검증 통과 - 주문 금액: {} >= 최소 주문 금액: {}", orderAmount, minTotal); + } + if (balanceDecimal.compareTo(volumeDecimal) < 0) { // 보유 수량 초과 시 400 에러 반환 BigDecimal shortage = volumeDecimal.subtract(balanceDecimal); 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 99b179f..be4e342 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 @@ -389,8 +389,39 @@ private UpbitResDTO.OrderChance getOrderChance(String phoneNumber, String market String query = "market=" + market; String authorization = jwtApiUtil.createUpBitJwt(phoneNumber, query, null); + log.info("업비트 API 호출 시작 - market: {}", market); // Feign Client가 DTO로 변환 - return upbitFeignClient.getOrderChance(authorization, market); + UpbitResDTO.OrderChance orderChance = upbitFeignClient.getOrderChance(authorization, market); + + log.info("업비트 API 응답 수신 완료"); + + // 전체 응답 구조 확인 (디버깅용) + log.info("업비트 API 응답 전체 구조 - orderChance: {}", orderChance); + log.info("업비트 API 응답 - bid_fee: {}, ask_fee: {}, market: {}, bid: {}, ask: {}, bid_account: {}, ask_account: {}", + orderChance.bid_fee(), orderChance.ask_fee(), orderChance.market(), + orderChance.bid(), orderChance.ask(), orderChance.bid_account(), orderChance.ask_account()); + + // API 응답에서 실제 마켓 형식 확인 + if (orderChance.market() != null && orderChance.market().id() != null) { + log.info("업비트 API 응답 market.id: {} (요청한 market: {})", orderChance.market().id(), market); + } + + // bid 객체 확인 (최소 주문 금액 검증용) + if (orderChance.bid() != null) { + log.info("업비트 API 응답 bid 객체 존재 - currency: {}, min_total: {}", + orderChance.bid().currency(), orderChance.bid().min_total()); + } else { + log.warn("업비트 API 응답 bid 객체가 null입니다! - 최소 주문 금액 검증 불가"); + // ask 객체도 확인 + if (orderChance.ask() != null) { + log.info("업비트 API 응답 ask 객체 존재 - currency: {}, min_total: {}", + orderChance.ask().currency(), orderChance.ask().min_total()); + } else { + log.warn("업비트 API 응답 ask 객체도 null입니다!"); + } + } + + return orderChance; } catch (MemberException e) { log.error("업비트 API 키를 찾을 수 없습니다 - phoneNumber: {}", phoneNumber, e); @@ -529,6 +560,64 @@ private void validateOrderAvailability( requiredAmountStr = requiredAmount.toPlainString(); + // 1단계: 최소 주문 금액 검증 (주문 금액이 최소 주문 금액을 넘는지 확인) + log.info("업비트 최소 주문 금액 검증 시작 - 주문 금액: {}", requiredAmount); + + // 업비트 API 문서에 따르면 market.bid.min_total 또는 market.ask.min_total에 최소 주문 금액이 있음 + // 참고: https://docs.upbit.com/kr/reference/available-order-information + UpbitResDTO.Bid bid = null; + String minTotalStr = null; + + // 1순위: orderChance.bid() 확인 + if (orderChance.bid() != null && orderChance.bid().min_total() != null && !orderChance.bid().min_total().isEmpty()) { + bid = orderChance.bid(); + minTotalStr = bid.min_total(); + log.info("업비트 최소 주문 금액 검증 - orderChance.bid()에서 조회: {}", minTotalStr); + } + // 2순위: orderChance.market().bid() 확인 + else if (orderChance.market() != null && orderChance.market().bid() != null + && orderChance.market().bid().min_total() != null + && !orderChance.market().bid().min_total().isEmpty()) { + bid = orderChance.market().bid(); + minTotalStr = bid.min_total(); + log.info("업비트 최소 주문 금액 검증 - orderChance.market().bid()에서 조회: {}", minTotalStr); + } + + log.info("업비트 최소 주문 금액 검증 - bid 객체: {}, min_total: {}", + bid != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null"); + + BigDecimal minTotal; + String minTotalSource; + + if (minTotalStr != null && !minTotalStr.isEmpty()) { + // API에서 제공하는 최소 주문 금액 사용 + minTotal = new BigDecimal(minTotalStr); + minTotalSource = "API 응답"; + log.info("업비트 최소 주문 금액 검증 - API 응답에서 최소 주문 금액 조회: {}", minTotal); + } else { + // 업비트 API가 최소 주문 금액을 제공하지 않으므로 마켓 타입에 따라 기본값 사용 + minTotal = getUpbitMinimumOrderAmount(market); + minTotalSource = "마켓별 기본값"; + log.warn("업비트 API 응답에 최소 주문 금액 정보가 없어 마켓별 기본값 사용 - market: {}, minTotal: {}, bid: {}, min_total: {}", + market, minTotal, + bid != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null 또는 빈 문자열"); + } + + log.info("업비트 최소 주문 금액 검증 - 주문 금액: {}, 최소 주문 금액: {} ({})", requiredAmount, minTotal, minTotalSource); + if (requiredAmount.compareTo(minTotal) < 0) { + // 주문 금액이 최소 주문 금액보다 낮으면 에러 발생 + log.warn("주문 금액이 최소 주문 금액보다 낮음 - 주문 금액: {}, 최소 주문 금액: {}", requiredAmount, minTotal); + Map errorDetails = Map.of( + "requiredAmount", requiredAmountStr, + "minTotal", minTotal.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + log.info("업비트 최소 주문 금액 검증 통과 - 주문 금액: {} >= 최소 주문 금액: {}", requiredAmount, minTotal); + + // 2단계: 잔고 검증 (최소 주문 금액을 넘는다면, 잔고로 살 수 있는지 확인) if (balanceDecimal.compareTo(requiredAmount) < 0) { // 잔고 부족 시 400 에러 반환 BigDecimal shortage = requiredAmount.subtract(balanceDecimal); @@ -548,20 +637,77 @@ private void validateOrderAvailability( } // 매도 주문 타입 검증 + BigDecimal volumeDecimal = new BigDecimal(volume); + BigDecimal orderAmount = null; // 주문 금액 (최소 주문 금액 검증용) + if ("limit".equals(orderType)) { // 지정가 매도: volume과 price 필요 if (price == null || price.isEmpty()) { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } + BigDecimal priceDecimal = new BigDecimal(price); + orderAmount = priceDecimal.multiply(volumeDecimal); } else if ("market".equals(orderType)) { // 시장가 매도: volume만 필요 (price 불필요) + // 시장가 매도는 실제 체결 금액을 알 수 없으므로 최소 주문 금액 검증은 생략 } else { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } - BigDecimal volumeDecimal = new BigDecimal(volume); requiredAmountStr = volume; // 매도 시 필요한 수량 + // 지정가 매도: 최소 주문 금액 검증 + if ("limit".equals(orderType) && orderAmount != null) { + // 업비트 API 문서에 따르면 market.ask.min_total에 최소 주문 금액이 있음 + // 참고: https://docs.upbit.com/kr/reference/available-order-information + UpbitResDTO.Ask ask = null; + String minTotalStr = null; + + // 1순위: orderChance.ask() 확인 + if (orderChance.ask() != null && orderChance.ask().min_total() != null && !orderChance.ask().min_total().isEmpty()) { + ask = orderChance.ask(); + minTotalStr = ask.min_total(); + log.info("업비트 매도 최소 주문 금액 검증 - orderChance.ask()에서 조회: {}", minTotalStr); + } + // 2순위: orderChance.market().ask() 확인 + else if (orderChance.market() != null && orderChance.market().ask() != null + && orderChance.market().ask().min_total() != null + && !orderChance.market().ask().min_total().isEmpty()) { + ask = orderChance.market().ask(); + minTotalStr = ask.min_total(); + log.info("업비트 매도 최소 주문 금액 검증 - orderChance.market().ask()에서 조회: {}", minTotalStr); + } + + BigDecimal minTotal; + String minTotalSource; + + if (minTotalStr != null && !minTotalStr.isEmpty()) { + // API에서 제공하는 최소 주문 금액 사용 + minTotal = new BigDecimal(minTotalStr); + minTotalSource = "API 응답"; + log.info("업비트 매도 최소 주문 금액 검증 - API 응답에서 최소 주문 금액 조회: {}", minTotal); + } else { + // 업비트 API가 최소 주문 금액을 제공하지 않으므로 마켓 타입에 따라 기본값 사용 + minTotal = getUpbitMinimumOrderAmount(market); + minTotalSource = "마켓별 기본값"; + log.warn("업비트 API 응답에 최소 주문 금액 정보가 없어 마켓별 기본값 사용 - market: {}, minTotal: {}, ask: {}, min_total: {}", + market, minTotal, + ask != null ? "존재" : "null", + minTotalStr != null ? minTotalStr : "null 또는 빈 문자열"); + } + + log.info("업비트 매도 최소 주문 금액 검증 - 주문 금액: {}, 최소 주문 금액: {} ({})", orderAmount, minTotal, minTotalSource); + if (orderAmount.compareTo(minTotal) < 0) { + log.warn("주문 금액이 최소 주문 금액보다 낮음 - 주문 금액: {}, 최소 주문 금액: {}", orderAmount, minTotal); + Map errorDetails = Map.of( + "orderAmount", orderAmount.toPlainString(), + "minTotal", minTotal.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + log.info("업비트 매도 최소 주문 금액 검증 통과 - 주문 금액: {} >= 최소 주문 금액: {}", orderAmount, minTotal); + } + if (balanceDecimal.compareTo(volumeDecimal) < 0) { // 보유 수량 초과 시 400 에러 반환 BigDecimal shortage = volumeDecimal.subtract(balanceDecimal); @@ -976,4 +1122,39 @@ private LocalDateTime parseCreatedAt(String createdAt) { return LocalDateTime.now(); } } + + /** + * 업비트 마켓별 최소 주문 금액 조회 + * 참고: + * - 원화(KRW) 마켓: https://docs.upbit.com/kr/docs/krw-market-info - 5,000 KRW + * - BTC 마켓: https://docs.upbit.com/kr/docs/btc-market-info - 0.00005 BTC + * - USDT 마켓: https://docs.upbit.com/kr/docs/usdt-market-info - 0.5 USDT + */ + private BigDecimal getUpbitMinimumOrderAmount(String market) { + if (market == null || market.isEmpty()) { + log.warn("market이 null이거나 비어있어 원화 마켓 기본값(5000) 사용"); + return new BigDecimal("5000"); + } + + String upperMarket = market.toUpperCase(); + + if (upperMarket.startsWith("KRW-")) { + // 원화 마켓: 5,000 KRW + log.info("업비트 원화 마켓 최소 주문 금액: 5000 KRW"); + return new BigDecimal("5000"); + } else if (upperMarket.startsWith("BTC-")) { + // BTC 마켓: 0.00005 BTC + log.info("업비트 BTC 마켓 최소 주문 금액: 0.00005 BTC"); + return new BigDecimal("0.00005"); + } else if (upperMarket.startsWith("USDT-")) { + // USDT 마켓: 0.5 USDT + // 참고: https://docs.upbit.com/kr/docs/usdt-market-info + log.info("업비트 USDT 마켓 최소 주문 금액: 0.5 USDT"); + return new BigDecimal("0.5"); + } else { + // 알 수 없는 마켓 타입: 원화 마켓 기본값 사용 + log.warn("알 수 없는 업비트 마켓 타입: {}, 원화 마켓 기본값(5000) 사용", market); + return new BigDecimal("5000"); + } + } } \ No newline at end of file From 2ea38ee01e0a3f3b01d5c12661b7691121c5cf43 Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 00:17:36 +0900 Subject: [PATCH 10/37] =?UTF-8?q?=F0=9F=9A=80=20chore:=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20API=20=EB=94=94=EB=B2=84=EA=B9=85=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/invest/controller/InvestController.java | 13 +++++++++++++ .../scoi/domain/invest/service/InvestService.java | 6 ++++++ 2 files changed, 19 insertions(+) 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 92ba2d4..ae1ad87 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 @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import com.example.scoi.global.security.userdetails.CustomUserDetails; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api") @@ -50,7 +52,17 @@ public ApiResponse checkOrderAvailability( @RequestBody InvestReqDTO.OrderDTO request, @AuthenticationPrincipal CustomUserDetails user ) { + System.out.println("========================================"); + System.out.println("주문 가능 여부 확인 API 호출 시작!!!"); + System.out.println("========================================"); + + log.info("=== 주문 가능 여부 확인 API 호출 시작 ==="); + log.info("요청 정보 - exchangeType: {}, market: {}, side: {}, orderType: {}, price: {}, volume: {}", + request.exchangeType(), request.market(), request.side(), request.orderType(), request.price(), request.volume()); + String phoneNumber = user.getUsername(); + log.info("사용자 phoneNumber: {}", phoneNumber); + // 주문 가능 여부 확인 investService.checkOrderAvailability( phoneNumber, @@ -62,6 +74,7 @@ public ApiResponse checkOrderAvailability( request.volume() ); + log.info("=== 주문 가능 여부 확인 완료 - 주문 가능 ==="); // 주문 가능한 경우 200 응답 반환 (result는 null) return ApiResponse.onSuccess(InvestSuccessCode.ORDER_AVAILABLE); } 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 9502213..e1db220 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 @@ -63,9 +63,15 @@ public void checkOrderAvailability( String price, String volume ) { + System.out.println("InvestService.checkOrderAvailability 호출됨!"); + log.info("=== InvestService.checkOrderAvailability 시작 ==="); + log.info("파라미터 - phoneNumber: {}, exchangeType: {}, market: {}, side: {}, orderType: {}, price: {}, volume: {}", + phoneNumber, exchangeType, market, side, orderType, price, volume); + // 사용자 존재 여부 확인 Member member = memberRepository.findByPhoneNumber(phoneNumber) .orElseThrow(() -> new InvestException(InvestErrorCode.API_KEY_NOT_FOUND)); + log.info("사용자 조회 완료 - memberId: {}", member.getId()); // 시크릿 키 복호화하기 // 쿼리 파라미터에 따라 빗썸 or 업비트 API 조회하기 From 408c6ea792f9d7ccaaa1e22e24d5675193f241fd Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 01:23:32 +0900 Subject: [PATCH 11/37] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=8B=9C=EC=9E=A5?= =?UTF-8?q?=EA=B0=80=20=EB=A7=A4=EB=8F=84=20=EC=8B=9C=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EB=B0=98=20=EC=B5=9C=EC=86=8C=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EA=B8=88=EC=95=A1=20=EA=B2=80=EC=A6=9D=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 --- .../client/adapter/BithumbApiClient.java | 41 +++++++++++++++++-- .../invest/client/adapter/UpbitApiClient.java | 24 +++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java index e740296..7ee99e7 100644 --- a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java +++ b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java @@ -615,15 +615,50 @@ else if (orderChance.market() != null && orderChance.market().bid() != null orderAmount = priceDecimal.multiply(volumeDecimal); } else if ("market".equals(orderType)) { // 시장가 매도: volume만 필요 (price 불필요) - // 시장가 매도는 실제 체결 금액을 알 수 없으므로 최소 주문 금액 검증은 생략 + // 현재가 조회 후 (현재가 × 수량)으로 주문 금액 계산하여 최소 주문 금액과 비교 + try { + String convertedMarket = convertMarketForBithumb(market); + log.info("빗썸 시장가 매도 주문 금액 계산을 위한 현재가 조회 시작 - market: {}", convertedMarket); + String tickerResponse = bithumbFeignClient.getTicker(convertedMarket); + + if (tickerResponse != null && !tickerResponse.isEmpty()) { + ObjectMapper objectMapper = new ObjectMapper(); + BithumbResDTO.Ticker ticker = null; + + try { + BithumbResDTO.Ticker[] tickers = objectMapper.readValue(tickerResponse, BithumbResDTO.Ticker[].class); + if (tickers != null && tickers.length > 0) { + ticker = tickers[0]; + } + } catch (Exception arrayException) { + try { + ticker = objectMapper.readValue(tickerResponse, BithumbResDTO.Ticker.class); + } catch (Exception singleException) { + log.warn("빗썸 시장가 매도 현재가 조회 JSON 파싱 실패 - 최소 주문 금액 검증을 생략합니다: {}", singleException.getMessage()); + } + } + + if (ticker != null && ticker.trade_price() != null && ticker.trade_price() > 0) { + BigDecimal currentPrice = BigDecimal.valueOf(ticker.trade_price()); + orderAmount = currentPrice.multiply(volumeDecimal); + log.info("빗썸 시장가 매도 주문 금액 계산 - 현재가: {}, 수량: {}, 주문 금액: {}", currentPrice, volumeDecimal, orderAmount); + } else { + log.warn("빗썸 시장가 매도 현재가 조회 실패 또는 가격이 0 이하 - 최소 주문 금액 검증을 생략합니다. market: {}", convertedMarket); + } + } else { + log.warn("빗썸 시장가 매도 현재가 조회 실패 - 응답이 비어있음, 최소 주문 금액 검증을 생략합니다. market: {}", convertedMarket); + } + } catch (Exception e) { + log.warn("빗썸 시장가 매도 주문 금액 계산 실패 - 최소 주문 금액 검증을 생략합니다: {}", e.getMessage()); + } } else { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } requiredAmountStr = volume; // 매도 시 필요한 수량 - // 지정가 매도: 최소 주문 금액 검증 - if ("limit".equals(orderType) && orderAmount != null) { + // 지정가/시장가 매도: 최소 주문 금액 검증 (orderAmount가 계산된 경우에만) + if (orderAmount != null) { // 빗썸 API 문서에 따르면 market.ask.min_total에 최소 주문 금액이 있음 BithumbResDTO.Ask ask = null; String minTotalStr = null; 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 be4e342..5a07570 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 @@ -649,15 +649,33 @@ else if (orderChance.market() != null && orderChance.market().bid() != null orderAmount = priceDecimal.multiply(volumeDecimal); } else if ("market".equals(orderType)) { // 시장가 매도: volume만 필요 (price 불필요) - // 시장가 매도는 실제 체결 금액을 알 수 없으므로 최소 주문 금액 검증은 생략 + // 현재가 조회 후 (현재가 × 수량)으로 주문 금액 계산하여 최소 주문 금액과 비교 + try { + log.info("업비트 시장가 매도 주문 금액 계산을 위한 현재가 조회 시작 - market: {}", market); + List tickers = upbitFeignClient.getTicker(market); + if (tickers != null && !tickers.isEmpty()) { + UpbitResDTO.Ticker ticker = tickers.get(0); + if (ticker.trade_price() != null && ticker.trade_price() > 0) { + BigDecimal currentPrice = BigDecimal.valueOf(ticker.trade_price()); + orderAmount = currentPrice.multiply(volumeDecimal); + log.info("업비트 시장가 매도 주문 금액 계산 - 현재가: {}, 수량: {}, 주문 금액: {}", currentPrice, volumeDecimal, orderAmount); + } else { + log.warn("업비트 시장가 매도 현재가 조회 실패 또는 가격이 0 이하 - 최소 주문 금액 검증을 생략합니다. market: {}", market); + } + } else { + log.warn("업비트 시장가 매도 현재가 조회 실패 - 응답이 비어있음, 최소 주문 금액 검증을 생략합니다. market: {}", market); + } + } catch (Exception e) { + log.warn("업비트 시장가 매도 주문 금액 계산 실패 - 최소 주문 금액 검증을 생략합니다: {}", e.getMessage()); + } } else { throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); } requiredAmountStr = volume; // 매도 시 필요한 수량 - // 지정가 매도: 최소 주문 금액 검증 - if ("limit".equals(orderType) && orderAmount != null) { + // 지정가/시장가 매도: 최소 주문 금액 검증 (orderAmount가 계산된 경우에만) + if (orderAmount != null) { // 업비트 API 문서에 따르면 market.ask.min_total에 최소 주문 금액이 있음 // 참고: https://docs.upbit.com/kr/reference/available-order-information UpbitResDTO.Ask ask = null; From f1b0cbd79aa5fd4cf02e5879fcfdd20cf7af542d Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 01:42:20 +0900 Subject: [PATCH 12/37] =?UTF-8?q?=F0=9F=94=A5=20remove:=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20API=EC=97=90=EC=84=9C=20password=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/invest/controller/InvestController.java | 2 +- .../scoi/domain/invest/controller/InvestControllerDocs.java | 4 ++-- 2 files changed, 3 insertions(+), 3 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 ae1ad87..ba008b4 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 @@ -49,7 +49,7 @@ public ApiResponse getMaxOrderInfo( @PostMapping("/orders/test") @Override public ApiResponse checkOrderAvailability( - @RequestBody InvestReqDTO.OrderDTO request, + @RequestBody InvestReqDTO.TestOrderDTO request, @AuthenticationPrincipal CustomUserDetails user ) { System.out.println("========================================"); 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 93ca306..bf3f6c9 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 @@ -30,10 +30,10 @@ ApiResponse getMaxOrderInfo( @Operation( summary = "주문 가능 여부 확인 By 강서현", - description = "주문 직전 해당 주문이 가능한지 여부를 확인합니다." + description = "주문 직전 해당 주문이 가능한지 여부를 확인합니다. password는 필요하지 않습니다." ) ApiResponse checkOrderAvailability( - @RequestBody InvestReqDTO.OrderDTO request, + @RequestBody InvestReqDTO.TestOrderDTO request, @AuthenticationPrincipal CustomUserDetails user ); From cc9405665ded24f6cc3a1d08ded098d4da9e6ca5 Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Wed, 11 Feb 2026 03:49:57 +0900 Subject: [PATCH 13/37] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=B9=97=EC=8D=B8?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=A5=EA=B0=80=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/adapter/BithumbApiClient.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java index f0e7ec4..2b3ed87 100644 --- a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java +++ b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java @@ -580,12 +580,31 @@ public InvestResDTO.OrderDTO createOrder( String convertedMarket = convertMarketForBithumb(market); // 주문 생성 요청 DTO 생성 + // @JsonInclude(NON_NULL) 어노테이션으로 null 필드는 JSON에서 자동 제외됨 + String finalPrice = (price != null && !price.isEmpty()) ? price : null; + String finalVolume = (volume != null && !volume.isEmpty()) ? volume : null; + + // 시장가 매수(order_type: "price")일 때는 volume을 null로 설정하여 JSON에서 제외 + // order_type: "price"는 항상 매수이므로 side 체크 불필요 + if ("price".equals(orderType)) { + // 시장가 매수: volume을 null로 설정하여 JSON에서 제외 + finalVolume = null; + log.info("빗썸 시장가 매수 (order_type: price) - volume을 null로 설정하여 JSON에서 제외합니다."); + } + // 시장가 매도(order_type: "market")일 때는 price를 null로 설정하여 JSON에서 제외 + // order_type: "market"는 항상 매도이므로 side 체크 불필요 + else if ("market".equals(orderType)) { + // 시장가 매도: price를 null로 설정하여 JSON에서 제외 + finalPrice = null; + log.info("빗썸 시장가 매도 (order_type: market) - price를 null로 설정하여 JSON에서 제외합니다."); + } + BithumbReqDTO.CreateOrder request = BithumbReqDTO.CreateOrder.builder() .market(convertedMarket) .side(side) .order_type(orderType) - .price(price) - .volume(volume) + .price(finalPrice) // ← 조건에 따라 null로 설정 + .volume(finalVolume) // ← 조건에 따라 null로 설정 .build(); // JWT 생성 (POST 요청이므로 body 사용) From 0e1a006b43fd8736114b503dbccde0c86ad884b2 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:48:20 +0900 Subject: [PATCH 14/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EB=8A=94=20=EC=88=AB=EC=9E=90=EB=A7=8C=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=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 --- 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 68528da..ddb9155 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 @@ -26,7 +26,7 @@ public record SmsVerifyRequest( String phoneNumber, @NotBlank(message = "인증번호는 필수입니다.") - @Size(min = 6, max = 6, message = "인증번호는 6자리입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자입니다.") String verificationCode ) {} From 8dbf0546da8191fa50ca550b53b86797731ccc72 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:51:25 +0900 Subject: [PATCH 15/37] =?UTF-8?q?=F0=9F=94=A7=20fix:=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/auth/exception/code/AuthErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java index e7eab53..a8286c4 100644 --- a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java +++ b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java @@ -45,7 +45,7 @@ public enum AuthErrorCode implements BaseErrorCode { // 토큰 관련 UNAUTHORIZED(HttpStatus.UNAUTHORIZED, - "AUTH401_1", + "AUTH401_0", "인증이 필요합니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401_2", From 8de99f70ce51179cb06d5283f2d78fcf4a04ee51 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:51:55 +0900 Subject: [PATCH 16/37] =?UTF-8?q?=F0=9F=94=A7=20fix:=20=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=20=EC=88=98=EB=AA=85=EC=9D=80=20=EC=97=B0=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= 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 4a0c44e..fde5189 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 @@ -386,8 +386,8 @@ public AuthResDTO.ReissueResponse reissue(AuthReqDTO.ReissueRequest request) { String newAccessToken = jwtUtil.createAccessToken(phoneNumber); String newRefreshToken = jwtUtil.createRefreshToken(phoneNumber); - // 7. RT 업데이트 (Rotation, issuedAt 갱신하여 최대 수명도 연장) - memberToken.updateTokenWithIssuedAt(newRefreshToken, now.plusDays(REFRESH_TOKEN_SLIDING_DAYS), now); + // 7. RT 업데이트 (Rotation, issuedAt 유지하여 최대 수명 30일 보장) + memberToken.updateToken(newRefreshToken, now.plusDays(REFRESH_TOKEN_SLIDING_DAYS)); // 8. lastLoginAt 갱신 (사용자 활동 추적) Member member = memberToken.getMember(); From ca12365ac85337ab495cbdd25de55e6c647d4980 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:52:28 +0900 Subject: [PATCH 17/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20SQL=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/LoginFailCountManager.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/service/LoginFailCountManager.java b/src/main/java/com/example/scoi/domain/auth/service/LoginFailCountManager.java index 7549eeb..228a22f 100644 --- a/src/main/java/com/example/scoi/domain/auth/service/LoginFailCountManager.java +++ b/src/main/java/com/example/scoi/domain/auth/service/LoginFailCountManager.java @@ -1,6 +1,5 @@ package com.example.scoi.domain.auth.service; -import com.example.scoi.domain.member.entity.Member; import com.example.scoi.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -16,23 +15,23 @@ public class LoginFailCountManager { /** * 로그인 실패 카운트를 별도 트랜잭션에서 증가시킵니다. * REQUIRES_NEW로 외부 트랜잭션 롤백과 무관하게 커밋됩니다. + * @Modifying @Query로 L1 캐시 무관하게 DB 직접 업데이트합니다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public int increaseFailCount(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalStateException("Member not found: " + memberId)); - member.increaseLoginFailCount(); - return member.getLoginFailCount(); + memberRepository.incrementLoginFailCount(memberId); + return memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalStateException("Member not found: " + memberId)) + .getLoginFailCount(); } /** * 로그인 실패 카운트를 별도 트랜잭션에서 초기화합니다. * SMS 재인증으로 계정 잠금 해제 시 사용 — 이후 비밀번호가 틀려도 잠금 해제는 유지됩니다. + * @Modifying @Query로 L1 캐시 무관하게 DB 직접 업데이트합니다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void resetFailCount(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalStateException("Member not found: " + memberId)); - member.resetLoginFailCount(); + memberRepository.resetLoginFailCount(memberId); } } From 397bf22831d0bb7d7fc2e1091345dd4822677ac2 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:53:04 +0900 Subject: [PATCH 18/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/repository/MemberRepository.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/example/scoi/domain/member/repository/MemberRepository.java b/src/main/java/com/example/scoi/domain/member/repository/MemberRepository.java index eccb848..b685af4 100644 --- a/src/main/java/com/example/scoi/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/scoi/domain/member/repository/MemberRepository.java @@ -2,6 +2,9 @@ import com.example.scoi.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -12,4 +15,12 @@ public interface MemberRepository extends JpaRepository { // 휴대폰 번호 중복 체크 boolean existsByPhoneNumber(String phoneNumber); + + @Modifying + @Query("UPDATE Member m SET m.loginFailCount = m.loginFailCount + 1 WHERE m.id = :memberId") + int incrementLoginFailCount(@Param("memberId") Long memberId); + + @Modifying + @Query("UPDATE Member m SET m.loginFailCount = 0 WHERE m.id = :memberId") + int resetLoginFailCount(@Param("memberId") Long memberId); } From 9b67f3f78a0a50e5e35aa40ae35521fafca78525 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:53:28 +0900 Subject: [PATCH 19/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80=20(AT=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/global/security/filter/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java index 4d8464f..4f18f29 100644 --- a/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java @@ -72,7 +72,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } // 토큰 검증 (validateToken이 만료도 함께 체크하므로 먼저 호출) - if (!jwtUtil.validateToken(token)) { + if (!jwtUtil.validateAccessToken(token)) { log.warn("유효하지 않은 JWT 토큰: {}", requestURI); handleAuthenticationError(request, response, AuthErrorCode.INVALID_TOKEN); return; From 90421f4ed2cf9978af8ff22540e87a97916e91cd Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 14:53:50 +0900 Subject: [PATCH 20/37] =?UTF-8?q?=E2=9C=A8=20feat:=20AT=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=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 --- .../com/example/scoi/global/security/jwt/JwtUtil.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/example/scoi/global/security/jwt/JwtUtil.java b/src/main/java/com/example/scoi/global/security/jwt/JwtUtil.java index dff2e84..1b0dd1e 100644 --- a/src/main/java/com/example/scoi/global/security/jwt/JwtUtil.java +++ b/src/main/java/com/example/scoi/global/security/jwt/JwtUtil.java @@ -82,6 +82,15 @@ public boolean validateToken(String token) { } } + public String getTokenType(String token) { + Claims claims = parseClaims(token); + return claims.get(TOKEN_TYPE_CLAIM, String.class); + } + + public boolean validateAccessToken(String token) { + return validateToken(token) && ACCESS_TOKEN_TYPE.equals(getTokenType(token)); + } + public boolean isTokenExpired(String token) { try { Claims claims = parseClaims(token); From e018f956da6a8a3c6ad6ec3cd444a625ed218230 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 16:05:05 +0900 Subject: [PATCH 21/37] =?UTF-8?q?=F0=9F=94=A5=20fix:=20=EC=A3=BC=EB=AF=BC?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EB=B2=88=ED=98=B8=20validation=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9D=84=20=ED=99=94=EB=A9=B4=EC=84=A4=EA=B3=84?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=EC=A4=80=EC=97=90=20=EB=A7=9E=EA=B2=8C=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 --- .../example/scoi/domain/auth/dto/AuthReqDTO.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 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 ddb9155..2ee0286 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 @@ -50,7 +50,7 @@ public record SignupRequest( String koreanName, @NotBlank(message = "주민등록번호는 필수입니다.") - @Pattern(regexp = "^\\d{6}-\\d{7}$", message = "올바른 주민등록번호 형식이 아닙니다.") + @Pattern(regexp = "^\\d{6}-\\d{1}$", message = "올바른 주민등록번호 형식이 아닙니다.") String residentNumber, @NotBlank(message = "간편비밀번호는 필수입니다.") @@ -98,6 +98,20 @@ public record LoginRequest( String verificationToken ) {} + // 비인증 간편비밀번호 재설정 요청 (계정 잠금 후 SMS 재인증 flow) + public record PasswordResetRequest( + @NotBlank(message = "휴대폰 번호는 필수입니다.") + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + String phoneNumber, + + @NotBlank(message = "인증 토큰은 필수입니다.") + String verificationToken, + + @NotBlank(message = "새 간편비밀번호는 필수입니다.") + @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==") + String newPassword + ) {} + // 토큰 재발급 요청 public record ReissueRequest( @NotBlank(message = "Refresh Token은 필수입니다.") From a91bdb02e325bbbcf9d26cef2114a74aca5b4941 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 16:05:39 +0900 Subject: [PATCH 22/37] =?UTF-8?q?=E2=9C=A8=20feat:=20SMS=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B8=B0=EC=A1=B4/?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=ED=9A=8C=EC=9B=90=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/dto/AuthResDTO.java | 3 +- .../scoi/domain/auth/service/AuthService.java | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java b/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java index fb979f4..cbf8e2b 100644 --- a/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java +++ b/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java @@ -15,7 +15,8 @@ public record SmsSendResponse( // SMS 검증 응답 public record SmsVerifyResponse( - String verificationToken // 인증 성공 시 발급되는 일회용 토큰 (유효시간: 10분) + String verificationToken, // 인증 성공 시 발급되는 일회용 토큰 (유효시간: 10분) + boolean isExistingMember // 기존 회원 여부 (true: 기존 회원 → 간편비밀번호 설정, false: 신규 회원 → 회원가입) ) {} // 회원가입 응답 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 fde5189..4b8cd75 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 @@ -136,8 +136,11 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques String tokenKey = VERIFICATION_PREFIX + verificationToken; redisUtil.set(tokenKey, request.phoneNumber(), VERIFICATION_EXPIRATION_MINUTES, TimeUnit.MINUTES); - log.info("SMS 인증 성공: phoneNumber={}", request.phoneNumber()); - return new AuthResDTO.SmsVerifyResponse(verificationToken); + // 6. 기존 회원 여부 확인 (화면 분기용) + boolean isExistingMember = memberRepository.existsByPhoneNumber(request.phoneNumber()); + + log.info("SMS 인증 성공: phoneNumber={}, isExistingMember={}", request.phoneNumber(), isExistingMember); + return new AuthResDTO.SmsVerifyResponse(verificationToken, isExistingMember); } /** @@ -401,6 +404,36 @@ public AuthResDTO.ReissueResponse reissue(AuthReqDTO.ReissueRequest request) { ); } + @Transactional + public void resetPassword(AuthReqDTO.PasswordResetRequest request) { + // 1. Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + validateVerificationToken(request.verificationToken(), request.phoneNumber()); + + // 2. 회원 조회 + Member member = memberRepository.findByPhoneNumber(request.phoneNumber()) + .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + + // 3. 새 비밀번호 AES 복호화 후 검증 + String rawPassword; + try { + rawPassword = new String(hashUtil.decryptAES(request.newPassword())); + } catch (GeneralSecurityException e) { + log.error("AES 복호화 실패: phoneNumber={}", request.phoneNumber(), e); + throw new AuthException(AuthErrorCode.INVALID_PASSWORD); + } + + if (!rawPassword.matches("^\\d{6}$")) { + log.warn("간편비밀번호 형식 오류: phoneNumber={}", request.phoneNumber()); + throw new AuthException(AuthErrorCode.INVALID_PASSWORD); + } + + // 4. 비밀번호 업데이트 및 잠금 해제 + member.updateSimplePassword(passwordEncoder.encode(rawPassword)); + member.resetLoginFailCount(); + + log.info("비밀번호 재설정 성공: phoneNumber={}", request.phoneNumber()); + } + @Transactional public void logout(String phoneNumber, String accessToken) { // 1. RT 삭제 From c85bcf84e491bc8f4f071ffd73c8f149ba257b12 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 16:06:41 +0900 Subject: [PATCH 23/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B9=84=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=84=ED=8E=B8=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?Redis=20=ED=82=A4=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/controller/AuthController.java | 9 +++++++++ .../domain/auth/exception/code/AuthSuccessCode.java | 3 +++ .../example/scoi/domain/member/dto/MemberReqDTO.java | 1 + .../scoi/domain/member/service/MemberService.java | 7 +++++-- .../example/scoi/global/config/SecurityConfig.java | 11 ++++++----- .../security/filter/JwtAuthenticationFilter.java | 1 + 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java index 727e371..7090968 100644 --- a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java @@ -78,6 +78,15 @@ public ApiResponse reissue( return ApiResponse.onSuccess(AuthSuccessCode.TOKEN_REISSUED, response); } + @Operation(summary = "비인증 간편비밀번호 재설정 By 장명준", description = "계정 잠금 후 SMS 재인증으로 간편비밀번호를 재설정합니다.") + @PostMapping("/password/reset") + public ApiResponse resetPassword( + @Valid @RequestBody AuthReqDTO.PasswordResetRequest request + ) { + authService.resetPassword(request); + return ApiResponse.onSuccess(AuthSuccessCode.PASSWORD_RESET_SUCCESS); + } + @Operation(summary = "로그아웃 By 장명준", description = "로그아웃 처리 (Refresh Token 삭제, Access Token 블랙리스트 등록)") @PostMapping("/logout") public ApiResponse logout( diff --git a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthSuccessCode.java b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthSuccessCode.java index 38eba3f..fb293b5 100644 --- a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthSuccessCode.java +++ b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthSuccessCode.java @@ -27,6 +27,9 @@ public enum AuthSuccessCode implements BaseSuccessCode { LOGOUT_SUCCESS(HttpStatus.OK, "AUTH200_5", "로그아웃되었습니다."), + PASSWORD_RESET_SUCCESS(HttpStatus.OK, + "AUTH200_6", + "비밀번호가 재설정되었습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index 4b670bc..c4eb311 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -15,6 +15,7 @@ public record ChangePassword( public record ResetPassword( @Pattern(regexp = "^\\d{11}$") String phoneNumber, + String verificationToken, String newPassword ){} 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 411be85..6a6e3c9 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 @@ -125,10 +125,13 @@ public Void resetPassword( Member member = memberRepository.findByPhoneNumber(phoneNumber) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - // 인증된 전화번호인지 확인 - if (!redisUtil.exists(VERIFICATION_PREFIX+dto.phoneNumber())){ + // 인증된 전화번호인지 확인 (verification:{verificationToken} 키로 조회) + String tokenKey = VERIFICATION_PREFIX + dto.verificationToken(); + String verifiedPhoneNumber = redisUtil.get(tokenKey); + if (verifiedPhoneNumber == null || !verifiedPhoneNumber.equals(dto.phoneNumber())) { throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); } + redisUtil.delete(tokenKey); // 새 간편 비밀번호 검증 String newPassword; 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 f098bb4..18a5d77 100644 --- a/src/main/java/com/example/scoi/global/config/SecurityConfig.java +++ b/src/main/java/com/example/scoi/global/config/SecurityConfig.java @@ -32,11 +32,12 @@ public class SecurityConfig { // 인증 없이 접근 가능한 경로 private static final String[] PUBLIC_ENDPOINTS = { - "/auth/sms/**", // SMS 발송/검증 - "/auth/signup", // 회원가입 - "/auth/login", // 로그인 - "/auth/reissue", // 토큰 재발급 - "/auth/sms-token", // 임시 + "/auth/sms/**", // SMS 발송/검증 + "/auth/signup", // 회원가입 + "/auth/login", // 로그인 + "/auth/reissue", // 토큰 재발급 + "/auth/password/reset", // 비인증 비밀번호 재설정 + "/auth/sms-token", // 임시 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", diff --git a/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java index 4f18f29..b510320 100644 --- a/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/scoi/global/security/filter/JwtAuthenticationFilter.java @@ -43,6 +43,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { "/auth/signup", "/auth/login", "/auth/reissue", + "/auth/password/reset", "/swagger-ui/", "/v3/api-docs/", "/swagger-resources/", From f164b33596879da2f9decc00fa540c367be916ce Mon Sep 17 00:00:00 2001 From: JuHeon Date: Wed, 11 Feb 2026 17:20:20 +0900 Subject: [PATCH 24/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B0=84=ED=8E=B8=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20(=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20->=20=EC=9D=B8=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 15 ++++++ .../scoi/domain/auth/service/AuthService.java | 48 +++++++++++++++++++ .../member/controller/MemberController.java | 10 ---- .../controller/MemberControllerDocs.java | 6 --- .../domain/member/service/MemberService.java | 42 ---------------- .../scoi/global/config/SecurityConfig.java | 11 +++-- 6 files changed, 69 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java index 727e371..311bb89 100644 --- a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java @@ -4,6 +4,8 @@ import com.example.scoi.domain.auth.dto.AuthReqDTO; import com.example.scoi.domain.auth.dto.AuthResDTO; import com.example.scoi.domain.auth.service.AuthService; +import com.example.scoi.domain.member.dto.MemberReqDTO; +import com.example.scoi.domain.member.exception.code.MemberSuccessCode; import com.example.scoi.global.apiPayload.ApiResponse; import com.example.scoi.global.apiPayload.code.BaseSuccessCode; import com.example.scoi.global.apiPayload.code.GeneralSuccessCode; @@ -33,6 +35,19 @@ public ApiResponse generateSmsToken( return ApiResponse.onSuccess(code, authService.generateSmsToken(phoneNumber)); } + // 간편 비밀번호 재설정 + @Operation( + summary = "간편 비밀번호 재설정 API By 김주헌", + description = "비밀번호 분실 또는 5회 실패 시 재설정을 합니다." + ) + @PostMapping("/password/reset") + public ApiResponse resetPassword( + @RequestBody MemberReqDTO.ResetPassword dto + ){ + BaseSuccessCode code = MemberSuccessCode.RESET_SIMPLE_PASSWORD; + return ApiResponse.onSuccess(code, authService.resetPassword(dto)); + } + @Operation(summary = "SMS 발송 By 장명준", description = "휴대폰 번호로 인증번호를 발송합니다.") @PostMapping("/sms/send") public ApiResponse sendSms( 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 4a0c44e..e7952ab 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 @@ -8,9 +8,12 @@ import com.example.scoi.domain.member.entity.Member; import com.example.scoi.domain.member.enums.MemberType; import com.example.scoi.domain.member.entity.MemberToken; +import com.example.scoi.domain.member.exception.MemberException; +import com.example.scoi.domain.member.exception.code.MemberErrorCode; import com.example.scoi.domain.member.repository.MemberRepository; import com.example.scoi.domain.member.repository.MemberTokenRepository; import com.example.scoi.domain.member.service.MemberService; +import com.example.scoi.global.apiPayload.code.GeneralErrorCode; import com.example.scoi.global.client.CoolSmsClient; import com.example.scoi.global.client.dto.CoolSmsDTO; import com.example.scoi.global.redis.RedisUtil; @@ -26,6 +29,7 @@ import java.security.GeneralSecurityException; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; @@ -69,6 +73,7 @@ public class AuthService { private static final long REFRESH_TOKEN_SLIDING_DAYS = 14; // 비활성 기준 만료 private static final long REFRESH_TOKEN_ABSOLUTE_DAYS = 30; // 최대 수명 private static final long SMS_COOLDOWN_SECONDS = 60; + private static final String SIMPLE_PASSWORD_REGEX = "^[0-9]{6}$"; public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { // 0. 쿨다운 체크 (1분) @@ -167,6 +172,49 @@ public String validateVerificationToken(String verificationToken, String phoneNu return verifiedPhoneNumber; } + // 간편 비밀번호 재설정 + @jakarta.transaction.Transactional + public Void resetPassword( + MemberReqDTO.ResetPassword dto + ) { + + // 인증된 전화번호인지 확인 + if (!redisUtil.exists(VERIFICATION_PREFIX+dto.verificationCode())){ + throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); + } + + // 새 간편 비밀번호 검증 + String newPassword; + try { + newPassword = new String(hashUtil.decryptAES(dto.newPassword())); + + // 6자리 숫자가 아닌 경우 + if (!newPassword.matches(SIMPLE_PASSWORD_REGEX)) { + throw new IllegalArgumentException(); + } + } catch (GeneralSecurityException e ) { + Map binding = new HashMap<>(); + binding.put("password", "간편 비밀번호 복호화에 실패했습니다."); + throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); + } catch (IllegalArgumentException e) { + Map binding = new HashMap<>(); + binding.put("password", "6자리 숫자만 입력 가능합니다."); + throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); + } + + // 사용자 가져오기 + String phoneNumber = jwtUtil.getPhoneNumberFromToken(dto.verificationCode()); + Member member = memberRepository.findByPhoneNumber(phoneNumber) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 간편 비밀번호 변경 + member.updateSimplePassword(passwordEncoder.encode(newPassword)); + + // 로그인 횟수 -> 0 + member.resetLoginFailCount(); + return null; + } + @Transactional public AuthResDTO.SignupResponse signup(AuthReqDTO.SignupRequest request) { // 1. Verification Token 검증 (SMS 인증 완료 확인) diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java index 7860e6f..a5b4fcd 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberController.java @@ -49,16 +49,6 @@ public ApiResponse> changePassword( } } - // 간편 비밀번호 재설정 - @PostMapping("/members/me/password/reset") - public ApiResponse resetPassword( - @RequestBody MemberReqDTO.ResetPassword dto, - @AuthenticationPrincipal CustomUserDetails user - ){ - BaseSuccessCode code = MemberSuccessCode.RESET_SIMPLE_PASSWORD; - return ApiResponse.onSuccess(code, memberService.resetPassword(dto, user.getUsername())); - } - // 거래소 목록 조회 @GetMapping("/exchanges") public ApiResponse> getExchangeList( diff --git a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java index be7a2ae..b06995f 100644 --- a/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java +++ b/src/main/java/com/example/scoi/domain/member/controller/MemberControllerDocs.java @@ -28,12 +28,6 @@ public interface MemberControllerDocs { ) ApiResponse> changePassword(@RequestBody MemberReqDTO.ChangePassword dto, @AuthenticationPrincipal CustomUserDetails user) throws GeneralSecurityException; - @Operation( - summary = "간편 비밀번호 재설정 API By 김주헌", - description = "비밀번호 분실 또는 5회 실패 시 재설정을 합니다." - ) - ApiResponse resetPassword(@RequestBody MemberReqDTO.ResetPassword dto, @AuthenticationPrincipal CustomUserDetails user); - @Operation( summary = "거래소 목록 조회 API By 김주헌", description = "지원 거래소 목록과, 사용자 기준으로 지원 거래소와 연동 상태를 조회합니다." 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 4ab4e00..8c1fb2d 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 @@ -115,48 +115,6 @@ public Optional> changePassword( return Optional.empty(); } - // 간편 비밀번호 재설정 - @Transactional - public Void resetPassword( - MemberReqDTO.ResetPassword dto, - String phoneNumber - ) { - - Member member = memberRepository.findByPhoneNumber(phoneNumber) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 인증된 전화번호인지 확인 - if (!redisUtil.exists(VERIFICATION_PREFIX+dto.verificationCode())){ - throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); - } - - // 새 간편 비밀번호 검증 - String newPassword; - try { - newPassword = new String(hashUtil.decryptAES(dto.newPassword())); - - // 6자리 숫자가 아닌 경우 - if (!newPassword.matches(SIMPLE_PASSWORD_REGEX)) { - throw new IllegalArgumentException(); - } - } catch (GeneralSecurityException e ) { - Map binding = new HashMap<>(); - binding.put("password", "간편 비밀번호 복호화에 실패했습니다."); - throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); - } catch (IllegalArgumentException e) { - Map binding = new HashMap<>(); - binding.put("password", "6자리 숫자만 입력 가능합니다."); - throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); - } - - // 간편 비밀번호 변경 - member.updateSimplePassword(passwordEncoder.encode(newPassword)); - - // 로그인 횟수 -> 0 - member.resetLoginFailCount(); - return null; - } - // 거래소 목록 조회 public List getExchangeList( String phoneNumber 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 091297d..8b66459 100644 --- a/src/main/java/com/example/scoi/global/config/SecurityConfig.java +++ b/src/main/java/com/example/scoi/global/config/SecurityConfig.java @@ -32,11 +32,12 @@ public class SecurityConfig { // 인증 없이 접근 가능한 경로 private static final String[] PUBLIC_ENDPOINTS = { - "/auth/sms/**", // SMS 발송/검증 - "/auth/signup", // 회원가입 - "/auth/login", // 로그인 - "/auth/reissue", // 토큰 재발급 - "/auth/sms-token", // 임시 + "/auth/sms/**", // SMS 발송/검증 + "/auth/signup", // 회원가입 + "/auth/login", // 로그인 + "/auth/reissue", // 토큰 재발급 + "/auth/password/reset", // 간편 비밀번호 재설정 + "/auth/sms-token", // 임시 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", From 77be0af8a7ef9549acca1e3fb1342c088e2dab4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Wed, 11 Feb 2026 17:49:09 +0900 Subject: [PATCH 25/37] =?UTF-8?q?=F0=9F=94=A7=20fix:=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EB=B3=80=ED=99=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/transfer/converter/TransferConverter.java | 8 ++++---- .../example/scoi/domain/transfer/dto/TransferReqDTO.java | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java index 233bd63..de73480 100644 --- a/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java +++ b/src/main/java/com/example/scoi/domain/transfer/converter/TransferConverter.java @@ -77,7 +77,7 @@ public static Recipient toFavoriteRecipient(TransferReqDTO.RecipientInformation return Recipient.builder() .walletAddress(recipient.walletAddress()) .recipientKoName(recipient.recipientKoName()) - .recipientType(recipient.memberType()) + .recipientType(MemberType.from(recipient.memberType())) // .recipientCorpKoName(recipient.corpKoreanName()) // .recipientCorpEnName(recipient.corpEnglishName()) .isFavorite(true) @@ -123,7 +123,7 @@ public static TransferResDTO.CheckRecipientResDTO toCheckRecipientResDTO( // 공통 수취인 정보 변환 로직 (중복 제거) private static TransferResDTO.RecipientDetailDTO toRecipientDetailDTO(TransferReqDTO.RecipientInformation info) { return TransferResDTO.RecipientDetailDTO.builder() - .recipientType(info.memberType()) + .recipientType(MemberType.from(info.memberType())) .recipientKoName(info.recipientKoName()) .recipientEnName(info.recipientEnName()) // .corpKoreanName(info.corpKoreanName()) @@ -162,7 +162,7 @@ public static TransferReqDTO.BithumbWithdrawRequest toBithumbWithdrawRequest(Tra .amount(Double.valueOf(dto.amount())) .address(dto.address()) .exchangeName(String.valueOf(dto.exchangeName())) - .receiverType(MemberType.valueOf(mappedReceiverType)) + .receiverType(mappedReceiverType) .receiverKoName(dto.receiverKoName()) .receiverEnName(dto.receiverEnName()) // .receiverCorpKoName(dto.receiverCorpKoName()) // 법인일 때만 값이 들어있음 @@ -215,7 +215,7 @@ public static Recipient toRecipient(TransferReqDTO.WithdrawRequest request, Memb .walletAddress(request.address()) .recipientEnName(request.receiverEnName()) .recipientKoName(request.receiverKoName()) - .recipientType(request.receiverType()) + .recipientType(MemberType.from(request.receiverType())) .member(member) .build(); } diff --git a/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java b/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java index 50db7d8..91d0aa1 100644 --- a/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java +++ b/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java @@ -18,8 +18,8 @@ public class TransferReqDTO { // 출금 시 필요한 수취인 값 public record RecipientInformation( @Schema(description = "수취인 유형 (개인/법인)", example = "INDIVIDUAL", allowableValues = {"INDIVIDUAL", "CORPORATION"}) - @NotNull(message = "수취인 유형은 필수입니다.") - MemberType memberType, + @NotBlank(message = "수취인 유형은 필수입니다.") + String memberType, @Schema(description = "수취인 국문 이름", example = "김철수") @NotBlank(message = "수취인 이름은 필수입니다.") @@ -102,7 +102,7 @@ public record WithdrawRequest( ExchangeType exchangeType, @Schema(description = "수취인 유형 (빗썸 전용, 상대방 거래소)", example = "INDIVIDUAL", allowableValues = {"INDIVIDUAL", "CORPORATION"}) - MemberType receiverType, + String receiverType, @Schema(description = "수취인 성명 (국문, 빗썸 전용)", example = "김철수") String receiverKoName, @@ -148,7 +148,7 @@ public record BithumbWithdrawRequest( @JsonProperty("exchange_name") String exchangeName, // 상대방 출금 거래소 @JsonProperty("receiver_type") - MemberType receiverType, // personal 또는 corporation + String receiverType, // personal 또는 corporation // 수취인(대표자) 정보 @JsonProperty("receiver_ko_name") From e765172a0fb160011ab9deaa2acaaaf97807a417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Wed, 11 Feb 2026 17:49:33 +0900 Subject: [PATCH 26/37] =?UTF-8?q?=F0=9F=94=A7=20fix:=20=EC=98=A4=ED=83=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/transfer/exception/code/TransferSuccessCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/scoi/domain/transfer/exception/code/TransferSuccessCode.java b/src/main/java/com/example/scoi/domain/transfer/exception/code/TransferSuccessCode.java index e0885da..e8bc1fd 100644 --- a/src/main/java/com/example/scoi/domain/transfer/exception/code/TransferSuccessCode.java +++ b/src/main/java/com/example/scoi/domain/transfer/exception/code/TransferSuccessCode.java @@ -15,7 +15,7 @@ public enum TransferSuccessCode implements BaseSuccessCode { TRANSFER200_3(HttpStatus.OK, "TRANSFER200_3", "즐겨찾기 수취인으로 변경했습니다."), TRANSFER200_4(HttpStatus.OK, "TRANSFER200_4", "즐겨찾기 수취인에서 해제했습니다."), TRANSFER200_5(HttpStatus.OK, "TRANSFER200_5", "수취인 입력값 검증에 성공했습니다."), - TRANSFER200_6(HttpStatus.OK, "TRANSFER200_6", "출금 견접 검증에 성공했습니다."), + TRANSFER200_6(HttpStatus.OK, "TRANSFER200_6", "출금 견적 검증에 성공했습니다."), TRANSFER200_7(HttpStatus.OK, "TRANSFER200_7", "출금 요청에 성공했습니다."), TRANSFER201_1(HttpStatus.CREATED, "TRANSFER201_1", "즐겨찾기 수취인 등록에 성공했습니다.") From dd4e19c76ebc54b58a02b87a7814e75c187cf4e6 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 20:50:57 +0900 Subject: [PATCH 27/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20example?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/dto/AuthReqDTO.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 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 2ee0286..bfa14b7 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 @@ -16,6 +16,7 @@ public class AuthReqDTO { public record SmsSendRequest( @NotBlank(message = "휴대폰 번호는 필수입니다.") @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + @Schema(description = "휴대폰 번호", example = "01012345678") String phoneNumber ) {} @@ -23,10 +24,12 @@ public record SmsSendRequest( public record SmsVerifyRequest( @NotBlank(message = "휴대폰 번호는 필수입니다.") @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + @Schema(description = "휴대폰 번호", example = "01012345678") String phoneNumber, @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자입니다.") + @Schema(description = "SMS 인증번호 6자리", example = "123456") String verificationCode ) {} @@ -34,27 +37,32 @@ public record SmsVerifyRequest( public record SignupRequest( @NotBlank(message = "휴대폰 번호는 필수입니다.") @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + @Schema(description = "휴대폰 번호", example = "01012345678") String phoneNumber, @NotBlank(message = "인증 토큰은 필수입니다.") + @Schema(description = "SMS 인증 완료 후 발급된 verificationToken") String verificationToken, @NotBlank(message = "영문 이름은 필수입니다.") @Pattern(regexp = "^[A-Z ]+$", message = "영문 대문자와 공백만 입력 가능합니다.") @Size(max = 50, message = "영문 이름은 50자 이내입니다.") + @Schema(description = "영문 이름 (대문자)", example = "JANG MYONGJUN") String englishName, @NotBlank(message = "한글 이름은 필수입니다.") @Pattern(regexp = "^[가-힣]+$", message = "한글만 입력 가능합니다.") @Size(min = 2, max = 5, message = "한글 이름은 2~5자입니다.") + @Schema(description = "한글 이름", example = "장명준") String koreanName, @NotBlank(message = "주민등록번호는 필수입니다.") - @Pattern(regexp = "^\\d{6}-\\d{1}$", message = "올바른 주민등록번호 형식이 아닙니다.") + @Pattern(regexp = "^\\d{7}$", message = "올바른 주민등록번호 형식이 아닙니다.") + @Schema(description = "주민등록번호 앞 7자리 (생년월일 6자리 + 성별코드 1자리)", example = "0306203") String residentNumber, @NotBlank(message = "간편비밀번호는 필수입니다.") - @Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==") + @Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") String simplePassword, @Schema(description = "회원 타입 (미입력 시 INDIVIDUAL 기본값)", @@ -76,11 +84,11 @@ public record ApiKeyRequest( ExchangeType exchangeType, @NotBlank(message = "퍼블릭 키는 필수입니다.") - @Schema(description = "거래소 API 퍼블릭 키", example = "your-public-key") + @Schema(description = "거래소 API 퍼블릭 키", example = "abcdef1234567890abcdef12") String publicKey, @NotBlank(message = "시크릿 키는 필수입니다.") - @Schema(description = "거래소 API 시크릿 키 (AES 암호화된 Base64)", example = "asdadsasdasd...") + @Schema(description = "거래소 API 시크릿 키 (AES 암호화된 Base64)", example = "abcdef1234567890abcdef1234567890") String secretKey ) {} @@ -88,10 +96,11 @@ public record ApiKeyRequest( public record LoginRequest( @NotBlank(message = "휴대폰 번호는 필수입니다.") @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + @Schema(description = "휴대폰 번호", example = "01012345678") String phoneNumber, @NotBlank(message = "간편비밀번호는 필수입니다.") - @Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==") + @Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") String simplePassword, @Schema(description = "SMS 재인증 토큰 (계정 잠금/RT 만료 시 필수, 일반 로그인 시 생략)", nullable = true) @@ -102,13 +111,15 @@ public record LoginRequest( public record PasswordResetRequest( @NotBlank(message = "휴대폰 번호는 필수입니다.") @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + @Schema(description = "휴대폰 번호", example = "01012345678") String phoneNumber, @NotBlank(message = "인증 토큰은 필수입니다.") + @Schema(description = "SMS 인증 완료 후 발급된 verificationToken") String verificationToken, @NotBlank(message = "새 간편비밀번호는 필수입니다.") - @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==") + @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") String newPassword ) {} @@ -117,4 +128,4 @@ public record ReissueRequest( @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} -} \ No newline at end of file +} From 92f1afa1f6518d390fe49d9ba1e392c272e0abd2 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 22:21:00 +0900 Subject: [PATCH 28/37] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/scoi/domain/member/dto/MemberReqDTO.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index baee02c..c4eb311 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -1,6 +1,7 @@ package com.example.scoi.domain.member.dto; import com.example.scoi.domain.member.enums.ExchangeType; +import jakarta.validation.constraints.Pattern; public class MemberReqDTO { From fa3b1d6952181bf8c411c38e97019f08ebfd225d Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Wed, 11 Feb 2026 23:23:16 +0900 Subject: [PATCH 29/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20API(=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8?= =?UTF-8?q?=20=EC=9E=AC=EC=84=A4=EC=A0=95)=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 9 ---- .../scoi/domain/auth/service/AuthService.java | 48 +++---------------- .../scoi/domain/member/dto/MemberReqDTO.java | 2 + .../domain/member/service/MemberService.java | 1 - 4 files changed, 9 insertions(+), 51 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java index f310f1d..311bb89 100644 --- a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java @@ -93,15 +93,6 @@ public ApiResponse reissue( return ApiResponse.onSuccess(AuthSuccessCode.TOKEN_REISSUED, response); } - @Operation(summary = "비인증 간편비밀번호 재설정 By 장명준", description = "계정 잠금 후 SMS 재인증으로 간편비밀번호를 재설정합니다.") - @PostMapping("/password/reset") - public ApiResponse resetPassword( - @Valid @RequestBody AuthReqDTO.PasswordResetRequest request - ) { - authService.resetPassword(request); - return ApiResponse.onSuccess(AuthSuccessCode.PASSWORD_RESET_SUCCESS); - } - @Operation(summary = "로그아웃 By 장명준", description = "로그아웃 처리 (Refresh Token 삭제, Access Token 블랙리스트 등록)") @PostMapping("/logout") public ApiResponse logout( 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 74170ce..d7ad9c8 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 @@ -170,8 +170,7 @@ public String validateVerificationToken(String verificationToken, String phoneNu throw new AuthException(AuthErrorCode.INVALID_TOKEN); } - redisUtil.delete(tokenKey); - log.debug("Verification Token 검증 성공 및 삭제: phoneNumber={}", phoneNumber); + log.debug("Verification Token 검증 성공: phoneNumber={}", phoneNumber); return verifiedPhoneNumber; } @@ -181,10 +180,12 @@ public Void resetPassword( MemberReqDTO.ResetPassword dto ) { - // 인증된 전화번호인지 확인 - if (!redisUtil.exists(VERIFICATION_PREFIX+dto.verificationCode())){ - throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); - } + // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber()); + + // 사용자 가져오기 + Member member = memberRepository.findByPhoneNumber(phoneNumber) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); // 새 간편 비밀번호 검증 String newPassword; @@ -205,11 +206,6 @@ public Void resetPassword( throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); } - // 사용자 가져오기 - String phoneNumber = jwtUtil.getPhoneNumberFromToken(dto.verificationCode()); - Member member = memberRepository.findByPhoneNumber(phoneNumber) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - // 간편 비밀번호 변경 member.updateSimplePassword(passwordEncoder.encode(newPassword)); @@ -452,36 +448,6 @@ public AuthResDTO.ReissueResponse reissue(AuthReqDTO.ReissueRequest request) { ); } - @Transactional - public void resetPassword(AuthReqDTO.PasswordResetRequest request) { - // 1. Verification Token 검증 및 소멸 (SMS 인증 완료 확인) - validateVerificationToken(request.verificationToken(), request.phoneNumber()); - - // 2. 회원 조회 - Member member = memberRepository.findByPhoneNumber(request.phoneNumber()) - .orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); - - // 3. 새 비밀번호 AES 복호화 후 검증 - String rawPassword; - try { - rawPassword = new String(hashUtil.decryptAES(request.newPassword())); - } catch (GeneralSecurityException e) { - log.error("AES 복호화 실패: phoneNumber={}", request.phoneNumber(), e); - throw new AuthException(AuthErrorCode.INVALID_PASSWORD); - } - - if (!rawPassword.matches("^\\d{6}$")) { - log.warn("간편비밀번호 형식 오류: phoneNumber={}", request.phoneNumber()); - throw new AuthException(AuthErrorCode.INVALID_PASSWORD); - } - - // 4. 비밀번호 업데이트 및 잠금 해제 - member.updateSimplePassword(passwordEncoder.encode(rawPassword)); - member.resetLoginFailCount(); - - log.info("비밀번호 재설정 성공: phoneNumber={}", request.phoneNumber()); - } - @Transactional public void logout(String phoneNumber, String accessToken) { // 1. RT 삭제 diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index c4eb311..aa18b2c 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -1,6 +1,7 @@ package com.example.scoi.domain.member.dto; import com.example.scoi.domain.member.enums.ExchangeType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; public class MemberReqDTO { @@ -16,6 +17,7 @@ public record ResetPassword( @Pattern(regexp = "^\\d{11}$") String phoneNumber, String verificationToken, + @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") String newPassword ){} 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 6a6e3c9..ed670a5 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 @@ -131,7 +131,6 @@ public Void resetPassword( if (verifiedPhoneNumber == null || !verifiedPhoneNumber.equals(dto.phoneNumber())) { throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); } - redisUtil.delete(tokenKey); // 새 간편 비밀번호 검증 String newPassword; From bd817dde7f0b99f2c548460769ac79e2c5f69435 Mon Sep 17 00:00:00 2001 From: seohyunk09 <2022112400@dgu.ac.kr> Date: Thu, 12 Feb 2026 05:31:37 +0900 Subject: [PATCH 30/37] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=B9=97=EC=8D=B8?= =?UTF-8?q?=20=EC=B5=9C=EB=8C=80=20=EC=A3=BC=EB=AC=B8=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95(=EC=A7=80=EC=A0=95=EA=B0=80=20=EB=A7=A4?= =?UTF-8?q?=EB=8F=84=20=EA=B3=84=EC=82=B0,=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EA=B8=88=EC=95=A1=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/adapter/BithumbApiClient.java | 157 ++++++++++++++++-- 1 file changed, 143 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java index b525452..24c2f80 100644 --- a/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java +++ b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java @@ -57,6 +57,9 @@ public MaxOrderInfoDTO getMaxOrderInfo(String phoneNumber, ExchangeType exchange return parseMaxOrderInfoResponse(accountList, targetCoin, coinType, unitPrice, orderType, side); + } catch (InvestException e) { + // InvestException은 그대로 전파 (INSUFFICIENT_COIN_AMOUNT, MINIMUM_ORDER_AMOUNT 등) + throw e; } catch (GeneralSecurityException e) { log.error("빗썸 JWT 생성 실패", e); throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); @@ -197,9 +200,62 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List 0) { + ticker = tickers[0]; + } + } catch (Exception arrayException) { + // 배열 파싱 실패 시 단일 객체로 파싱 시도 + try { + ticker = objectMapper.readValue(tickerResponse, BithumbResDTO.Ticker.class); + } catch (Exception singleException) { + log.warn("빗썸 시장가 매도 현재가 조회 JSON 파싱 실패: {}", singleException.getMessage()); + } + } + + if (ticker != null && ticker.trade_price() != null && ticker.trade_price() > 0) { + BigDecimal currentPrice = BigDecimal.valueOf(ticker.trade_price()); + BigDecimal orderAmount = currentPrice.multiply(balanceDecimal); + BigDecimal minOrderAmount = new BigDecimal("5000"); // 빗썸 기본 최소 주문 금액 + + if (orderAmount.compareTo(minOrderAmount) < 0) { + log.warn("빗썸 시장가 매도 - 최소 주문 금액 미만 - 주문 금액: {}, 최소 주문 금액: {}", + orderAmount, minOrderAmount); + Map errorDetails = Map.of( + "orderAmount", orderAmount.toPlainString(), + "minTotal", minOrderAmount.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + + log.info("빗썸 시장가 매도 - 최소 주문 금액 검증 통과 - balance: {}, 현재가: {}, 주문 금액: {}, 최소 주문 금액: {}", + balance, currentPrice, orderAmount, minOrderAmount); + } + } + } catch (InvestException e) { + // MINIMUM_ORDER_AMOUNT 예외는 그대로 전파 + throw e; + } catch (Exception e) { + log.warn("빗썸 시장가 매도 현재가 조회 실패 - 최소 주문 금액 검증을 생략합니다: {}", e.getMessage()); + } + + // 소수점 절사하여 정수로 변환 maxQuantity = balanceDecimal.setScale(0, RoundingMode.DOWN).toPlainString(); log.info("빗썸 시장가 매도 - 코인 잔액: {}, 최대 매도 가능 수량: {} (정수)", balance, maxQuantity); } catch (NumberFormatException e) { @@ -212,23 +268,93 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List 0) { + // 단가가 잔고보다 크면 잔고 부족 에러 + if (unitPriceDecimal.compareTo(balanceDecimal) > 0) { + log.warn("빗썸 지정가 매수 - 잔고 부족 - 잔고: {}, 단가: {}", balance, unitPrice); + Map errorDetails = Map.of( + "balance", balance, + "requiredAmount", unitPrice, + "shortage", unitPriceDecimal.subtract(balanceDecimal).toPlainString() + ); + throw new InvestException(InvestErrorCode.INSUFFICIENT_BALANCE, errorDetails); + } + + BigDecimal quantity = balanceDecimal.divide(unitPriceDecimal, 8, RoundingMode.DOWN); + // 소수점 절사하여 정수로 변환 + maxQuantity = quantity.setScale(0, RoundingMode.DOWN).toPlainString(); + log.info("빗썸 지정가 매수 - KRW 잔액: {}, 단가: {}, 최대 매수 가능 수량: {} (정수)", + balance, unitPrice, maxQuantity); + } else { + log.warn("단위 가격이 0 이하입니다. 최대 주문 수량을 계산할 수 없습니다."); + } + } catch (InvestException e) { + // INSUFFICIENT_BALANCE 예외는 그대로 전파 + throw e; + } catch (NumberFormatException e) { + log.warn("단위 가격 형식이 올바르지 않습니다. 최대 주문 수량을 계산할 수 없습니다. unitPrice: {}", unitPrice); + } + } + } else if ("ask".equals(side)) { + // 지정가 매도: 코인 보유 여부 확인 + BigDecimal balanceDecimal; try { - BigDecimal balanceDecimal = new BigDecimal(balance); - BigDecimal unitPriceDecimal = new BigDecimal(unitPrice); + balanceDecimal = new BigDecimal(balance); + } catch (NumberFormatException e) { + balanceDecimal = BigDecimal.ZERO; + } + + // 코인이 없으면 maxQuantity를 0으로 설정 (시장가 매도와 동일하게 처리) + if (balanceDecimal.compareTo(BigDecimal.ZERO) <= 0) { + log.warn("빗썸 지정가 매도 - 보유 수량 없음 - coinType: {}, balance: {}", targetCoin, balance); + maxQuantity = "0"; + } else { + // 매도는 보유 수량이 maxQuantity + maxQuantity = balanceDecimal.setScale(0, RoundingMode.DOWN).toPlainString(); - if (unitPriceDecimal.compareTo(BigDecimal.ZERO) > 0) { - BigDecimal quantity = balanceDecimal.divide(unitPriceDecimal, 8, RoundingMode.DOWN); - // 소수점 절사하여 정수로 변환 - maxQuantity = quantity.setScale(0, RoundingMode.DOWN).toPlainString(); - log.info("빗썸 최대 주문 수량 계산 - balance: {}, unitPrice: {}, maxQuantity: {} (정수)", - balance, unitPrice, maxQuantity); - } else { - log.warn("단위 가격이 0 이하입니다. 최대 주문 수량을 계산할 수 없습니다."); + // unitPrice가 있으면 최소 주문 금액 검증 + if (unitPrice != null && !unitPrice.isEmpty()) { + try { + BigDecimal unitPriceDecimal = new BigDecimal(unitPrice); + + if (unitPriceDecimal.compareTo(BigDecimal.ZERO) > 0) { + // 최소 주문 금액 검증: unitPrice * balance >= 5000원 + BigDecimal maxOrderAmount = unitPriceDecimal.multiply(balanceDecimal); + BigDecimal minOrderAmount = new BigDecimal("5000"); // 빗썸 기본 최소 주문 금액 + + if (maxOrderAmount.compareTo(minOrderAmount) < 0) { + log.warn("빗썸 지정가 매도 - 최소 주문 금액 미만 - 주문 금액: {}, 최소 주문 금액: {}", + maxOrderAmount, minOrderAmount); + Map errorDetails = Map.of( + "orderAmount", maxOrderAmount.toPlainString(), + "minTotal", minOrderAmount.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + + log.info("빗썸 지정가 매도 - 최소 주문 금액 검증 통과 - balance: {}, unitPrice: {}, maxQuantity: {}, 주문 금액: {}, 최소 주문 금액: {}", + balance, unitPrice, maxQuantity, maxOrderAmount, minOrderAmount); + } else { + log.warn("단위 가격이 0 이하입니다. 최소 주문 금액 검증을 할 수 없습니다."); + } + } catch (InvestException e) { + // MINIMUM_ORDER_AMOUNT, INSUFFICIENT_COIN_AMOUNT 예외는 그대로 전파 + throw e; + } catch (NumberFormatException e) { + log.warn("단위 가격 형식이 올바르지 않습니다. 최소 주문 금액 검증을 할 수 없습니다. unitPrice: {}", unitPrice); + } } - } catch (NumberFormatException e) { - log.warn("단위 가격 형식이 올바르지 않습니다. 최대 주문 수량을 계산할 수 없습니다. unitPrice: {}", unitPrice); } + } else { + log.warn("빗썸 지정가 주문 - side 파라미터가 없거나 잘못됨 ({}), maxQuantity: null", side); + maxQuantity = null; } } @@ -237,6 +363,9 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List Date: Thu, 12 Feb 2026 05:33:13 +0900 Subject: [PATCH 31/37] =?UTF-8?q?=20=F0=9F=90=9B=20fix:=20=EC=97=85?= =?UTF-8?q?=EB=B9=84=ED=8A=B8=20=EC=B5=9C=EB=8C=80=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0(=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=20=EC=A3=BC=EB=AC=B8=20=EA=B8=88=EC=95=A1=20?= =?UTF-8?q?,=20=EC=9E=94=EA=B3=A0=20=EB=B6=80=EC=A1=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../invest/client/adapter/UpbitApiClient.java | 162 +++++++++++++++--- 1 file changed, 141 insertions(+), 21 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..41a4146 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 @@ -137,6 +137,9 @@ public MaxOrderInfoDTO getMaxOrderInfo(String phoneNumber, ExchangeType exchange e.status(), errorBody); throw e; // 원본 FeignException을 그대로 던져서 상위에서 세부적인 분기 가능 } + } catch (InvestException e) { + // InvestException은 그대로 전파 (INSUFFICIENT_COIN_AMOUNT, MINIMUM_ORDER_AMOUNT 등) + throw e; } catch (FeignException e) { // FeignException은 그대로 전파하여 상위에서 세부적인 분기 가능 log.error("업비트 최대 주문 정보 조회 API 호출 실패 - FeignException: status: {}", e.status(), e); @@ -181,14 +184,28 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List acco log.warn("업비트 시장가 주문 - side 파라미터가 없거나 잘못됨 ({}), 기본값으로 매수 처리", side); } } else { - // 지정가 주문 - if (coinType.contains("-")) { - String[] parts = coinType.split("-"); - currency = parts[0]; // KRW - targetCurrency = parts[0]; + // 지정가 주문: side 파라미터를 기준으로 매수/매도 판단 + if ("bid".equals(side)) { + // 지정가 매수: KRW 잔액 조회 + targetCurrency = "KRW"; + currency = "KRW"; + log.info("업비트 지정가 매수 - {}를 매수하기 위해 KRW 잔액 조회", coinType); + } else if ("ask".equals(side)) { + // 지정가 매도: 코인 잔액 조회 + if (coinType.contains("-")) { + String[] parts = coinType.split("-"); + targetCurrency = parts[1]; // KRW-USDT -> USDT + currency = parts[1]; + } else { + targetCurrency = coinType; + currency = coinType; + } + log.info("업비트 지정가 매도 - {} 잔액 조회", targetCurrency); } else { - currency = coinType; - targetCurrency = coinType; + // side가 없거나 잘못된 경우 기본값 (매수) + targetCurrency = "KRW"; + currency = "KRW"; + log.warn("업비트 지정가 주문 - side 파라미터가 없거나 잘못됨 ({}), 기본값으로 매수 처리", side); } } @@ -254,9 +271,42 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List acco } } else if ("ask".equals(side)) { // 시장가 매도: 코인 잔액을 maxQuantity로 반환 (최대 매도 가능 수량) - // 소수점 절사하여 정수로 변환 + // 현재가 조회하여 최소 주문 금액 검증 try { BigDecimal balanceDecimal = new BigDecimal(balance); + + // 현재가 조회하여 최소 주문 금액 검증 + try { + List tickers = upbitFeignClient.getTicker(coinType); + if (tickers != null && !tickers.isEmpty()) { + UpbitResDTO.Ticker ticker = tickers.get(0); + if (ticker.trade_price() != null && ticker.trade_price() > 0) { + BigDecimal currentPrice = BigDecimal.valueOf(ticker.trade_price()); + BigDecimal orderAmount = currentPrice.multiply(balanceDecimal); + BigDecimal minOrderAmount = getUpbitMinimumOrderAmount(normalizeCoinType(coinType)); + + if (orderAmount.compareTo(minOrderAmount) < 0) { + log.warn("업비트 시장가 매도 - 최소 주문 금액 미만 - 주문 금액: {}, 최소 주문 금액: {}", + orderAmount, minOrderAmount); + Map errorDetails = Map.of( + "orderAmount", orderAmount.toPlainString(), + "minTotal", minOrderAmount.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + + log.info("업비트 시장가 매도 - 최소 주문 금액 검증 통과 - balance: {}, 현재가: {}, 주문 금액: {}, 최소 주문 금액: {}", + balance, currentPrice, orderAmount, minOrderAmount); + } + } + } catch (InvestException e) { + // MINIMUM_ORDER_AMOUNT 예외는 그대로 전파 + throw e; + } catch (Exception e) { + log.warn("업비트 시장가 매도 현재가 조회 실패 - 최소 주문 금액 검증을 생략합니다: {}", e.getMessage()); + } + + // 소수점 절사하여 정수로 변환 maxQuantity = balanceDecimal.setScale(0, RoundingMode.DOWN).toPlainString(); log.info("업비트 시장가 매도 - 코인 잔액: {}, 최대 매도 가능 수량: {} (정수)", balance, maxQuantity); } catch (NumberFormatException e) { @@ -269,22 +319,89 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List acco } } else { // 지정가 주문 - if (unitPrice != null && !unitPrice.isEmpty()) { + if ("ask".equals(side)) { + // 지정가 매도: 코인 보유 여부 확인 + BigDecimal balanceDecimal; try { - BigDecimal balanceDecimal = new BigDecimal(balance); - BigDecimal unitPriceDecimal = new BigDecimal(unitPrice); + balanceDecimal = new BigDecimal(balance); + } catch (NumberFormatException e) { + balanceDecimal = BigDecimal.ZERO; + } + + // 코인이 없으면 maxQuantity를 0으로 설정 (시장가 매도와 동일하게 처리) + if (balanceDecimal.compareTo(BigDecimal.ZERO) <= 0) { + log.warn("업비트 지정가 매도 - 보유 수량 없음 - coinType: {}, balance: {}", coinType, balance); + maxQuantity = "0"; + } else { + // 매도는 보유 수량이 maxQuantity + maxQuantity = balanceDecimal.setScale(0, RoundingMode.DOWN).toPlainString(); - if (unitPriceDecimal.compareTo(BigDecimal.ZERO) > 0) { - BigDecimal quantity = balanceDecimal.divide(unitPriceDecimal, 8, RoundingMode.DOWN); - // 소수점 절사하여 정수로 변환 - maxQuantity = quantity.setScale(0, RoundingMode.DOWN).toPlainString(); - log.info("업비트 최대 주문 수량 계산 - balance: {}, unitPrice: {}, maxQuantity: {} (정수)", - balance, unitPrice, maxQuantity); - } else { - log.warn("단위 가격이 0 이하입니다. 최대 주문 수량을 계산할 수 없습니다."); + // unitPrice가 있으면 최소 주문 금액 검증 + if (unitPrice != null && !unitPrice.isEmpty()) { + try { + BigDecimal unitPriceDecimal = new BigDecimal(unitPrice); + + if (unitPriceDecimal.compareTo(BigDecimal.ZERO) > 0) { + // 최소 주문 금액 검증: unitPrice * balance >= 5000원 + BigDecimal maxOrderAmount = unitPriceDecimal.multiply(balanceDecimal); + BigDecimal minOrderAmount = getUpbitMinimumOrderAmount(normalizeCoinType(coinType)); + + if (maxOrderAmount.compareTo(minOrderAmount) < 0) { + log.warn("업비트 지정가 매도 - 최소 주문 금액 미만 - 주문 금액: {}, 최소 주문 금액: {}", + maxOrderAmount, minOrderAmount); + Map errorDetails = Map.of( + "orderAmount", maxOrderAmount.toPlainString(), + "minTotal", minOrderAmount.toPlainString() + ); + throw new InvestException(InvestErrorCode.MINIMUM_ORDER_AMOUNT, errorDetails); + } + + log.info("업비트 지정가 매도 - 최소 주문 금액 검증 통과 - balance: {}, unitPrice: {}, maxQuantity: {}, 주문 금액: {}, 최소 주문 금액: {}", + balance, unitPrice, maxQuantity, maxOrderAmount, minOrderAmount); + } else { + log.warn("단위 가격이 0 이하입니다. 최소 주문 금액 검증을 할 수 없습니다."); + } + } catch (InvestException e) { + // MINIMUM_ORDER_AMOUNT, INSUFFICIENT_COIN_AMOUNT 예외는 그대로 전파 + throw e; + } catch (NumberFormatException e) { + log.warn("단위 가격 형식이 올바르지 않습니다. 최소 주문 금액 검증을 할 수 없습니다. unitPrice: {}", unitPrice); + } + } + } + } else { + // 지정가 매수 + if (unitPrice != null && !unitPrice.isEmpty()) { + try { + BigDecimal balanceDecimal = new BigDecimal(balance); + BigDecimal unitPriceDecimal = new BigDecimal(unitPrice); + + if (unitPriceDecimal.compareTo(BigDecimal.ZERO) > 0) { + // 단가가 잔고보다 크면 잔고 부족 에러 + if (unitPriceDecimal.compareTo(balanceDecimal) > 0) { + log.warn("업비트 지정가 매수 - 잔고 부족 - 잔고: {}, 단가: {}", balance, unitPrice); + Map errorDetails = Map.of( + "balance", balance, + "requiredAmount", unitPrice, + "shortage", unitPriceDecimal.subtract(balanceDecimal).toPlainString() + ); + throw new InvestException(InvestErrorCode.INSUFFICIENT_BALANCE, errorDetails); + } + + BigDecimal quantity = balanceDecimal.divide(unitPriceDecimal, 8, RoundingMode.DOWN); + // 소수점 절사하여 정수로 변환 + maxQuantity = quantity.setScale(0, RoundingMode.DOWN).toPlainString(); + log.info("업비트 최대 주문 수량 계산 - balance: {}, unitPrice: {}, maxQuantity: {} (정수)", + balance, unitPrice, maxQuantity); + } else { + log.warn("단위 가격이 0 이하입니다. 최대 주문 수량을 계산할 수 없습니다."); + } + } catch (InvestException e) { + // INSUFFICIENT_BALANCE 예외는 그대로 전파 + throw e; + } catch (NumberFormatException e) { + log.warn("단위 가격 형식이 올바르지 않습니다. 최대 주문 수량을 계산할 수 없습니다. unitPrice: {}", unitPrice); } - } catch (NumberFormatException e) { - log.warn("단위 가격 형식이 올바르지 않습니다. 최대 주문 수량을 계산할 수 없습니다. unitPrice: {}", unitPrice); } } } @@ -294,6 +411,9 @@ private MaxOrderInfoDTO parseMaxOrderInfoResponse(List acco return new MaxOrderInfoDTO(balance, maxQuantity); + } catch (InvestException e) { + // InvestException은 그대로 전파 (INSUFFICIENT_COIN_AMOUNT, MINIMUM_ORDER_AMOUNT 등) + throw e; } catch (Exception e) { log.error("업비트 최대 주문 정보 조회 API 응답 파싱 실패", e); throw new InvestException(InvestErrorCode.EXCHANGE_API_ERROR); From de2195accca4dc86fd4db9f835c01b70b3a48dae Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:27:33 +0900 Subject: [PATCH 32/37] =?UTF-8?q?=E2=9C=A8=20feat:=20Swagger=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EB=B3=80=EA=B2=BD=20(0.3.0=20->=200.3.2)?= 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 e4961ca..35594cd 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.0"); + Info info = new Info().title("스코이").description("스코이 Swagger").version("0.3.2"); // JWT 토큰 헤더 방식 String securityScheme = "JWT TOKEN"; From 3569ff5b59a311dee86e6c73daedb084355428e7 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:39:17 +0900 Subject: [PATCH 33/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B0=84=ED=8E=B8=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20DTO=20=EB=B3=80=EA=B2=BD=20(?= =?UTF-8?q?=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/auth/dto/AuthReqDTO.java | 14 ++++++++++++++ .../scoi/domain/auth/service/AuthService.java | 5 ++--- .../scoi/domain/member/dto/MemberReqDTO.java | 11 ----------- 3 files changed, 16 insertions(+), 14 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 bfa14b7..955757e 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 @@ -128,4 +128,18 @@ public record ReissueRequest( @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} + + // 간편 비밀번호 재설정 + public record ResetPassword( + @NotNull(message = "SMS 인증 토큰은 필수입니다.") + @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") + String verificationCode, + @NotNull(message = "휴대전화 번호는 필수입니다.") + @NotBlank(message = "휴대전화 번호는 빈칸일 수 없습니다.") + String phoneNumber, + @NotNull(message = "신규 간편 비밀번호는 필수입니다.") + @NotBlank(message = "신규 간편 비밀번호는 빈칸일 수 없습니다.") + @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") + String newPassword + ){} } 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 d7ad9c8..3a6c466 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 @@ -177,11 +177,10 @@ public String validateVerificationToken(String verificationToken, String phoneNu // 간편 비밀번호 재설정 @jakarta.transaction.Transactional public Void resetPassword( - MemberReqDTO.ResetPassword dto + AuthReqDTO.ResetPassword dto ) { - // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) - String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber()); + String phoneNumber = validateVerificationToken(dto.verificationCode(), dto.phoneNumber()); // 사용자 가져오기 Member member = memberRepository.findByPhoneNumber(phoneNumber) diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index ca0f3b2..8395ef3 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -18,17 +18,6 @@ public record ChangePassword( String newPassword ){} - // 간편 비밀번호 재설정 - public record ResetPassword( - @NotNull(message = "SMS 인증 토큰은 필수입니다.") - @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") - String verificationCode, - @NotNull(message = "신규 간편 비밀번호는 필수입니다.") - @NotBlank(message = "신규 간편 비밀번호는 빈칸일 수 없습니다.") - @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") - String newPassword - ){} - // API키 등록 및 수정 public record PostPatchApiKey( @NotNull(message = "거래소 타입은 빈칸일 수 없습니다.") From 0cd4fa05790c39ec1bd98e599fe916734a879a81 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:40:46 +0900 Subject: [PATCH 34/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B0=84=ED=8E=B8=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20(MemberService=20->=20AuthService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/service/MemberService.java | 44 ------------------- 1 file changed, 44 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 ed670a5..8c1fb2d 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 @@ -115,50 +115,6 @@ public Optional> changePassword( return Optional.empty(); } - // 간편 비밀번호 재설정 - @Transactional - public Void resetPassword( - MemberReqDTO.ResetPassword dto, - String phoneNumber - ) { - - Member member = memberRepository.findByPhoneNumber(phoneNumber) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 인증된 전화번호인지 확인 (verification:{verificationToken} 키로 조회) - String tokenKey = VERIFICATION_PREFIX + dto.verificationToken(); - String verifiedPhoneNumber = redisUtil.get(tokenKey); - if (verifiedPhoneNumber == null || !verifiedPhoneNumber.equals(dto.phoneNumber())) { - throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER); - } - - // 새 간편 비밀번호 검증 - String newPassword; - try { - newPassword = new String(hashUtil.decryptAES(dto.newPassword())); - - // 6자리 숫자가 아닌 경우 - if (!newPassword.matches(SIMPLE_PASSWORD_REGEX)) { - throw new IllegalArgumentException(); - } - } catch (GeneralSecurityException e ) { - Map binding = new HashMap<>(); - binding.put("password", "간편 비밀번호 복호화에 실패했습니다."); - throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); - } catch (IllegalArgumentException e) { - Map binding = new HashMap<>(); - binding.put("password", "6자리 숫자만 입력 가능합니다."); - throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding); - } - - // 간편 비밀번호 변경 - member.updateSimplePassword(passwordEncoder.encode(newPassword)); - - // 로그인 횟수 -> 0 - member.resetLoginFailCount(); - return null; - } - // 거래소 목록 조회 public List getExchangeList( String phoneNumber From b87579835e7c739333eb96d4cecf2048fb7c65a7 Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:41:14 +0900 Subject: [PATCH 35/37] =?UTF-8?q?=F0=9F=94=A5=20del:=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=20API=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/controller/AuthController.java | 11 +---------- .../example/scoi/global/config/SecurityConfig.java | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java index 311bb89..68a7283 100644 --- a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java @@ -26,15 +26,6 @@ public class AuthController { private final AuthService authService; - // SMS 인증 토큰 발급용: 최종 제출때는 삭제해야 함 - @GetMapping("/sms-token") - public ApiResponse generateSmsToken( - @RequestParam String phoneNumber - ){ - BaseSuccessCode code = GeneralSuccessCode.OK; - return ApiResponse.onSuccess(code, authService.generateSmsToken(phoneNumber)); - } - // 간편 비밀번호 재설정 @Operation( summary = "간편 비밀번호 재설정 API By 김주헌", @@ -42,7 +33,7 @@ public ApiResponse generateSmsToken( ) @PostMapping("/password/reset") public ApiResponse resetPassword( - @RequestBody MemberReqDTO.ResetPassword dto + @Valid @RequestBody AuthReqDTO.ResetPassword dto ){ BaseSuccessCode code = MemberSuccessCode.RESET_SIMPLE_PASSWORD; return ApiResponse.onSuccess(code, authService.resetPassword(dto)); 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 cdd938b..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", // 비인증 비밀번호 재설정 - "/auth/sms-token", // 임시 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", From 3ce721f5a6e148614a75d1cc2836d926d772eaff Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:48:05 +0900 Subject: [PATCH 36/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20DTO=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80,=20=EC=95=88=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20DTO=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/dto/AuthReqDTO.java | 19 +++---------------- 1 file changed, 3 insertions(+), 16 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 955757e..663d559 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 @@ -107,22 +107,6 @@ public record LoginRequest( String verificationToken ) {} - // 비인증 간편비밀번호 재설정 요청 (계정 잠금 후 SMS 재인증 flow) - public record PasswordResetRequest( - @NotBlank(message = "휴대폰 번호는 필수입니다.") - @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") - @Schema(description = "휴대폰 번호", example = "01012345678") - String phoneNumber, - - @NotBlank(message = "인증 토큰은 필수입니다.") - @Schema(description = "SMS 인증 완료 후 발급된 verificationToken") - String verificationToken, - - @NotBlank(message = "새 간편비밀번호는 필수입니다.") - @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") - String newPassword - ) {} - // 토큰 재발급 요청 public record ReissueRequest( @NotBlank(message = "Refresh Token은 필수입니다.") @@ -134,9 +118,12 @@ public record ResetPassword( @NotNull(message = "SMS 인증 토큰은 필수입니다.") @NotBlank(message = "SMS 인증 토큰은 빈칸일 수 없습니다.") String verificationCode, + @NotNull(message = "휴대전화 번호는 필수입니다.") @NotBlank(message = "휴대전화 번호는 빈칸일 수 없습니다.") + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") String phoneNumber, + @NotNull(message = "신규 간편 비밀번호는 필수입니다.") @NotBlank(message = "신규 간편 비밀번호는 빈칸일 수 없습니다.") @Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==") From 9e98013f86e50b5a7af6fa74824ce42b707b2b5c Mon Sep 17 00:00:00 2001 From: JuHeon Date: Thu, 12 Feb 2026 14:48:35 +0900 Subject: [PATCH 37/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=95=88=EC=93=B0=EB=8A=94=20import=EB=AC=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/scoi/domain/auth/controller/AuthController.java | 2 -- .../java/com/example/scoi/domain/member/dto/MemberReqDTO.java | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java index 68a7283..7880179 100644 --- a/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/scoi/domain/auth/controller/AuthController.java @@ -4,11 +4,9 @@ import com.example.scoi.domain.auth.dto.AuthReqDTO; import com.example.scoi.domain.auth.dto.AuthResDTO; import com.example.scoi.domain.auth.service.AuthService; -import com.example.scoi.domain.member.dto.MemberReqDTO; import com.example.scoi.domain.member.exception.code.MemberSuccessCode; import com.example.scoi.global.apiPayload.ApiResponse; import com.example.scoi.global.apiPayload.code.BaseSuccessCode; -import com.example.scoi.global.apiPayload.code.GeneralSuccessCode; import com.example.scoi.global.security.userdetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java index 8395ef3..6f14ef0 100644 --- a/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java @@ -1,8 +1,6 @@ package com.example.scoi.domain.member.dto; import com.example.scoi.domain.member.enums.ExchangeType; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull;