Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e76b7de
feat: API 공통 응답 클래스 정의
f1v3-dev Oct 2, 2025
5e7b5ae
feat: 에러 코드 및 예외 정의
f1v3-dev Oct 2, 2025
a34365b
feat: 사용자 API 컨트롤러 어드바이스 생성
f1v3-dev Oct 2, 2025
3c239e5
refactor: 공통 에러 코드를 기반으로 에러 처리
f1v3-dev Oct 2, 2025
dac84ec
refactor: 공통 에러 코드를 기반으로 에러 처리
f1v3-dev Oct 2, 2025
399bcff
feat: 마지막 인증번호 전송 시간 컬럼 추가
f1v3-dev Oct 2, 2025
f8b7f94
fix: 생성 기준이 아닌 마지막 전송 시간을 비교해서 검증하도록 수정
f1v3-dev Oct 2, 2025
0130ea1
feat: 인증번호 재전송 에러 추가
f1v3-dev Oct 2, 2025
ce23222
feat: ErrorStatus 도입을 통해 상태 코드 설정
f1v3-dev Oct 2, 2025
97763d4
feat: 관리자 서버 컨트롤러 어드바이스 추가
f1v3-dev Oct 3, 2025
9180586
docs: 사용자 서버 컨트롤러 어드바이스 javadoc 수정
f1v3-dev Oct 3, 2025
f3edb4e
feat: 추가 약관 표시 순서 검증 로직 및 에러 코드 정의
f1v3-dev Oct 3, 2025
23a0ffd
feat: `Exception` 예외 처리 및 로깅 추가
f1v3-dev Oct 3, 2025
8cc03ed
fix: EnumValid 수정
f1v3-dev Oct 3, 2025
a9c9db1
refactor: 변환 메서드명 수정
f1v3-dev Oct 3, 2025
3fa5567
feat: 검증 어노테이션 추가
f1v3-dev Oct 3, 2025
9d02ea2
refactor: 변환 메서드명 수정
f1v3-dev Oct 3, 2025
e0d0003
feat: API 테스트
f1v3-dev Oct 3, 2025
e176f31
fix: Controller 응답을 모두 공통 응답 객체 사용
f1v3-dev Oct 3, 2025
679d9ae
feat: Apache Commons Lang, Google Guava 의존성 추가
f1v3-dev Oct 11, 2025
48e76fb
feat: 예외 처리 개선 (로깅, 파라미터, 상위 예외)
f1v3-dev Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions http/admin-api/term.http
Original file line number Diff line number Diff line change
@@ -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"
}
50 changes: 50 additions & 0 deletions http/api/signup.http
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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
}
]
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<?>> 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<ApiResponse<?>> handleArgumentNotValidException(
MethodArgumentNotValidException e, HttpServletRequest request) {

ErrorCode errorCode = ErrorCode.INVALID_REQUEST_PARAMETER;

Map<String, String> errors = new HashMap<>();
Map<String, Object> 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<ApiResponse<?>> 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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,8 +31,8 @@ public class TermController {
* @param pageable default page = 0, size = 10
*/
@GetMapping
public ResponseEntity<List<TermResponse>> getPagedTerms(@PageableDefault Pageable pageable) {
return ResponseEntity.ok(termService.getPagedTerms(pageable));
public ApiResponse<List<TermResponse>> getPagedTerms(@PageableDefault Pageable pageable) {
return ApiResponse.success(termService.getPagedTerms(pageable));
}

/**
Expand All @@ -42,8 +42,8 @@ public ResponseEntity<List<TermResponse>> getPagedTerms(@PageableDefault Pageabl
* @return 생성된 약관의 ID 포함한 응답 DTO
*/
@PostMapping
public ResponseEntity<CreateTermResponse> createTerm(@Valid @RequestBody CreateTermRequest request) {
CreateTermResponse response = termService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CreateTermResponse> createTerm(@Valid @RequestBody CreateTermRequest request) {
return ApiResponse.success(termService.create(request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
* 관리자용 약관 서비스
Expand All @@ -39,7 +44,7 @@ public List<TermResponse> 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)
Expand Down Expand Up @@ -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<String, Object> parameters = Map.of(
"termCode", term.getCode(),
"termVersion", term.getVersion()
);

throw new ReservationException(ErrorCode.TERM_VERSION_CONSTRAINT_VIOLATION, log::error, parameters, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<?>> 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<ApiResponse<?>> handleArgumentNotValidException(
MethodArgumentNotValidException e, HttpServletRequest request) {

ErrorCode code = ErrorCode.INVALID_REQUEST_PARAMETER;

Map<String, String> errors = new HashMap<>();
Map<String, Object> 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<ApiResponse<?>> 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()));
}
}
Loading