diff --git a/src/main/java/com/flyway/auth/service/SignUpServiceImpl.java b/src/main/java/com/flyway/auth/service/SignUpServiceImpl.java index 4d6d02a4..81b71e5e 100644 --- a/src/main/java/com/flyway/auth/service/SignUpServiceImpl.java +++ b/src/main/java/com/flyway/auth/service/SignUpServiceImpl.java @@ -5,6 +5,7 @@ import com.flyway.auth.domain.KakaoUserInfo; import com.flyway.auth.dto.EmailSignUpRequest; import com.flyway.auth.repository.SignUpAttemptRepository; +import com.flyway.sender.service.SmsVerificationService; import com.flyway.template.exception.BusinessException; import com.flyway.template.exception.ErrorCode; import com.flyway.user.domain.User; @@ -30,6 +31,7 @@ public class SignUpServiceImpl implements SignUpService { private final UserProfileRepository userProfileRepository; private final SignUpAttemptRepository signUpAttemptRepository; private final PasswordEncoder passwordEncoder; + private final SmsVerificationService smsVerificationService; @Override @Transactional @@ -37,6 +39,11 @@ public void signUp(EmailSignUpRequest request) { validateRequest(request); LocalDateTime now = LocalDateTime.now(); + // SMS 인증 확인 + if (!smsVerificationService.isVerified(request.getPhoneNumber())) { + throw new BusinessException(ErrorCode.USER_PHONE_NOT_VERIFIED); + } + int validAttempt = signUpAttemptRepository.consumeIfVerified(request.getAttemptId(), request.getEmail(), now); if (validAttempt != 1) { throw new BusinessException(ErrorCode.USER_INVALID_SIGN_UP_ATTEMPT); @@ -70,6 +77,7 @@ public void signUp(EmailSignUpRequest request) { userRepository.save(user); + // UserIdentity 생성 UserIdentity identity = UserIdentity.builder() .userIdentityId(UUID.randomUUID().toString()) @@ -124,14 +132,12 @@ public User signUpKakaoUser(KakaoUserInfo userInfo) { .build(); userIdentityRepository.save(identity); - UserProfile profile = UserProfile.builder() .userId(userId) .name(nickname) + .phoneNumber("") .build(); - userProfileRepository.createProfile(profile); - return user; } @@ -155,6 +161,10 @@ public void completeOauthSignUp(String userId, EmailSignUpRequest request) { userRepository.updateStatus(userId, AuthStatus.ACTIVE); + if (!smsVerificationService.isVerified(request.getPhoneNumber())) { + throw new BusinessException(ErrorCode.USER_PHONE_NOT_VERIFIED); + } + UserProfile profile = UserProfile.builder() .userId(userId) .name(request.getName()) diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java index 1349587f..2ed11b06 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java @@ -72,7 +72,8 @@ protected void configure(HttpSecurity http) throws Exception { // 비회원 허용 API .antMatchers( "/api/auth/**", - "/api/public/**" + "/api/public/**", + "/api/sms/**" ).permitAll() .anyRequest().authenticated() diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java index 4dc897d7..99655eca 100644 --- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java +++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java @@ -32,7 +32,7 @@ public class SecurityConfigWeb extends WebSecurityConfigurerAdapter { private static final String[] PUBLIC_ENDPOINTS = { "/", "/login", "/loginProc", "/signup", "/auth/**", "/search/**", - "/payments/success", "/payments/fail", "/payments/complete" //결제 콜백 + "/payments/success", "/payments/fail", "/payments/complete", "/api/sms/**" }; private final JwtProvider jwtProvider; diff --git a/src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java b/src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java index 5949a9bd..11733cc9 100644 --- a/src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java +++ b/src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java @@ -24,7 +24,8 @@ public class OnboardingAccessFilter extends OncePerRequestFilter { "/favicon.ico", "/error", "/api/auth/", - "/api/public/" + "/api/public/", + "/api/sms/" }; @Override diff --git a/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java index b99ba39e..288bc127 100644 --- a/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java @@ -42,7 +42,8 @@ protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { } return path.startsWith("/api/public/") - || path.startsWith("/api/auth/"); + || path.startsWith("/api/auth/") + || path.startsWith("/api/sms/"); } @Override diff --git a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java index 4bd86bd6..810ed6de 100644 --- a/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java +++ b/src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java @@ -35,7 +35,9 @@ public class JwtWebAuthFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { - return resolvePath(request).startsWith(ADMIN_PREFIX); + String path = resolvePath(request); + return path.startsWith(ADMIN_PREFIX) + || path.startsWith("/api/"); } @Override diff --git a/src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java b/src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java new file mode 100644 index 00000000..34d63887 --- /dev/null +++ b/src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java @@ -0,0 +1,35 @@ +package com.flyway.sender.controller; + +import com.flyway.sender.service.SmsVerificationService; +import com.flyway.template.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/sms") +@RequiredArgsConstructor +public class SmsVerificationApiController { + + private final SmsVerificationService service; + + @PostMapping("/send") + public ResponseEntity> send(@RequestParam String phoneNumber) { + try { + service.sendCode(phoneNumber); + return ResponseEntity.ok(ApiResponse.success(null, "인증번호가 발송되었습니다.")); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("FAIL", e.getMessage())); + } + } + + @PostMapping("/verify") + public ResponseEntity> verify( + @RequestParam String phoneNumber, + @RequestParam String code) { + boolean ok = service.verify(phoneNumber, code); + String msg = ok ? "인증되었습니다." : "인증번호가 일치하지 않습니다."; + return ResponseEntity.ok(ApiResponse.success(ok, msg)); + } +} \ No newline at end of file diff --git a/src/main/java/com/flyway/sender/domain/SmsVerification.java b/src/main/java/com/flyway/sender/domain/SmsVerification.java new file mode 100644 index 00000000..f733d590 --- /dev/null +++ b/src/main/java/com/flyway/sender/domain/SmsVerification.java @@ -0,0 +1,19 @@ +package com.flyway.sender.domain; + +import lombok.*; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmsVerification { + + private String smsVerificationId; + private String phoneNumber; + private String code; + private LocalDateTime expiresAt; + private LocalDateTime verifiedAt; + private LocalDateTime createdAt; + +} \ No newline at end of file diff --git a/src/main/java/com/flyway/sender/dto/PassengerTicketInfo.java b/src/main/java/com/flyway/sender/dto/PassengerTicketInfo.java index 447ad2f8..fb450dda 100644 --- a/src/main/java/com/flyway/sender/dto/PassengerTicketInfo.java +++ b/src/main/java/com/flyway/sender/dto/PassengerTicketInfo.java @@ -12,7 +12,6 @@ public class PassengerTicketInfo { private String passengerId; private String firstName; private String lastName; - private String phoneNumber; // 구간 정보 private String reservationSegmentId; diff --git a/src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java b/src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java new file mode 100644 index 00000000..a0383673 --- /dev/null +++ b/src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java @@ -0,0 +1,23 @@ +package com.flyway.sender.mapper; + +import com.flyway.sender.domain.SmsVerification; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.time.LocalDateTime; + +@Mapper +public interface SmsVerificationMapper { + void insert(SmsVerification verification); + + SmsVerification findLatestByPhone(@Param("phoneNumber") String phoneNumber); + + int markVerified(@Param("smsVerificationId") String id, + @Param("verifiedAt") LocalDateTime verifiedAt); + + int countRecentByPhone(@Param("phoneNumber") String phoneNumber, + @Param("since") LocalDateTime since); + + // 인증 완료 여부 확인 (5분 내) + int existsVerifiedPhone(@Param("phoneNumber") String phoneNumber, + @Param("since") LocalDateTime since); +} diff --git a/src/main/java/com/flyway/sender/service/SmsService.java b/src/main/java/com/flyway/sender/service/SmsService.java index bfdb61d7..d2afa99b 100644 --- a/src/main/java/com/flyway/sender/service/SmsService.java +++ b/src/main/java/com/flyway/sender/service/SmsService.java @@ -79,14 +79,21 @@ public void sendRefundComplete(String to, String reservationId, Long amount) { sendSms(to, content); } - // 탑승객에게 티켓 정보 SMS 발송 (reservationId만 받음) + // 탑승객 티켓 정보를 예약자(회원) 번호로 SMS 발송 public void sendTicketInfoToPassengers(String reservationId) { - // 항공편 정보 조회 + // 1. 예약자(회원)의 전화번호 조회 + String userPhone = smsMapper.selectPhoneByReservationId(reservationId); + if (userPhone == null || userPhone.isEmpty()) { + log.info("[SMS] 티켓 발송 실패 - 회원 전화번호 없음, reservationId: {}", reservationId); + return; + } + + // 2. 항공편 정보 조회 List segments = reservationBookingRepository.findSegments(reservationId); - // 탑승객별 티켓 정보 조회 (좌석/기내식/수하물 포함) + // 3. 탑승객별 티켓 정보 조회 (좌석/기내식/수하물 포함) List ticketInfoList = smsMapper.selectPassengerTicketInfo(reservationId); @@ -95,19 +102,20 @@ public void sendTicketInfoToPassengers(String reservationId) { return; } - // passengerId별로 그룹핑 + // 4. passengerId별로 그룹핑 Map> passengerMap = ticketInfoList.stream() .collect(Collectors.groupingBy(PassengerTicketInfo::getPassengerId)); + // 5. 각 탑승객 정보를 회원 번호로 발송 for (Map.Entry> entry : passengerMap.entrySet()) { List paxInfoList = entry.getValue(); PassengerTicketInfo firstInfo = paxInfoList.get(0); try { String content = buildTicketSmsContent(firstInfo, segments, paxInfoList); - sendSms(firstInfo.getPhoneNumber(), content); - log.info("[SMS] 티켓 발송 - passenger: {} {}, phone: {}", - firstInfo.getFirstName(), firstInfo.getLastName(), firstInfo.getPhoneNumber()); + sendSms(userPhone, content); // 회원 번호로 발송 + log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}", + firstInfo.getFirstName(), firstInfo.getLastName(), userPhone); } catch (Exception e) { log.error("[SMS] 티켓 발송 실패 - passengerId: {}, error: {}", firstInfo.getPassengerId(), e.getMessage()); diff --git a/src/main/java/com/flyway/sender/service/SmsVerificationService.java b/src/main/java/com/flyway/sender/service/SmsVerificationService.java new file mode 100644 index 00000000..cc60cb9a --- /dev/null +++ b/src/main/java/com/flyway/sender/service/SmsVerificationService.java @@ -0,0 +1,59 @@ +package com.flyway.sender.service; + +import com.flyway.sender.domain.SmsVerification; +import com.flyway.sender.mapper.SmsVerificationMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Random; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsVerificationService { + + private final SmsVerificationMapper mapper; + private final SmsService smsService; + + @Transactional + public void sendCode(String phoneNumber) { + // 5분 내 3회 제한 + int count = mapper.countRecentByPhone(phoneNumber, LocalDateTime.now().minusMinutes(5)); + if (count >= 3) { + throw new RuntimeException("잠시 후 다시 시도해주세요."); + } + + String code = String.format("%06d", new Random().nextInt(1000000)); + + SmsVerification v = SmsVerification.builder() + .smsVerificationId(UUID.randomUUID().toString()) + .phoneNumber(phoneNumber) + .code(code) + .expiresAt(LocalDateTime.now().plusMinutes(3)) + .build(); + mapper.insert(v); + + smsService.sendSms(phoneNumber, "[Flyway] 인증번호 [" + code + "]"); + log.info("[SMS] 인증코드 발송 - {}", phoneNumber.substring(0, 3) + "****"); + } + + @Transactional + public boolean verify(String phoneNumber, String code) { + SmsVerification v = mapper.findLatestByPhone(phoneNumber); + if (v == null) return false; + if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false; + if (!v.getCode().equals(code)) return false; + + mapper.markVerified(v.getSmsVerificationId(), LocalDateTime.now()); + return true; + } + + // 회원가입 시 인증 완료 여부 확인 (10분 내 인증 완료된 번호인지) + public boolean isVerified(String phoneNumber) { + return mapper.existsVerifiedPhone(phoneNumber, LocalDateTime.now().minusMinutes(10)) > 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/flyway/template/exception/ErrorCode.java b/src/main/java/com/flyway/template/exception/ErrorCode.java index 2c1eb17d..ded0135e 100644 --- a/src/main/java/com/flyway/template/exception/ErrorCode.java +++ b/src/main/java/com/flyway/template/exception/ErrorCode.java @@ -29,6 +29,7 @@ public enum ErrorCode { USER_PASSWORD_ENCODE_ERROR(500, "U006", "비밀번호 처리 중 오류가 발생했습니다."), USER_DB_ERROR(500, "U007", "회원 정보 저장 중 오류가 발생했습니다."), USER_INVALID_SIGN_UP_ATTEMPT(400, "U008", "유효하지 않은 회원가입 요청입니다."), + USER_PHONE_NOT_VERIFIED(400, "U012", "전화번호 인증이 필요합니다."), USER_ALREADY_WITHDRAWN(409, "U009", "이미 탈퇴 처리된 사용자입니다."), USER_NOT_WITHDRAWN(409, "U010", "탈퇴 처리된 사용자만 익명화할 수 있습니다."), USER_NOT_FOUND(404, "U011", "사용자를 찾을 수 없습니다."), diff --git a/src/main/resources/db/migration/V21__create_sms_verification_table.sql b/src/main/resources/db/migration/V21__create_sms_verification_table.sql new file mode 100644 index 00000000..69f275ad --- /dev/null +++ b/src/main/resources/db/migration/V21__create_sms_verification_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE sms_verification ( + sms_verification_id CHAR(36) NOT NULL, + phone_number VARCHAR(20) NOT NULL, + code VARCHAR(6) NOT NULL, + expires_at DATETIME NOT NULL, + verified_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sms_verification_id), + INDEX idx_sms_phone (phone_number, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file diff --git a/src/main/resources/mapper/sender/SmsMapper.xml b/src/main/resources/mapper/sender/SmsMapper.xml index 93515806..cc22892c 100644 --- a/src/main/resources/mapper/sender/SmsMapper.xml +++ b/src/main/resources/mapper/sender/SmsMapper.xml @@ -31,13 +31,13 @@ AND phone_number != '' + diff --git a/src/main/resources/mapper/sender/SmsVerificationMapper.xml b/src/main/resources/mapper/sender/SmsVerificationMapper.xml new file mode 100644 index 00000000..2385ba2c --- /dev/null +++ b/src/main/resources/mapper/sender/SmsVerificationMapper.xml @@ -0,0 +1,39 @@ + + + + + + + INSERT INTO sms_verification + (sms_verification_id, phone_number, code, expires_at, created_at) + VALUES (#{smsVerificationId}, #{phoneNumber}, #{code}, #{expiresAt}, NOW()) + + + + + + UPDATE sms_verification SET verified_at = #{verifiedAt} + WHERE sms_verification_id = #{smsVerificationId} AND verified_at IS NULL + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/user/UserProfileMapper.xml b/src/main/resources/mapper/user/UserProfileMapper.xml index abc0f200..d3131e1d 100644 --- a/src/main/resources/mapper/user/UserProfileMapper.xml +++ b/src/main/resources/mapper/user/UserProfileMapper.xml @@ -11,11 +11,10 @@ user_id, name, phone_number - ) VALUES ( - #{userId}, - #{name}, - COALESCE(#{phoneNumber}, '') - ) + ) VALUES (#{userId}, + #{name}, + #{phoneNumber} + ) diff --git a/src/main/webapp/WEB-INF/views/signup.jsp b/src/main/webapp/WEB-INF/views/signup.jsp index 4c0b5882..b69548c3 100644 --- a/src/main/webapp/WEB-INF/views/signup.jsp +++ b/src/main/webapp/WEB-INF/views/signup.jsp @@ -133,11 +133,39 @@
- -
- - -
+ +
+
+ + +
+ +
+ + + + +
diff --git a/src/main/webapp/resources/auth/signup.css b/src/main/webapp/resources/auth/signup.css index 87db6659..b513a751 100644 --- a/src/main/webapp/resources/auth/signup.css +++ b/src/main/webapp/resources/auth/signup.css @@ -111,3 +111,6 @@ body { margin-top: 6px; font-size: 0.85rem; } +.is-hidden { + display: none; +} \ No newline at end of file diff --git a/src/main/webapp/resources/signup/js/signup.js b/src/main/webapp/resources/signup/js/signup.js index f0df9dac..3804f56e 100644 --- a/src/main/webapp/resources/signup/js/signup.js +++ b/src/main/webapp/resources/signup/js/signup.js @@ -132,6 +132,16 @@ const phoneNumberInput = document.getElementById("phoneNumber"); const submitBtn = signupForm ? signupForm.querySelector('button[type="submit"]') : null; + const sendSmsBtn = document.getElementById("sendSmsBtn"); + const resendSmsBtn = document.getElementById("resendSmsBtn"); + const smsStatus = document.getElementById("smsStatus"); + const smsErrorStatus = document.getElementById("smsErrorStatus"); + const smsSentBox = document.getElementById("smsSentBox"); + const smsSuccessBox = document.getElementById("smsSuccessBox"); + const smsCodeInput = document.getElementById("smsCode"); + const verifySmsBtn = document.getElementById("verifySmsBtn"); + const smsVerifyStatus = document.getElementById("smsVerifyStatus"); + const phoneVerifiedHidden = document.getElementById("phoneVerified"); function setStatus(el, msg, ok) { if (!el) return; el.textContent = msg || ""; @@ -161,12 +171,15 @@ const phoneOk = isValidPhone(phoneNumberInput?.value?.trim()); let ok = nameOk && emailOk && phoneOk; - if (!isOauth) { - const verifiedOk = emailVerifiedHidden?.value === "true"; - const pw = passwordInput?.value || ""; - const confirm = passwordConfirmInput?.value || ""; - ok = ok && verifiedOk && validatePassword(pw) && pw === confirm; - } + if (!isOauth) { + const verifiedOk = emailVerifiedHidden?.value === "true"; + const pw = passwordInput?.value || ""; + const confirm = passwordConfirmInput?.value || ""; + ok = ok && verifiedOk && validatePassword(pw) && pw === confirm; + } + // 카카오/이메일 모두 SMS 인증 필요 + const phoneVerifiedOk = phoneVerifiedHidden?.value === "true"; + ok = ok && phoneVerifiedOk; submitBtn.disabled = !ok; submitBtn.classList.toggle("opacity-50", !ok); @@ -352,17 +365,27 @@ }); } - if (signupForm) { - signupForm.addEventListener("submit", function (e) { - if (!isOauth && emailVerifiedHidden && emailVerifiedHidden.value !== "true") { - e.preventDefault(); - if (typeof showToast === "function") { - showToast("이메일 인증을 완료해 주세요.", "error"); - } else { - alert("이메일 인증을 완료해 주세요."); - } - return; - } + if (signupForm) { + signupForm.addEventListener("submit", function (e) { + if (!isOauth && emailVerifiedHidden && emailVerifiedHidden.value !== "true") { + e.preventDefault(); + if (typeof showToast === "function") { + showToast("이메일 인증을 완료해 주세요.", "error"); + } else { + alert("이메일 인증을 완료해 주세요."); + } + return; + } + // SMS 인증 체크 (카카오/이메일 모두) + if (phoneVerifiedHidden && phoneVerifiedHidden.value !== "true") { + e.preventDefault(); + if (typeof showToast === "function") { + showToast("전화번호 인증을 완료해 주세요.", "error"); + } else { + alert("전화번호 인증을 완료해 주세요."); + } + return; + } if (!isOauth) { const pw = document.getElementById("rawPassword")?.value || ""; @@ -452,7 +475,125 @@ if (emailInput) emailInput.addEventListener("input", updateSubmitState); if (phoneNumberInput) phoneNumberInput.addEventListener("input", updateSubmitState); - window.goToStep = goToStep; + function resetSmsState() { + if (phoneVerifiedHidden) phoneVerifiedHidden.value = "false"; + if (smsSentBox) smsSentBox.classList.add("hidden"); + if (smsSuccessBox) smsSuccessBox.classList.add("hidden"); + if (smsErrorStatus) smsErrorStatus.classList.add("hidden"); + if (sendSmsBtn) { + sendSmsBtn.disabled = false; + sendSmsBtn.classList.remove("hidden"); + } + if (phoneNumberInput) phoneNumberInput.readOnly = false; + updateSubmitState(); + } + + if (phoneNumberInput) { + phoneNumberInput.addEventListener("input", resetSmsState); + } + + + async function handleSendSms() { + const phone = (phoneNumberInput?.value || "").replace(/\D/g, ""); + if (!isValidPhone(phone)) { + if (smsErrorStatus) { + smsErrorStatus.textContent = "올바른 전화번호를 입력해 주세요."; + smsErrorStatus.classList.remove("hidden"); + } + return; + } + + if (sendSmsBtn) sendSmsBtn.disabled = true; + if (smsErrorStatus) smsErrorStatus.classList.add("hidden"); + + try { + const res = await fetch(`${contextPath}/api/sms/send?phoneNumber=${encodeURIComponent(phone)}`, { + method: "POST" + }); + const data = await res.json(); + + if (!res.ok) { + if (smsErrorStatus) { + smsErrorStatus.textContent = data?.message || "인증번호 발송에 실패했습니다."; + smsErrorStatus.classList.remove("hidden"); + } + return; + } + + if (smsStatus) smsStatus.textContent = data?.message || "인증번호가 발송되었습니다."; + if (smsSentBox) smsSentBox.classList.remove("hidden"); + if (smsSuccessBox) smsSuccessBox.classList.add("hidden"); + if (sendSmsBtn) sendSmsBtn.classList.add("hidden"); + if (smsCodeInput) smsCodeInput.focus(); + } catch (e) { + if (smsErrorStatus) { + smsErrorStatus.textContent = "네트워크 오류가 발생했습니다."; + smsErrorStatus.classList.remove("hidden"); + } + } finally { + if (sendSmsBtn) sendSmsBtn.disabled = false; + } + } + + if (sendSmsBtn) { + sendSmsBtn.addEventListener("click", handleSendSms); + } + + if (resendSmsBtn) { + resendSmsBtn.addEventListener("click", handleSendSms); + } + if (verifySmsBtn) { + verifySmsBtn.addEventListener("click", async function () { + const phone = (phoneNumberInput?.value || "").replace(/\D/g, ""); + const code = (smsCodeInput?.value || "").trim(); + + if (!code || code.length !== 6) { + if (smsErrorStatus) { + smsErrorStatus.textContent = "6자리 인증번호를 입력해 주세요."; + smsErrorStatus.classList.remove("hidden"); + } + return; + } + + verifySmsBtn.disabled = true; + if (smsErrorStatus) smsErrorStatus.classList.add("hidden"); + + try { + const params = new URLSearchParams({ phoneNumber: phone, code: code }); + const res = await fetch(`${contextPath}/api/sms/verify?${params.toString()}`, { + method: "POST" + }); + const data = await res.json(); + + if (data?.data === true) { + if (smsVerifyStatus) smsVerifyStatus.textContent = "전화번호가 인증되었습니다."; + if (smsSuccessBox) smsSuccessBox.classList.remove("hidden"); + if (smsSentBox) smsSentBox.classList.add("hidden"); + if (phoneVerifiedHidden) phoneVerifiedHidden.value = "true"; + if (phoneNumberInput) phoneNumberInput.readOnly = true; + if (sendSmsBtn) sendSmsBtn.disabled = true; + if (smsCodeInput) smsCodeInput.readOnly = true; + updateSubmitState(); + } else { + if (smsErrorStatus) { + smsErrorStatus.textContent = data?.message || "인증번호가 일치하지 않습니다."; + smsErrorStatus.classList.remove("hidden"); + } + if (phoneVerifiedHidden) phoneVerifiedHidden.value = "false"; + } + } catch (e) { + if (smsErrorStatus) { + smsErrorStatus.textContent = "네트워크 오류가 발생했습니다."; + smsErrorStatus.classList.remove("hidden"); + } + if (phoneVerifiedHidden) phoneVerifiedHidden.value = "false"; + } finally { + verifySmsBtn.disabled = false; + } + }); + } + + window.goToStep = goToStep; window.toggleAllAgreements = toggleAllAgreements; window.checkAllStatus = checkAllStatus; window.selectMethod = selectMethod; diff --git a/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java b/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java index cd312303..80c6eee3 100644 --- a/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java +++ b/src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java @@ -4,6 +4,7 @@ import com.flyway.auth.domain.AuthStatus; import com.flyway.auth.dto.EmailSignUpRequest; import com.flyway.auth.repository.SignUpAttemptRepository; +import com.flyway.sender.service.SmsVerificationService; import com.flyway.template.exception.BusinessException; import com.flyway.template.exception.ErrorCode; import com.flyway.user.domain.User; @@ -36,6 +37,7 @@ class SignUpServiceImplTest { private SignUpAttemptRepository signUpAttemptRepository; private PasswordEncoder passwordEncoder; private SignUpServiceImpl signUpService; + private SmsVerificationService smsVerificationService; @BeforeEach void setUp() { @@ -44,13 +46,15 @@ void setUp() { userProfileRepository = Mockito.mock(UserProfileRepository.class); signUpAttemptRepository = Mockito.mock(SignUpAttemptRepository.class); passwordEncoder = Mockito.mock(PasswordEncoder.class); + smsVerificationService = Mockito.mock(SmsVerificationService.class); signUpService = new SignUpServiceImpl( userRepository, userIdentityRepository, userProfileRepository, signUpAttemptRepository, - passwordEncoder + passwordEncoder, + smsVerificationService ); } diff --git a/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java b/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java index ce7877cc..f377eb8b 100644 --- a/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java +++ b/src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java @@ -5,6 +5,7 @@ import com.flyway.auth.dto.EmailSignUpRequest; import com.flyway.auth.repository.EmailVerificationRepository; import com.flyway.auth.repository.SignUpAttemptRepository; +import com.flyway.sender.service.SmsVerificationService; import com.flyway.template.exception.BusinessException; import com.flyway.template.exception.ErrorCode; import com.flyway.user.domain.User; @@ -33,6 +34,7 @@ class UserAuthServiceImplTest { private PasswordEncoder passwordEncoder; private SignUpAttemptRepository signUpAttemptRepository; private EmailVerificationRepository emailVerificationRepository; + private SmsVerificationService smsVerificationService; private SignUpServiceImpl signUpService; @@ -43,13 +45,15 @@ void setUp() { userProfileRepository = Mockito.mock(UserProfileRepository.class); signUpAttemptRepository = Mockito.mock(SignUpAttemptRepository.class); passwordEncoder = Mockito.mock(PasswordEncoder.class); + smsVerificationService = Mockito.mock(SmsVerificationService.class); signUpService = new SignUpServiceImpl( userRepository, userIdentityRepository, userProfileRepository, signUpAttemptRepository, - passwordEncoder + passwordEncoder, + smsVerificationService ); }