Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ject.official_qr_checkin_server.common.exception;

import java.io.Serial;
import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

@Serial
private static final long serialVersionUID = 1L;

private final ErrorCode errorCode;

public BusinessException(ErrorCode errorCode) {
this(errorCode, errorCode.getMessage());
}

public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ject.official_qr_checkin_server.common.exception;

import java.io.Serializable;
import org.springframework.http.HttpStatus;

public interface ErrorCode extends Serializable {

HttpStatus getHttpStatus();

String getCode();

String getMessage();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package ject.official_qr_checkin_server.common.exception;

import org.springframework.http.HttpStatus;

public enum GlobalErrorCode implements ErrorCode {

UNSUPPORTED_PARAMETER_TYPE(
HttpStatus.BAD_REQUEST,
"GLOBAL-001",
"요청 파라미터의 형식이 올바르지 않습니다."
),
MISSING_REQUEST_PARAMETER(
HttpStatus.BAD_REQUEST,
"GLOBAL-002",
"필수 요청 파라미터가 누락되었습니다."
),
MISSING_REQUEST_BODY(
HttpStatus.BAD_REQUEST,
"GLOBAL-003",
"요청 본문이 누락되었거나 형식이 올바르지 않습니다."
),
VALIDATION_FAILED(
HttpStatus.BAD_REQUEST,
"GLOBAL-004",
"요청 값 검증에 실패했습니다."
),
METHOD_NOT_ALLOWED(
HttpStatus.METHOD_NOT_ALLOWED,
"GLOBAL-005",
"지원하지 않는 HTTP 메서드입니다."
),
RESOURCE_NOT_FOUND(
HttpStatus.NOT_FOUND,
"GLOBAL-006",
"요청한 리소스를 찾을 수 없습니다."
),
INTERNAL_SERVER_ERROR(
HttpStatus.INTERNAL_SERVER_ERROR,
"GLOBAL-999",
"서버 내부 오류가 발생했습니다."
);

private final HttpStatus httpStatus;
private final String code;
private final String message;

GlobalErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}

@Override
public HttpStatus getHttpStatus() {
return httpStatus;
}

@Override
public String getCode() {
return code;
}

@Override
public String getMessage() {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package ject.official_qr_checkin_server.common.exception;

import java.util.List;
import java.util.Objects;
import ject.official_qr_checkin_server.common.response.ApiResponse;
import ject.official_qr_checkin_server.common.response.ErrorResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@RestControllerAdvice
public class GlobalExceptionHandler {

private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiResponse<List<String>>> handleBusinessException(BusinessException exception) {
ErrorCode errorCode = exception.getErrorCode();
logKnownException(exception, errorCode);
return createResponse(ErrorResponse.of(errorCode, exception.getMessage()));
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<ApiResponse<List<String>>> handleMethodArgumentTypeMismatch(
MethodArgumentTypeMismatchException exception
) {
return handleKnownException(exception, GlobalErrorCode.UNSUPPORTED_PARAMETER_TYPE);
}

@ExceptionHandler(MissingServletRequestParameterException.class)
ResponseEntity<ApiResponse<List<String>>> handleMissingRequestParameter(
MissingServletRequestParameterException exception
) {
return handleKnownException(exception, GlobalErrorCode.MISSING_REQUEST_PARAMETER);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ApiResponse<List<String>>> handleMessageNotReadable(
HttpMessageNotReadableException exception
) {
return handleKnownException(exception, GlobalErrorCode.MISSING_REQUEST_BODY);
}

@ExceptionHandler({
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class
})
ResponseEntity<ApiResponse<List<String>>> handleValidationException(Exception exception) {
GlobalErrorCode errorCode = GlobalErrorCode.VALIDATION_FAILED;
logKnownException(exception, errorCode);

List<String> messages = switch (exception) {
case MethodArgumentNotValidException methodArgumentNotValidException ->
extractMessages(methodArgumentNotValidException.getAllErrors(), errorCode);
case HandlerMethodValidationException handlerMethodValidationException ->
extractMessages(handlerMethodValidationException.getAllErrors(), errorCode);
default -> List.of(errorCode.getMessage());
};

return createResponse(ErrorResponse.of(errorCode, messages));
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
ResponseEntity<ApiResponse<List<String>>> handleMethodNotSupported(
HttpRequestMethodNotSupportedException exception
) {
return handleKnownException(exception, GlobalErrorCode.METHOD_NOT_ALLOWED);
}

@ExceptionHandler(NoResourceFoundException.class)
ResponseEntity<ApiResponse<List<String>>> handleResourceNotFound(NoResourceFoundException exception) {
return handleKnownException(exception, GlobalErrorCode.RESOURCE_NOT_FOUND);
}

@ExceptionHandler(Exception.class)
ResponseEntity<ApiResponse<List<String>>> handleUnexpectedException(Exception exception) {
GlobalErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR;
log.error("Unhandled exception. responseCode={}", errorCode.getCode(), exception);
return createResponse(ErrorResponse.of(errorCode));
}

private ResponseEntity<ApiResponse<List<String>>> handleKnownException(
Exception exception,
ErrorCode errorCode
) {
logKnownException(exception, errorCode);
return createResponse(ErrorResponse.of(errorCode));
}

private List<String> extractMessages(
List<? extends MessageSourceResolvable> errors,
ErrorCode fallbackErrorCode
) {
List<String> messages = errors.stream()
.map(MessageSourceResolvable::getDefaultMessage)
.filter(Objects::nonNull)
.distinct()
.toList();

return messages.isEmpty() ? List.of(fallbackErrorCode.getMessage()) : messages;
}

private ResponseEntity<ApiResponse<List<String>>> createResponse(ErrorResponse errorResponse) {
return ResponseEntity
.status(errorResponse.httpStatus())
.body(errorResponse.toApiResponse());
}

private void logKnownException(Exception exception, ErrorCode errorCode) {
log.warn(
"Handled exception. type={}, responseCode={}",
exception.getClass().getSimpleName(),
errorCode.getCode()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ject.official_qr_checkin_server.common.response;

import java.time.Instant;

public record ApiResponse<T>(
String status,
T data,
Instant timestamp
) {

private static final String SUCCESS_STATUS = "SUCCESS";

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(SUCCESS_STATUS, data, Instant.now());
}

public static <T> ApiResponse<T> error(String status, T data) {
return new ApiResponse<>(status, data, Instant.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package ject.official_qr_checkin_server.common.response;

import java.util.List;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.AbstractJacksonHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@RestControllerAdvice
public class ApiResponseBodyAdvice implements ResponseBodyAdvice<Object> {

private static final List<String> EXCLUDED_PATH_PREFIXES = List.of(
"/actuator",
"/v3/api-docs",
"/swagger-ui"
);

@Override
public boolean supports(
MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType
) {
return AbstractJacksonHttpMessageConverter.class.isAssignableFrom(converterType);
}

@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
if (shouldSkip(body, request, response)) {
return body;
}

return ApiResponse.success(body);
}

private boolean shouldSkip(Object body, ServerHttpRequest request, ServerHttpResponse response) {
return body instanceof ApiResponse<?>
|| body instanceof ProblemDetail
|| isExcludedPath(request.getURI().getPath())
|| isNonSuccessfulOrEmptyResponse(response);
}

private boolean isExcludedPath(String path) {
return EXCLUDED_PATH_PREFIXES.stream().anyMatch(path::startsWith);
}
Comment on lines +56 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

제외 경로를 경계 단위로 판별하세요.

startsWith는 /actuator-health나 /v3/api-docs-preview도 제외합니다. 정확한 경로 또는 prefix + "/" 하위 경로만 제외해야 합니다.

수정 예시
 private boolean isExcludedPath(String path) {
-	return EXCLUDED_PATH_PREFIXES.stream().anyMatch(path::startsWith);
+	return EXCLUDED_PATH_PREFIXES.stream()
+		.anyMatch(prefix -> path.equals(prefix) || path.startsWith(prefix + "/"));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private boolean isExcludedPath(String path) {
return EXCLUDED_PATH_PREFIXES.stream().anyMatch(path::startsWith);
}
private boolean isExcludedPath(String path) {
return EXCLUDED_PATH_PREFIXES.stream()
.anyMatch(prefix -> path.equals(prefix) || path.startsWith(prefix + "/"));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdvice.java`
around lines 56 - 58, Update isExcludedPath so each excluded prefix matches only
when path equals the prefix or starts with the prefix followed by “/”; do not
use an unrestricted startsWith check, preserving exclusion of the exact endpoint
and its subpaths while allowing names such as /actuator-health and
/v3/api-docs-preview.


private boolean isNonSuccessfulOrEmptyResponse(ServerHttpResponse response) {
if (!(response instanceof ServletServerHttpResponse servletResponse)) {
return false;
}

int status = servletResponse.getServletResponse().getStatus();
return status < 200
|| status >= 300
|| status == HttpStatus.NO_CONTENT.value()
|| status == HttpStatus.RESET_CONTENT.value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package ject.official_qr_checkin_server.common.response;

import java.util.List;
import ject.official_qr_checkin_server.common.exception.ErrorCode;
import org.springframework.http.HttpStatus;

public record ErrorResponse(
HttpStatus httpStatus,
String code,
List<String> messages
) {

public ErrorResponse {
messages = List.copyOf(messages);
}

public static ErrorResponse of(ErrorCode errorCode) {
return of(errorCode, List.of(errorCode.getMessage()));
}

public static ErrorResponse of(ErrorCode errorCode, String message) {
return of(errorCode, List.of(message));
}

public static ErrorResponse of(ErrorCode errorCode, List<String> messages) {
return new ErrorResponse(errorCode.getHttpStatus(), errorCode.getCode(), messages);
}

public ApiResponse<List<String>> toApiResponse() {
return ApiResponse.error(code, messages);
}
}
Loading