diff --git a/database/schema.sql b/database/schema.sql index 2c74b78..a130e17 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -46,18 +46,18 @@ CREATE TABLE users nickname VARCHAR(50) NOT NULL, phone_number VARCHAR(20) NOT NULL UNIQUE, birth_date DATE NOT NULL, - gender VARCHAR(5) NOT NULL, # ENUM ('M', 'F') + gender VARCHAR(5) NOT NULL, # ENUM ('M', 'F') role VARCHAR(20) NOT NULL, # ENUM ('USER', 'SUPPLIER', 'ADMIN') - created_at DATETIME DEFAULT NOW(), - updated_at DATETIME DEFAULT NOW() ON UPDATE NOW() + created_at DATETIME DEFAULT NOW(), + updated_at DATETIME DEFAULT NOW() ON UPDATE NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE user_term_agreements ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - user_id BIGINT NOT NULL, - term_id BIGINT NOT NULL, + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + term_id BIGINT NOT NULL, agreed_at DATETIME DEFAULT NOW(), FOREIGN KEY (user_id) REFERENCES users (id), @@ -77,10 +77,11 @@ CREATE TABLE phone_verifications verification_code VARCHAR(5) NOT NULL, attempt_count INT NOT NULL DEFAULT 0, # 인증 시도 횟수 (최대 3회 제한) is_verified BOOLEAN NOT NULL DEFAULT FALSE, - expired_at DATETIME NOT NULL, - verified_at DATETIME NULL, - created_at DATETIME DEFAULT NOW(), - updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(), + expired_at DATETIME NOT NULL, + verified_at DATETIME NULL, + last_sent_at DATETIME NOT NULL, + created_at DATETIME DEFAULT NOW(), + updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(), UNIQUE KEY uk_phone_number (phone_number) ) ENGINE = InnoDB diff --git a/http/admin-api/term.http b/http/admin-api/term.http new file mode 100644 index 0000000..f161edc --- /dev/null +++ b/http/admin-api/term.http @@ -0,0 +1,16 @@ +### 약관 조회 (페이지네이션) +GET http://localhost:8081/v1/admin/terms?page=0&size=10 + + +### 약관 생성 +POST http://localhost:8081/v1/admin/terms +Content-Type: application/json + +{ + "code": "TERM_TEST", + "title": "약관 생성 테스트", + "content": "이 약관은 테스트를 위해 생성되었습니다.", + "displayOrder": 1000, + "isRequired": false, + "activatedAt": "2025-10-03T14:00:00" +} \ No newline at end of file diff --git a/http/api/signup.http b/http/api/signup.http new file mode 100644 index 0000000..a37d68e --- /dev/null +++ b/http/api/signup.http @@ -0,0 +1,50 @@ +### 회원가입을 위해 현재 활성화된 약관 조회 +GET http://localhost:8080/v1/terms/active + +### 핸드폰 인증 번호 요청 +POST http://localhost:8080/v1/phone-verifications/send +Content-Type: application/json + +{ + "phoneNumber": "010-2717-8134" +} + +### 핸드폰 인증 번호 검증 +POST http://localhost:8080/v1/phone-verifications/verify +Content-Type: application/json + +{ + "phoneNumber": "010-2717-8134", + "verificationCode": "64793" +} + +### 회원 가입 요청 +POST http://localhost:8080/v1/users/signup +Content-Type: application/json + +{ + "password": "seungjo123!", + "email": "f1v3@kakao.com", + "nickname": "f1v3", + "phoneNumber": "010-2717-8134", + "birth": "1999-05-13", + "gender": "M", + "agreedTerms": [ + { + "termCode": "TERM_SERVICE", + "version": 2 + }, + { + "termCode": "TERM_PRIVACY", + "version": 3 + }, + { + "termCode": "TERM_AGE", + "version": 1 + }, + { + "termCode": "TERM_MARKETING", + "version": 1 + } + ] +} \ No newline at end of file diff --git a/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/AdminReservationControllerAdvice.java b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/AdminReservationControllerAdvice.java new file mode 100644 index 0000000..db164d6 --- /dev/null +++ b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/AdminReservationControllerAdvice.java @@ -0,0 +1,87 @@ +package com.f1v3.reservation.admin; + +import com.f1v3.reservation.common.api.error.ErrorCode; +import com.f1v3.reservation.common.api.error.ReservationException; +import com.f1v3.reservation.common.api.response.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * 관리자 서버 컨트롤러 어드바이스 + * + * @author Seungjo, Jeong + */ +@Slf4j +@RestControllerAdvice +public class AdminReservationControllerAdvice { + + @Order(Ordered.HIGHEST_PRECEDENCE) + @ExceptionHandler(ReservationException.class) + public ResponseEntity> handleReservationException(ReservationException e) { + String logMessage = e.getLogMessage(); + e.getLogLevel().accept(logMessage); + + return ResponseEntity + .status(e.getStatus().getCode()) + .body(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleArgumentNotValidException( + MethodArgumentNotValidException e, HttpServletRequest request) { + + ErrorCode errorCode = ErrorCode.INVALID_REQUEST_PARAMETER; + + Map errors = new HashMap<>(); + Map parameters = new HashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> { + errors.put(error.getField(), error.getDefaultMessage()); + parameters.put(error.getField(), error.getRejectedValue()); + } + ); + + log.info("Validation error | URL: {} | Method: {} | Errors: {} | Parameters: {}", + request.getRequestURI(), + request.getMethod(), + errors, + parameters + ); + + ApiResponse response = ApiResponse.error( + errorCode.getCode(), + errorCode.getMessage(), + errors + ); + + return ResponseEntity + .status(errorCode.getStatus().getCode()) + .body(response); + } + + @Order(Ordered.LOWEST_PRECEDENCE) + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException( + Exception e, HttpServletRequest request) { + + log.error("Unexpected error | URL: {} | Method: {} | Error: {}", + request.getRequestURI(), + request.getMethod(), + e.getMessage(), + e + ); + + ErrorCode errorCode = ErrorCode.SERVER_ERROR; + return ResponseEntity + .status(errorCode.getStatus().getCode()) + .body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage())); + } +} diff --git a/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermController.java b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermController.java index 91cfb3b..33d1fcb 100644 --- a/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermController.java +++ b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermController.java @@ -3,12 +3,12 @@ import com.f1v3.reservation.admin.term.dto.CreateTermRequest; import com.f1v3.reservation.admin.term.dto.CreateTermResponse; import com.f1v3.reservation.admin.term.dto.TermResponse; +import com.f1v3.reservation.common.api.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -31,8 +31,8 @@ public class TermController { * @param pageable default page = 0, size = 10 */ @GetMapping - public ResponseEntity> getPagedTerms(@PageableDefault Pageable pageable) { - return ResponseEntity.ok(termService.getPagedTerms(pageable)); + public ApiResponse> getPagedTerms(@PageableDefault Pageable pageable) { + return ApiResponse.success(termService.getPagedTerms(pageable)); } /** @@ -42,8 +42,8 @@ public ResponseEntity> getPagedTerms(@PageableDefault Pageabl * @return 생성된 약관의 ID 포함한 응답 DTO */ @PostMapping - public ResponseEntity createTerm(@Valid @RequestBody CreateTermRequest request) { - CreateTermResponse response = termService.create(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createTerm(@Valid @RequestBody CreateTermRequest request) { + return ApiResponse.success(termService.create(request)); } } diff --git a/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermService.java b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermService.java index 978cdd0..39a50e9 100644 --- a/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermService.java +++ b/reservation-admin-api/src/main/java/com/f1v3/reservation/admin/term/TermService.java @@ -3,6 +3,8 @@ import com.f1v3.reservation.admin.term.dto.CreateTermRequest; import com.f1v3.reservation.admin.term.dto.CreateTermResponse; import com.f1v3.reservation.admin.term.dto.TermResponse; +import com.f1v3.reservation.common.api.error.ErrorCode; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.term.Term; import com.f1v3.reservation.common.domain.term.dto.AdminTermDto; import com.f1v3.reservation.common.domain.term.enums.TermCode; @@ -15,6 +17,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; + +import static com.f1v3.reservation.common.api.error.ErrorCode.TERM_CODE_INVALID; /** * 관리자용 약관 서비스 @@ -39,7 +44,7 @@ public List getPagedTerms(Pageable pageable) { @Transactional public CreateTermResponse create(CreateTermRequest request) { TermCode termCode = TermCode.getCode(request.code()) - .orElseThrow(() -> new IllegalArgumentException("Invalid term code: " + request.code())); + .orElseThrow(() -> new ReservationException(TERM_CODE_INVALID, log::warn)); int nextVersion = termRepository.findMaxVersionByCode(termCode) .map(version -> version + 1) @@ -68,8 +73,12 @@ private Term saveWithConstraintCheck(Term term) { try { return termRepository.save(term); } catch (DataIntegrityViolationException e) { - log.error("Term constraint violation: {}", e.getMessage()); - throw new IllegalStateException("Term constraint violation", e); + Map parameters = Map.of( + "termCode", term.getCode(), + "termVersion", term.getVersion() + ); + + throw new ReservationException(ErrorCode.TERM_VERSION_CONSTRAINT_VIOLATION, log::error, parameters, e); } } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/ReservationControllerAdvice.java b/reservation-api/src/main/java/com/f1v3/reservation/api/ReservationControllerAdvice.java new file mode 100644 index 0000000..5f3a8c2 --- /dev/null +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/ReservationControllerAdvice.java @@ -0,0 +1,87 @@ +package com.f1v3.reservation.api; + +import com.f1v3.reservation.common.api.error.ErrorCode; +import com.f1v3.reservation.common.api.error.ReservationException; +import com.f1v3.reservation.common.api.response.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * 사용자 서버 컨트롤러 어드바이스 + * + * @author Seungjo, Jeong + */ +@Slf4j +@RestControllerAdvice +public class ReservationControllerAdvice { + + @Order(Ordered.HIGHEST_PRECEDENCE) + @ExceptionHandler(ReservationException.class) + public ResponseEntity> handleReservationException(ReservationException e) { + String logMessage = e.getLogMessage(); + e.getLogLevel().accept(logMessage); + + return ResponseEntity + .status(e.getStatus().getCode()) + .body(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleArgumentNotValidException( + MethodArgumentNotValidException e, HttpServletRequest request) { + + ErrorCode code = ErrorCode.INVALID_REQUEST_PARAMETER; + + Map errors = new HashMap<>(); + Map parameters = new HashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> { + errors.put(error.getField(), error.getDefaultMessage()); + parameters.put(error.getField(), error.getRejectedValue()); + } + ); + + log.info("Validation error | URI = {} | Method = {} | Errors = {} | Parameters = {}", + request.getRequestURI(), + request.getMethod(), + errors, + parameters + ); + + ApiResponse response = ApiResponse.error( + ErrorCode.INVALID_REQUEST_PARAMETER.getCode(), + ErrorCode.INVALID_REQUEST_PARAMETER.getMessage(), + errors + ); + + return ResponseEntity + .status(code.getStatus().getCode()) + .body(response); + } + + @Order(Ordered.LOWEST_PRECEDENCE) + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException( + Exception e, HttpServletRequest request) { + + log.error("Unexpected error | URL = {} | Method = {} | Message = {}", + request.getRequestURI(), + request.getMethod(), + e.getMessage(), + e + ); + + ErrorCode errorCode = ErrorCode.SERVER_ERROR; + return ResponseEntity + .status(errorCode.getStatus().getCode()) + .body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage())); + } +} diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationController.java b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationController.java index d21e464..aa09e84 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationController.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationController.java @@ -3,14 +3,11 @@ import com.f1v3.reservation.api.phoneverification.dto.SendPhoneVerificationRequest; import com.f1v3.reservation.api.phoneverification.dto.SendPhoneVerificationResponse; import com.f1v3.reservation.api.phoneverification.dto.VerifyPhoneVerificationRequest; +import com.f1v3.reservation.common.api.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; /** * 핸드폰 인증 컨트롤러 클래스 @@ -25,15 +22,16 @@ public class PhoneVerificationController { private final PhoneVerificationService phoneVerificationService; @PostMapping("/send") - public ResponseEntity sendVerifyCode(@Valid @RequestBody SendPhoneVerificationRequest request) { + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse sendVerifyCode(@Valid @RequestBody SendPhoneVerificationRequest request) { SendPhoneVerificationResponse response = phoneVerificationService.sendVerifyCode(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + return ApiResponse.success(response); } @PostMapping("/verify") - public ResponseEntity verifyCode(@Valid @RequestBody VerifyPhoneVerificationRequest request) { + @ResponseStatus(HttpStatus.CREATED) + public void verifyCode(@Valid @RequestBody VerifyPhoneVerificationRequest request) { phoneVerificationService.incrementAttempt(request.phoneNumber()); phoneVerificationService.verifyCode(request); - return ResponseEntity.ok().build(); } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationService.java b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationService.java index 372a0f6..7104749 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationService.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/PhoneVerificationService.java @@ -8,6 +8,7 @@ import com.f1v3.reservation.api.phoneverification.strategy.PhoneVerificationStrategy; import com.f1v3.reservation.api.phoneverification.strategy.ResendVerificationStrategy; import com.f1v3.reservation.api.user.UserValidationService; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.phoneverification.PhoneVerification; import com.f1v3.reservation.common.domain.phoneverification.repository.PhoneVerificationRepository; import lombok.RequiredArgsConstructor; @@ -17,6 +18,8 @@ import java.util.concurrent.ThreadLocalRandom; +import static com.f1v3.reservation.common.api.error.ErrorCode.*; + /** * 핸드폰 인증 서비스 클래스 * @@ -46,7 +49,7 @@ public SendPhoneVerificationResponse sendVerifyCode(SendPhoneVerificationRequest @Transactional public void incrementAttempt(String phoneNumber) { PhoneVerification verification = phoneVerificationRepository.findByPhoneNumber(phoneNumber) - .orElseThrow(() -> new IllegalArgumentException("인증 요청이 존재하지 않습니다.")); + .orElseThrow(() -> new ReservationException(PHONE_VERIFICATION_NOT_FOUND, log::info)); verification.incrementAttempt(); } @@ -54,22 +57,22 @@ public void incrementAttempt(String phoneNumber) { @Transactional public void verifyCode(VerifyPhoneVerificationRequest request) { PhoneVerification verification = phoneVerificationRepository.findByPhoneNumber(request.phoneNumber()) - .orElseThrow(() -> new IllegalArgumentException("인증 요청이 존재하지 않습니다.")); + .orElseThrow(() -> new ReservationException(PHONE_VERIFICATION_NOT_FOUND, log::info)); if (verification.isAlreadyVerified()) { - throw new IllegalArgumentException("이미 인증된 핸드폰 번호입니다."); + throw new ReservationException(PHONE_VERIFICATION_ALREADY_VERIFIED, log::info); } if (verification.isExpired()) { - throw new IllegalArgumentException("인증 요청이 만료되었습니다. 다시 인증코드를 발급해주세요."); + throw new ReservationException(PHONE_VERIFICATION_CODE_EXPIRED, log::info); } if (verification.isExceededMaxAttempts()) { - throw new IllegalArgumentException("인증 시도 횟수를 초과하였습니다. 다시 인증코드를 발급해주세요."); + throw new ReservationException(PHONE_VERIFICATION_ATTEMPTS_EXCEEDED, log::info); } if (!verification.checkCode(request.verificationCode())) { - throw new IllegalArgumentException("인증 코드가 일치하지 않습니다."); + throw new ReservationException(PHONE_VERIFICATION_CODE_INVALID, log::info); } userValidationService.checkPhoneNumberExists(request.phoneNumber()); @@ -81,14 +84,14 @@ public void verifyCode(VerifyPhoneVerificationRequest request) { @Transactional(readOnly = true) public void checkVerified(String phoneNumber) { PhoneVerification verification = phoneVerificationRepository.findByPhoneNumber(phoneNumber) - .orElseThrow(() -> new IllegalStateException("핸드폰 인증 내역이 존재하지 않습니다.")); + .orElseThrow(() -> new ReservationException(PHONE_VERIFICATION_NOT_FOUND, log::info)); if (!verification.isAlreadyVerified()) { - throw new IllegalStateException("핸드폰 인증이 완료되지 않았습니다."); + throw new ReservationException(PHONE_VERIFICATION_NOT_VERIFIED, log::info); } if (verification.isExpiredForVerifiedDuration()) { - throw new IllegalStateException("핸드폰 인증이 만료되었습니다. 다시 인증해주세요."); + throw new ReservationException(PHONE_VERIFICATION_INFO_EXPIRED, log::info); } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/strategy/ResendVerificationStrategy.java b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/strategy/ResendVerificationStrategy.java index e5c2285..e743b6a 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/strategy/ResendVerificationStrategy.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/phoneverification/strategy/ResendVerificationStrategy.java @@ -1,7 +1,10 @@ package com.f1v3.reservation.api.phoneverification.strategy; +import com.f1v3.reservation.common.api.error.ErrorCode; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.phoneverification.PhoneVerification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; @@ -10,6 +13,7 @@ * * @author Seungjo, Jeong */ +@Slf4j @RequiredArgsConstructor public class ResendVerificationStrategy implements PhoneVerificationStrategy { @@ -20,11 +24,12 @@ public class ResendVerificationStrategy implements PhoneVerificationStrategy { @Override public PhoneVerification execute(String phoneNumber) { - boolean isResendAllowed = existingVerification.getCreatedAt().plusMinutes(RESEND_LIMIT_MINUTES) + + boolean isResendAllowed = existingVerification.getLastSentAt().plusMinutes(RESEND_LIMIT_MINUTES) .isBefore(LocalDateTime.now()); if (!isResendAllowed) { - throw new IllegalArgumentException(RESEND_LIMIT_MINUTES + "분 이내에 생성한 인증 요청이 존재합니다."); + throw new ReservationException(ErrorCode.PHONE_VERIFICATION_RESEND_NOT_ALLOWED, log::info); } existingVerification.resend(newCode); diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermController.java b/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermController.java index a1a7345..d877e20 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermController.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermController.java @@ -1,8 +1,8 @@ package com.f1v3.reservation.api.term; import com.f1v3.reservation.api.term.dto.TermResponse; +import com.f1v3.reservation.common.api.response.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,7 +25,7 @@ public class TermController { * 회원가입을 위한 활성 약관 조회 */ @GetMapping("/terms/active") - public ResponseEntity> getActiveTerms() { - return ResponseEntity.ok(termService.getActiveTerms()); + public ApiResponse> getActiveTerms() { + return ApiResponse.success(termService.getActiveTerms()); } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermValidationService.java b/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermValidationService.java index ee854fb..1352518 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermValidationService.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/term/TermValidationService.java @@ -2,18 +2,24 @@ import com.f1v3.reservation.api.term.dto.TermResponse; import com.f1v3.reservation.api.user.dto.SignupUserRequest; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.term.enums.TermCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.Set; import java.util.stream.Collectors; +import static com.f1v3.reservation.common.api.error.ErrorCode.TERM_CODE_INVALID; +import static com.f1v3.reservation.common.api.error.ErrorCode.TERM_REQUIRED_NOT_AGREED; + /** * 회원가입 약관 동의 검증 서비스 * * @author Seungjo, Jeong */ +@Slf4j @Service @RequiredArgsConstructor public class TermValidationService { @@ -28,13 +34,14 @@ public void validateRequiredTermsAgreed(Set Set agreedTermIds = termRequests.stream() .map(request -> TermCode.getCode(request.termCode()) - .orElseThrow(() -> new IllegalArgumentException("Invalid termCode: " + request.termCode()))) + .orElseThrow(() -> new ReservationException(TERM_CODE_INVALID, log::warn)) + ) .collect(Collectors.toSet()); requiredTermIds.removeAll(agreedTermIds); if (!requiredTermIds.isEmpty()) { - throw new IllegalArgumentException("필수 약관에 모두 동의해야 합니다. 누락된 약관 id=" + requiredTermIds); + throw new ReservationException(TERM_REQUIRED_NOT_AGREED, log::info); } } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserController.java b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserController.java index e75db61..46d9edc 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserController.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserController.java @@ -2,14 +2,11 @@ import com.f1v3.reservation.api.user.dto.SignupUserRequest; import com.f1v3.reservation.api.user.dto.SignupUserResponse; +import com.f1v3.reservation.common.api.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; /** * 회원 API 컨트롤러 @@ -24,8 +21,8 @@ public class UserController { private final UserService userService; @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupUserRequest request) { - SignupUserResponse response = userService.signup(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signup(@Valid @RequestBody SignupUserRequest request) { + return ApiResponse.success(userService.signup(request)); } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserService.java b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserService.java index ed1db09..063ff08 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserService.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserService.java @@ -41,7 +41,7 @@ public SignupUserResponse signup(SignupUserRequest request) { .nickname(request.nickname()) .phoneNumber(request.phoneNumber()) .birth(request.birth()) - .gender(Gender.findBy(request.gender())) + .gender(Gender.getGender(request.gender())) .build(); User savedUser = userRepository.save(newUser); diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserTermAgreementService.java b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserTermAgreementService.java index 1bb568e..bd5066b 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserTermAgreementService.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserTermAgreementService.java @@ -2,6 +2,7 @@ import com.f1v3.reservation.api.term.TermValidationService; import com.f1v3.reservation.api.user.dto.SignupUserRequest; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.term.Term; import com.f1v3.reservation.common.domain.term.enums.TermCode; import com.f1v3.reservation.common.domain.term.repository.TermRepository; @@ -9,17 +10,21 @@ import com.f1v3.reservation.common.domain.user.UserTermAgreement; import com.f1v3.reservation.common.domain.user.repository.UserTermAgreementRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Set; +import static com.f1v3.reservation.common.api.error.ErrorCode.TERM_NOT_FOUND; + /** * 사용자 약관 동의 서비스 클래스 * * @author Seungjo, Jeong */ +@Slf4j @Service @RequiredArgsConstructor public class UserTermAgreementService { @@ -41,10 +46,10 @@ public void createAgreements(User user, Set private UserTermAgreement createAgreement(User user, SignupUserRequest.SignupTermRequest request) { TermCode termCode = TermCode.getCode(request.termCode()) - .orElseThrow(() -> new IllegalArgumentException("Invalid termCode: " + request.termCode())); + .orElseThrow(() -> new ReservationException(TERM_NOT_FOUND, log::warn)); Term term = termRepository.findByCodeAndVersion(termCode, request.version()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 약관입니다. termCode=" + request.termCode() + ", version=" + request.version())); + .orElseThrow(() -> new ReservationException(TERM_NOT_FOUND, log::warn)); return UserTermAgreement.builder() .user(user) diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserValidationService.java b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserValidationService.java index b242ef6..f0aaef7 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserValidationService.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/user/UserValidationService.java @@ -1,14 +1,20 @@ package com.f1v3.reservation.api.user; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import static com.f1v3.reservation.common.api.error.ErrorCode.USER_EMAIL_ALREADY_EXISTS; +import static com.f1v3.reservation.common.api.error.ErrorCode.USER_PHONE_ALREADY_EXISTS; + /** * 회원 검증 서비스 * * @author Seungjo, Jeong */ +@Slf4j @Service @RequiredArgsConstructor public class UserValidationService { @@ -17,13 +23,13 @@ public class UserValidationService { public void checkPhoneNumberExists(String phoneNumber) { if (userRepository.existsByPhoneNumber(phoneNumber)) { - throw new IllegalArgumentException("이미 가입된 핸드폰 번호입니다."); + throw new ReservationException(USER_PHONE_ALREADY_EXISTS, log::info); } } public void checkEmailExists(String email) { if (userRepository.existsByEmail(email)) { - throw new IllegalArgumentException("이미 가입된 이메일입니다."); + throw new ReservationException(USER_EMAIL_ALREADY_EXISTS, log::info); } } } diff --git a/reservation-api/src/main/java/com/f1v3/reservation/api/user/dto/SignupUserRequest.java b/reservation-api/src/main/java/com/f1v3/reservation/api/user/dto/SignupUserRequest.java index e7a2faf..43ca4db 100644 --- a/reservation-api/src/main/java/com/f1v3/reservation/api/user/dto/SignupUserRequest.java +++ b/reservation-api/src/main/java/com/f1v3/reservation/api/user/dto/SignupUserRequest.java @@ -3,10 +3,7 @@ import com.f1v3.reservation.common.domain.term.enums.TermCode; import com.f1v3.reservation.common.domain.user.enums.Gender; import com.f1v3.reservation.common.validator.EnumValid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.*; import java.time.LocalDate; import java.util.Set; @@ -21,6 +18,7 @@ public record SignupUserRequest( String password, @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") String email, @NotBlank(message = "닉네임을 입력해주세요.") @@ -31,6 +29,7 @@ public record SignupUserRequest( String phoneNumber, @NotNull(message = "생년월일을 입력해주세요.") + @Past(message = "생년월일은 과거 날짜여야 합니다.") LocalDate birth, @NotBlank(message = "성별을 입력해주세요.") diff --git a/reservation-common/build.gradle b/reservation-common/build.gradle index 7c0e044..ef26877 100644 --- a/reservation-common/build.gradle +++ b/reservation-common/build.gradle @@ -10,4 +10,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.mindrot:jbcrypt:0.4' + + implementation 'org.apache.commons:commons-lang3:3.19.0' + implementation 'com.google.guava:guava:33.5.0-jre' + } \ No newline at end of file diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorCode.java b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorCode.java new file mode 100644 index 0000000..ba43da1 --- /dev/null +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorCode.java @@ -0,0 +1,61 @@ +package com.f1v3.reservation.common.api.error; + +import lombok.Getter; + +/** + * API 공통 에러 코드 ENUM + * + * @author Seungjo, Jeong + */ +@Getter +public enum ErrorCode { + + /* + 공통 에러 정의 [code: 1xxx] + */ + INVALID_REQUEST_PARAMETER(ErrorStatus.BAD_REQUEST, 1000, "요청 값이 올바르지 않습니다. 요청 값을 확인해주세요."), + + /* + 약관(Term) [code: 41xx] + */ + TERM_NOT_FOUND(ErrorStatus.NOT_FOUND, 4101, "약관을 찾을 수 없습니다."), + TERM_CODE_INVALID(ErrorStatus.BAD_REQUEST, 4102, "올바르지 않은 약관 코드입니다."), + TERM_REQUIRED_NOT_AGREED(ErrorStatus.BAD_REQUEST, 4103, "필수 약관에 동의하지 않았습니다."), + TERM_VERSION_CONSTRAINT_VIOLATION(ErrorStatus.CONFLICT, 4104, "약관 생성 시 버전 충돌이 발생했습니다. 버전을 확인해주세요."), + TERM_REQUIRED_DISPLAY_ORDER_INVALID(ErrorStatus.BAD_REQUEST, 4105, "필수 약관의 표시 순서는 0 ~ 500 사이의 값이어야 합니다."), + TERM_OPTIONAL_DISPLAY_ORDER_INVALID(ErrorStatus.BAD_REQUEST, 4106, "선택 약관의 표시 순서는 501 ~ 1000 사이의 값이어야 합니다."), + + /* + 핸드폰 인증(PhoneVerification) [code: 42xx] + */ + PHONE_VERIFICATION_NOT_FOUND(ErrorStatus.NOT_FOUND, 4201, "핸드폰 인증 정보를 찾을 수 없습니다."), + PHONE_VERIFICATION_CODE_EXPIRED(ErrorStatus.BAD_REQUEST, 4202, "인증번호가 만료되었습니다."), + PHONE_VERIFICATION_CODE_INVALID(ErrorStatus.BAD_REQUEST, 4203, "인증번호가 올바르지 않습니다."), + PHONE_VERIFICATION_ALREADY_VERIFIED(ErrorStatus.CONFLICT, 4204, "이미 인증된 핸드폰 번호입니다."), + PHONE_VERIFICATION_ATTEMPTS_EXCEEDED(ErrorStatus.BAD_REQUEST, 4205, "인증 시도 횟수를 초과했습니다."), + PHONE_VERIFICATION_NOT_VERIFIED(ErrorStatus.BAD_REQUEST, 4207, "핸드폰 인증이 완료되지 않았습니다."), + PHONE_VERIFICATION_INFO_EXPIRED(ErrorStatus.BAD_REQUEST, 4208, "핸드폰 인증 정보가 만료되었습니다."), + PHONE_VERIFICATION_RESEND_NOT_ALLOWED(ErrorStatus.BAD_REQUEST, 4209, "인증번호 재전송은 3분 후에 가능합니다."), + + /* + 회원(User) [code: 43xx] + */ + USER_EMAIL_ALREADY_EXISTS(ErrorStatus.CONFLICT, 4301, "이미 등록된 이메일입니다."), + USER_PHONE_ALREADY_EXISTS(ErrorStatus.CONFLICT, 4302, "이미 등록된 핸드폰 번호입니다."), + + /* + 서버 에러 정의 + */ + SERVER_ERROR(ErrorStatus.INTERNAL_SERVER_ERROR, 5000, "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."), + ; + + private final ErrorStatus status; + private final int code; + private final String message; + + ErrorCode(ErrorStatus status, int code, String message) { + this.status = status; + this.code = code; + this.message = message; + } +} \ No newline at end of file diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorStatus.java b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorStatus.java new file mode 100644 index 0000000..e150f1a --- /dev/null +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ErrorStatus.java @@ -0,0 +1,26 @@ +package com.f1v3.reservation.common.api.error; + +import lombok.Getter; + +/** + * HTTP 에러 상태 코드 + * + * @author Seungjo, Jeong + */ +@Getter +public enum ErrorStatus { + + BAD_REQUEST(400), + UNAUTHORIZED(401), + FORBIDDEN(403), + NOT_FOUND(404), + METHOD_NOT_ALLOWED(405), + CONFLICT(409), + INTERNAL_SERVER_ERROR(500); + + private final int code; + + ErrorStatus(int code) { + this.code = code; + } +} diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ReservationException.java b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ReservationException.java new file mode 100644 index 0000000..ad217f9 --- /dev/null +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/api/error/ReservationException.java @@ -0,0 +1,55 @@ +package com.f1v3.reservation.common.api.error; + +import com.google.common.base.Joiner; +import lombok.Getter; +import org.apache.commons.lang3.exception.ExceptionUtils; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Consumer; + +/** + * 공통 예외 최상위 클래스 + * + * @author Seungjo, Jeong + */ +@Getter +public class ReservationException extends RuntimeException { + private final ErrorStatus status; + private final int code; + private final String message; + private final Consumer logLevel; + private final Map parameters; + private final Exception rootCause; + + // 필수 정보: status, code, message, logLevel + public ReservationException(ErrorCode errorCode, Consumer logLevel, Map parameters, Exception rootCause) { + super(errorCode.getMessage(), rootCause); + this.status = errorCode.getStatus(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.logLevel = logLevel; + this.parameters = parameters; + this.rootCause = rootCause; + } + + public ReservationException(ErrorCode errorCode, Consumer logLevel, Map parameters) { + this(errorCode, logLevel, parameters, null); + } + + public ReservationException(ErrorCode errorCode, Consumer logLevel) { + this(errorCode, logLevel, Collections.emptyMap(), null); + } + + public String getLogMessage() { + return Joiner.on(" | ") + .skipNulls() + .join( + "ErrorCode = " + code, + "Message = " + message, + parameters.isEmpty() ? null : "Parameters = " + parameters, + rootCause == null ? null : "Root Cause = " + ExceptionUtils.getRootCauseMessage(rootCause), + rootCause == null ? null : "Stack Trace = " + ExceptionUtils.getStackTrace(rootCause) + ); + } +} diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/api/response/ApiResponse.java b/reservation-common/src/main/java/com/f1v3/reservation/common/api/response/ApiResponse.java new file mode 100644 index 0000000..d1e758b --- /dev/null +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/api/response/ApiResponse.java @@ -0,0 +1,24 @@ +package com.f1v3.reservation.common.api.response; + +/** + * API 공통 응답 클래스 + * + * @author Seungjo, Jeong + */ +public record ApiResponse( + int code, + String message, + T content +) { + public static ApiResponse success(T content) { + return new ApiResponse<>(0, null, content); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public static ApiResponse error(int code, String message, T content) { + return new ApiResponse<>(code, message, content); + } +} diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/phoneverification/PhoneVerification.java b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/phoneverification/PhoneVerification.java index 48af124..56bfe5f 100644 --- a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/phoneverification/PhoneVerification.java +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/phoneverification/PhoneVerification.java @@ -46,6 +46,9 @@ public class PhoneVerification extends BaseEntity { @Column(nullable = true) private LocalDateTime verifiedAt; + @Column(nullable = false) + private LocalDateTime lastSentAt; + @Builder public PhoneVerification(String phoneNumber, String verificationCode) { this.phoneNumber = phoneNumber; @@ -53,6 +56,7 @@ public PhoneVerification(String phoneNumber, String verificationCode) { this.attemptCount = 0; this.isVerified = false; this.expiredAt = LocalDateTime.now().plusMinutes(VERIFICATION_EXPIRY_MINUTES); + this.lastSentAt = LocalDateTime.now(); } public boolean isExpired() { @@ -81,6 +85,7 @@ public void resend(String newCode) { this.isVerified = false; this.expiredAt = LocalDateTime.now().plusMinutes(VERIFICATION_EXPIRY_MINUTES); this.verifiedAt = null; + this.lastSentAt = LocalDateTime.now(); } public void verify() { diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/term/Term.java b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/term/Term.java index 0ab4d7b..b850b5d 100644 --- a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/term/Term.java +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/term/Term.java @@ -1,5 +1,7 @@ package com.f1v3.reservation.common.domain.term; +import com.f1v3.reservation.common.api.error.ErrorCode; +import com.f1v3.reservation.common.api.error.ReservationException; import com.f1v3.reservation.common.domain.BaseEntity; import com.f1v3.reservation.common.domain.term.enums.TermCode; import jakarta.persistence.*; @@ -7,6 +9,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; @@ -15,6 +18,7 @@ * * @author Seungjo, Jeong */ +@Slf4j @Getter @Entity @Table(name = "terms") @@ -71,11 +75,11 @@ private Term(TermCode code, Integer version, String title, String content, private void validateDisplayOrder() { if (Boolean.TRUE.equals(isRequired)) { if (displayOrder < 0 || displayOrder > 500) { - throw new IllegalArgumentException("필수 약관의 displayOrder는 0에서 500 사이여야 합니다."); + throw new ReservationException(ErrorCode.TERM_REQUIRED_DISPLAY_ORDER_INVALID, log::warn); } } else { if (displayOrder < 501 || displayOrder > 1000) { - throw new IllegalArgumentException("선택 약관의 displayOrder는 501에서 1000 사이여야 합니다."); + throw new ReservationException(ErrorCode.TERM_OPTIONAL_DISPLAY_ORDER_INVALID, log::warn); } } } diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/user/enums/Gender.java b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/user/enums/Gender.java index 9633d0e..317561b 100644 --- a/reservation-common/src/main/java/com/f1v3/reservation/common/domain/user/enums/Gender.java +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/domain/user/enums/Gender.java @@ -29,7 +29,7 @@ public enum Gender { this.description = description; } - public static Gender findBy(String value) { + public static Gender getGender(String value) { return stringToEnum.get(value); } } diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValid.java b/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValid.java index c94927a..33eb835 100644 --- a/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValid.java +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValid.java @@ -1,5 +1,6 @@ package com.f1v3.reservation.common.validator; +import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.ElementType; @@ -14,6 +15,7 @@ */ @Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = EnumValidator.class) public @interface EnumValid { String message() default "Invalid value. This is not permitted."; diff --git a/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValidator.java b/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValidator.java index 0ebeb39..3c19626 100644 --- a/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValidator.java +++ b/reservation-common/src/main/java/com/f1v3/reservation/common/validator/EnumValidator.java @@ -29,8 +29,8 @@ public boolean isValid(String value, ConstraintValidatorContext context) { } } } - - context.buildConstraintViolationWithTemplate(value + " is not a valid enum value.") + + context.buildConstraintViolationWithTemplate(annotation.message()) .addConstraintViolation() .disableDefaultConstraintViolation();