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..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,9 +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.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; @@ -24,13 +24,17 @@ public class AuthController { private final AuthService authService; - // SMS 인증 토큰 발급용: 최종 제출때는 삭제해야 함 - @GetMapping("/sms-token") - public ApiResponse generateSmsToken( - @RequestParam String phoneNumber + // 간편 비밀번호 재설정 + @Operation( + summary = "간편 비밀번호 재설정 API By 김주헌", + description = "비밀번호 분실 또는 5회 실패 시 재설정을 합니다." + ) + @PostMapping("/password/reset") + public ApiResponse resetPassword( + @Valid @RequestBody AuthReqDTO.ResetPassword dto ){ - BaseSuccessCode code = GeneralSuccessCode.OK; - return ApiResponse.onSuccess(code, authService.generateSmsToken(phoneNumber)); + BaseSuccessCode code = MemberSuccessCode.RESET_SIMPLE_PASSWORD; + return ApiResponse.onSuccess(code, authService.resetPassword(dto)); } @Operation(summary = "SMS 발송 By 장명준", description = "휴대폰 번호로 인증번호를 발송합니다.") 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..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 @@ -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 = "인증번호는 필수입니다.") - @Size(min = 6, max = 6, message = "인증번호는 6자리입니다.") + @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{7}$", 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) @@ -103,4 +112,21 @@ public record ReissueRequest( @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} -} \ No newline at end of file + + // 간편 비밀번호 재설정 + 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==") + String newPassword + ){} +} 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/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", 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/auth/service/AuthService.java b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java index 4a0c44e..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 @@ -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분) @@ -136,8 +141,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); } /** @@ -162,11 +170,49 @@ 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; } + // 간편 비밀번호 재설정 + @jakarta.transaction.Transactional + public Void resetPassword( + AuthReqDTO.ResetPassword dto + ) { + // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + String phoneNumber = validateVerificationToken(dto.verificationCode(), dto.phoneNumber()); + + // 사용자 가져오기 + Member member = memberRepository.findByPhoneNumber(phoneNumber) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 새 간편 비밀번호 검증 + 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; + } + @Transactional public AuthResDTO.SignupResponse signup(AuthReqDTO.SignupRequest request) { // 1. Verification Token 검증 (SMS 인증 완료 확인) @@ -386,8 +432,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(); 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); } } 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..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 @@ -3,6 +3,8 @@ 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 java.util.List; @@ -10,22 +12,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/invest/client/adapter/BithumbApiClient.java b/src/main/java/com/example/scoi/domain/invest/client/adapter/BithumbApiClient.java index f0e7ec4..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 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 +732,110 @@ 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 불필요) + // 현재가 조회 후 (현재가 × 수량)으로 주문 금액 계산하여 최소 주문 금액과 비교 + 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); } - - BigDecimal volumeDecimal = new BigDecimal(volume); + requiredAmountStr = volume; // 매도 시 필요한 수량 + // 지정가/시장가 매도: 최소 주문 금액 검증 (orderAmount가 계산된 경우에만) + if (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); @@ -580,12 +885,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 사용) 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..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); @@ -389,8 +509,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 +680,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 +757,95 @@ 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 불필요) + // 현재가 조회 후 (현재가 × 수량)으로 주문 금액 계산하여 최소 주문 금액과 비교 + 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); } - BigDecimal volumeDecimal = new BigDecimal(volume); requiredAmountStr = volume; // 매도 시 필요한 수량 + // 지정가/시장가 매도: 최소 주문 금액 검증 (orderAmount가 계산된 경우에만) + if (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 +1260,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 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..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 @@ -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") @@ -47,10 +49,20 @@ public ApiResponse getMaxOrderInfo( @PostMapping("/orders/test") @Override public ApiResponse checkOrderAvailability( - @RequestBody InvestReqDTO.OrderDTO request, + @RequestBody InvestReqDTO.TestOrderDTO 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/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 ); 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 조회하기 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..8cd4983 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,7 @@ 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 lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -36,7 +37,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()); @@ -49,16 +50,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( @@ -85,7 +76,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 +90,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 +100,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..8db08cc 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,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; @@ -26,13 +27,7 @@ public interface MemberControllerDocs { summary = "간편 비밀번호 변경 API By 김주헌", description = "간편 비밀번호를 변경합니다." ) - 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); + ApiResponse> changePassword(@Valid @RequestBody MemberReqDTO.ChangePassword dto, @AuthenticationPrincipal CustomUserDetails user) throws GeneralSecurityException; @Operation( summary = "거래소 목록 조회 API By 김주헌", @@ -48,19 +43,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..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,35 +1,43 @@ 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, - String newPassword - ){} - - // 간편 비밀번호 재설정 - public record ResetPassword( - 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 ){} } 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/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); } 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/domain/transfer/controller/TransferController.java b/src/main/java/com/example/scoi/domain/transfer/controller/TransferController.java index 592fbf5..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 @@ -3,12 +3,14 @@ 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; 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.*; @@ -17,6 +19,7 @@ @RestController @RequestMapping("/api/transfers") @RequiredArgsConstructor +@Slf4j public class TransferController implements TransferControllerDocs{ private final TransferService transferService; @@ -73,10 +76,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 cfe8998..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(); } @@ -264,36 +264,38 @@ 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()) .recipientEnName(null) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.net_type())) + .exchangeType(ExchangeType.fromString(item.exchange_name().toUpperCase())) .currency(CoinType.valueOf(item.currency())) .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()) .recipientEnName(item.owner_en_name()) .walletAddress(item.withdraw_address()) - .exchangeType(ExchangeType.valueOf(item.net_type())) + .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/domain/transfer/dto/TransferReqDTO.java b/src/main/java/com/example/scoi/domain/transfer/dto/TransferReqDTO.java index bf9cdc7..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 = "수취인 이름은 필수입니다.") @@ -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 ) {} @@ -99,7 +102,7 @@ public record WithdrawRequest( ExchangeType exchangeType, @Schema(description = "수취인 유형 (빗썸 전용, 상대방 거래소)", example = "INDIVIDUAL", allowableValues = {"INDIVIDUAL", "CORPORATION"}) - MemberType receiverType, + String receiverType, @Schema(description = "수취인 성명 (국문, 빗썸 전용)", example = "김철수") String receiverKoName, @@ -145,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") 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", "즐겨찾기 수취인 등록에 성공했습니다.") 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); 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, 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 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..b017ef7 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,11 @@ 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", // 비인증 비밀번호 재설정 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", 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"; 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..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/", @@ -72,7 +73,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; 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);