diff --git a/be/build.gradle b/be/build.gradle index 2b6bd162..0568a67a 100644 --- a/be/build.gradle +++ b/be/build.gradle @@ -58,3 +58,7 @@ tasks.named('bootBuildImage') { tasks.named('test') { useJUnitPlatform() } + +clean { + delete file('src/main/generated') +} \ No newline at end of file diff --git a/be/src/main/java/yeonba/be/exception/JoinException.java b/be/src/main/java/yeonba/be/exception/JoinException.java new file mode 100644 index 00000000..a71374ce --- /dev/null +++ b/be/src/main/java/yeonba/be/exception/JoinException.java @@ -0,0 +1,31 @@ +package yeonba.be.exception; + +import org.springframework.http.HttpStatus; + +public enum JoinException implements BaseException { + + ALREADY_USED_PHONE_NUMBER( + HttpStatus.BAD_REQUEST, + "이미 사용 중인 핸드폰 번호입니다."); + + private final HttpStatus httpStatus; + private final String reason; + + JoinException(HttpStatus httpStatus, String reason) { + + this.httpStatus = httpStatus; + this.reason = reason; + } + + @Override + public HttpStatus getHttpStatus() { + + return httpStatus; + } + + @Override + public String getReason() { + + return reason; + } +} diff --git a/be/src/main/java/yeonba/be/exception/LoginException.java b/be/src/main/java/yeonba/be/exception/LoginException.java index de64f25e..deb5f8fd 100644 --- a/be/src/main/java/yeonba/be/exception/LoginException.java +++ b/be/src/main/java/yeonba/be/exception/LoginException.java @@ -6,16 +6,13 @@ public enum LoginException implements BaseException { VERIFICATION_CODE_NOT_FOUND( HttpStatus.BAD_REQUEST, - "해당 인증 코드 내역이 존재하지 않습니다."), - - EXPIRED_VERIFICATION_CODE( - HttpStatus.BAD_REQUEST, - "만료된 인증 코드입니다."); + "해당 인증 코드 내역이 존재하지 않습니다."); private final HttpStatus httpStatus; private final String reason; LoginException(HttpStatus httpStatus, String reason) { + this.httpStatus = httpStatus; this.reason = reason; } diff --git a/be/src/main/java/yeonba/be/login/controller/LoginController.java b/be/src/main/java/yeonba/be/login/controller/LoginController.java index 66cde475..23629e22 100644 --- a/be/src/main/java/yeonba/be/login/controller/LoginController.java +++ b/be/src/main/java/yeonba/be/login/controller/LoginController.java @@ -15,6 +15,7 @@ import yeonba.be.login.dto.request.UserPasswordInquiryRequest; import yeonba.be.login.dto.request.UserRefreshTokenRequest; import yeonba.be.login.dto.request.UserVerificationCodeRequest; +import yeonba.be.login.dto.request.UserVerifyPhoneNumberRequest; import yeonba.be.login.dto.response.UserEmailInquiryResponse; import yeonba.be.login.dto.response.UserJoinResponse; import yeonba.be.login.dto.response.UserLoginResponse; @@ -30,7 +31,6 @@ public class LoginController { private final LoginService loginService; @Operation(summary = "회원가입", description = "회원가입을 할 수 있습니다.") - @ApiResponse(responseCode = "200", description = "회원가입 성공") @PostMapping("/users/join") public ResponseEntity> join( @RequestBody UserJoinRequest request) { @@ -72,7 +72,7 @@ public ResponseEntity> emailInquiry( @ApiResponse(responseCode = "202", description = "임시 비밀번호 발급(비밀번호 찾기) 정상 처리") @PostMapping("/users/pw-inquiry") public ResponseEntity> passwordInquiry( - @RequestBody UserPasswordInquiryRequest request) { + @Valid @RequestBody UserPasswordInquiryRequest request) { loginService.sendTemporaryPasswordMail(request); @@ -82,7 +82,6 @@ public ResponseEntity> passwordInquiry( } @Operation(summary = "로그인", description = "로그인을 할 수 있습니다.") - @ApiResponse(responseCode = "200", description = "로그인 성공") @PostMapping("/users/login") public ResponseEntity> login( @RequestBody UserLoginRequest request) { @@ -116,4 +115,30 @@ public ResponseEntity> refresh( .ok() .body(new CustomResponse<>(new UserRefreshTokenResponse(createdJwt))); } + + @Operation(summary = "핸드폰 번호 인증 코드 sms 전송", description = "핸드 번호 인증 코드 sms 전송") + @ApiResponse(responseCode = "202", description = "인증 코드 전송 정상 처리") + @PostMapping("/users/join/phone-number/verification-code") + public ResponseEntity> verifyJoinPhoneNumber( + @Valid @RequestBody UserVerificationCodeRequest request) { + + loginService.sendJoinVerificationCodeMessage(request); + + return ResponseEntity + .accepted() + .body(new CustomResponse<>()); + } + + @Operation(summary = "핸드폰 번호 인증", description = "회원가입 과정서 핸드폰 번호 인증") + @ApiResponse(responseCode = "202", description = "핸드폰 번호 인증 정상 처리") + @PostMapping("/users/join/phone-number") + public ResponseEntity> verifyPhoneNumber( + @Valid @RequestBody UserVerifyPhoneNumberRequest request) { + + loginService.verifyPhoneNumber(request); + + return ResponseEntity + .accepted() + .body(new CustomResponse<>()); + } } diff --git a/be/src/main/java/yeonba/be/login/dto/request/UserVerifyPhoneNumberRequest.java b/be/src/main/java/yeonba/be/login/dto/request/UserVerifyPhoneNumberRequest.java new file mode 100644 index 00000000..9a155889 --- /dev/null +++ b/be/src/main/java/yeonba/be/login/dto/request/UserVerifyPhoneNumberRequest.java @@ -0,0 +1,32 @@ +package yeonba.be.login.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserVerifyPhoneNumberRequest { + + @Schema( + type = "string", + description = "인증 번호를 받은 번호", + example = "01011112222") + @Pattern( + regexp = "^010\\d{8}$", + message = "전화번호는 11자리 010으로 시작하며 하이픈(-) 없이 0~9의 숫자로 이뤄져야 합니다.") + @NotBlank(message = "전화번호는 반드시 입력되어야 합니다.") + private String phoneNumber; + + @Schema( + type = "string", + description = "아이디 찾기 인증 코드", + example = "A1b2C3") + @Pattern( + regexp = "^[A-Za-z0-9]{6}$", + message = "인증 코드는 6자리로 영어대소문자, 숫자로만 이뤄져야 합니다.") + @NotBlank(message = "인증 코드는 반드시 입력되어야 합니다.") + private String verificationCode; +} diff --git a/be/src/main/java/yeonba/be/login/repository/VerificationCodeCommand.java b/be/src/main/java/yeonba/be/login/repository/VerificationCodeCommand.java index af9b084a..54750016 100644 --- a/be/src/main/java/yeonba/be/login/repository/VerificationCodeCommand.java +++ b/be/src/main/java/yeonba/be/login/repository/VerificationCodeCommand.java @@ -1,5 +1,6 @@ package yeonba.be.login.repository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import yeonba.be.login.entity.VerificationCode; @@ -19,4 +20,9 @@ public void delete(VerificationCode verificationCode) { verificationCodeRepository.delete(verificationCode); } + + public void deleteAllExpiredAtBefore(LocalDateTime deletedAt) { + + verificationCodeRepository.deleteAllByExpiredAtBefore(deletedAt); + } } diff --git a/be/src/main/java/yeonba/be/login/repository/VerificationCodeQuery.java b/be/src/main/java/yeonba/be/login/repository/VerificationCodeQuery.java index 50ecbd0f..68303075 100644 --- a/be/src/main/java/yeonba/be/login/repository/VerificationCodeQuery.java +++ b/be/src/main/java/yeonba/be/login/repository/VerificationCodeQuery.java @@ -1,5 +1,6 @@ package yeonba.be.login.repository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import yeonba.be.exception.GeneralException; @@ -12,9 +13,13 @@ public class VerificationCodeQuery { private final VerificationCodeRepository verificationCodeRepository; - public VerificationCode findBy(String phoneNumber, String code) { + public VerificationCode findBy( + String phoneNumber, + String code, + LocalDateTime verifyAt) { - return verificationCodeRepository.findByPhoneNumberAndCode(phoneNumber, code) + return verificationCodeRepository + .findFirstByPhoneNumberAndCodeAndExpiredAtIsAfter(phoneNumber, code, verifyAt) .orElseThrow(() -> new GeneralException(LoginException.VERIFICATION_CODE_NOT_FOUND)); } } diff --git a/be/src/main/java/yeonba/be/login/repository/VerificationCodeRepository.java b/be/src/main/java/yeonba/be/login/repository/VerificationCodeRepository.java index 08f6d3d3..4cba2d23 100644 --- a/be/src/main/java/yeonba/be/login/repository/VerificationCodeRepository.java +++ b/be/src/main/java/yeonba/be/login/repository/VerificationCodeRepository.java @@ -1,10 +1,16 @@ package yeonba.be.login.repository; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import yeonba.be.login.entity.VerificationCode; public interface VerificationCodeRepository extends JpaRepository { - Optional findByPhoneNumberAndCode(String phoneNumber, String code); + Optional findFirstByPhoneNumberAndCodeAndExpiredAtIsAfter( + String phoneNumber, + String code, + LocalDateTime verifyAt); + + void deleteAllByExpiredAtBefore(LocalDateTime deletedAt); } diff --git a/be/src/main/java/yeonba/be/login/service/LoginService.java b/be/src/main/java/yeonba/be/login/service/LoginService.java index b8f4d305..2c9973dc 100644 --- a/be/src/main/java/yeonba/be/login/service/LoginService.java +++ b/be/src/main/java/yeonba/be/login/service/LoginService.java @@ -3,14 +3,16 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import yeonba.be.exception.GeneralException; -import yeonba.be.exception.LoginException; +import yeonba.be.exception.JoinException; import yeonba.be.exception.UserException; import yeonba.be.login.dto.request.UserEmailInquiryRequest; import yeonba.be.login.dto.request.UserPasswordInquiryRequest; import yeonba.be.login.dto.request.UserVerificationCodeRequest; +import yeonba.be.login.dto.request.UserVerifyPhoneNumberRequest; import yeonba.be.login.dto.response.UserEmailInquiryResponse; import yeonba.be.login.entity.VerificationCode; import yeonba.be.login.repository.VerificationCodeCommand; @@ -39,13 +41,13 @@ public class LoginService { private final EmailService emailService; private final SmsService smsService; - /* - 임시 비밀번호는 다음 과정을 거친다. - 1. 요청 이메일 기반 사용자 조회 - 2. 임시 비밀번호 생성 - 3. 사용자 비밀번호, 임시 비밀번호로 변경 - 4. 임시 비밀번호 발급 메일 전송 - */ + /* + 임시 비밀번호는 다음 과정을 거친다. + 1. 요청 이메일 기반 사용자 조회 + 2. 임시 비밀번호 생성 + 3. 사용자 비밀번호, 임시 비밀번호로 변경 + 4. 임시 비밀번호 발급 메일 전송 + */ // TODO : 비밀번호 암호화 로직 추가 @@ -90,14 +92,11 @@ public UserEmailInquiryResponse findEmail(UserEmailInquiryRequest request) { String phoneNumber = request.getPhoneNumber(); String code = request.getVerificationCode(); + LocalDateTime verifyAt = LocalDateTime.now(); // 인증 코드 조회 - VerificationCode verificationCode = verificationCodeQuery.findBy(phoneNumber, code); - - // 인증 코드 만료 여부 확인 - if (verificationCode.isExpired(LocalDateTime.now())) { - throw new GeneralException(LoginException.EXPIRED_VERIFICATION_CODE); - } + VerificationCode verificationCode = verificationCodeQuery + .findBy(phoneNumber, code, verifyAt); // 핸드폰 번호 기반 사용자 조회 및 인증 코드 내역 삭제 User user = userQuery.findByPhoneNumber(phoneNumber); @@ -105,4 +104,52 @@ public UserEmailInquiryResponse findEmail(UserEmailInquiryRequest request) { return new UserEmailInquiryResponse(user.getEmail()); } + + @Transactional + public void sendJoinVerificationCodeMessage(UserVerificationCodeRequest request) { + + // 이미 사용 중인 번호인 지 검증 + if (userQuery.existByPhoneNumber(request.getPhoneNumber())) { + + throw new GeneralException(JoinException.ALREADY_USED_PHONE_NUMBER); + } + + // 인증 코드 생성 및 저장 + VerificationCode verificationCode = saveVerificationCode(request); + + // 인증 코드 메시지 전송 + String message = String.format(VERIFICATION_CODE_MESSAGE, verificationCode.getCode()); + smsService.sendMessage(request.getPhoneNumber(), message); + } + + private VerificationCode saveVerificationCode(UserVerificationCodeRequest request) { + + String phoneNumber = request.getPhoneNumber(); + String code = VerificationCodeGenerator.generateVerificationCode(); + LocalDateTime expiredAt = LocalDateTime.now() + .plus(VERIFICATION_CODE_TTL, ChronoUnit.MINUTES); + VerificationCode verificationCode = new VerificationCode(phoneNumber, code, expiredAt); + + return verificationCodeCommand.save(verificationCode); + } + + @Transactional + public void verifyPhoneNumber(UserVerifyPhoneNumberRequest request) { + + String code = request.getVerificationCode(); + LocalDateTime verifyAt = LocalDateTime.now(); + + VerificationCode verificationCode = verificationCodeQuery + .findBy(request.getPhoneNumber(), code, verifyAt); + + verificationCodeCommand.delete(verificationCode); + } + + @Scheduled(cron = "0 0 0 1 * *") + @Transactional + public void deleteExpiredVerificationCodes() { + + LocalDateTime deletedAt = LocalDateTime.now(); + verificationCodeCommand.deleteAllExpiredAtBefore(deletedAt); + } }