diff --git a/build.gradle b/build.gradle index 91338548..17142e21 100644 --- a/build.gradle +++ b/build.gradle @@ -22,21 +22,28 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - // mysql --version + // Health Check + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // JPA & Database + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'mysql:mysql-connector-java:8.0.32' - implementation 'org.springframework.boot:spring-boot-starter-validation' //JSON implementation 'com.googlecode.json-simple:json-simple:1.1.1' - // Health Check - implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Slack Webhook + implementation 'com.slack.api:slack-api-client:1.28.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.slack.api:slack-app-backend:1.28.0' + implementation 'com.slack.api:slack-api-model:1.28.0' } tasks.named('test') { diff --git a/src/main/java/sopt/org/umbbaServer/error/ApiResponse.java b/src/main/java/sopt/org/umbbaServer/error/ApiResponse.java new file mode 100644 index 00000000..51d07ed8 --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/error/ApiResponse.java @@ -0,0 +1,34 @@ +package sopt.org.umbbaServer.error; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + private final int code; + private final String message; + private T data; + + public static ApiResponse success(SuccessType successType) { + return new ApiResponse<>(successType.getHttpStatusCode(), successType.getMessage()); + } + + public static ApiResponse success(SuccessType successType, T data) { + return new ApiResponse(successType.getHttpStatusCode(), successType.getMessage(), data); + } + + public static ApiResponse error(ErrorType errorType) { + return new ApiResponse<>(errorType.getHttpStatusCode(), errorType.getMessage()); + } + + public static ApiResponse error(ErrorType errorType, String message) { + return new ApiResponse<>(errorType.getHttpStatusCode(), message); + } +} diff --git a/src/main/java/sopt/org/umbbaServer/error/ControllerExceptionAdvice.java b/src/main/java/sopt/org/umbbaServer/error/ControllerExceptionAdvice.java new file mode 100644 index 00000000..39f3c8f1 --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/error/ControllerExceptionAdvice.java @@ -0,0 +1,59 @@ +package sopt.org.umbbaServer.error; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import sopt.org.umbbaServer.util.slack.SlackApi; + +import javax.validation.UnexpectedTypeException; +import java.util.Objects; + +@RestControllerAdvice +@Component +@RequiredArgsConstructor +public class ControllerExceptionAdvice { + + private final SlackApi slackApi; + + /** + * 400 BAD_REQUEST + */ + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ApiResponse handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { + FieldError fieldError = Objects.requireNonNull(e.getFieldError()); + return ApiResponse.error(ErrorType.REQUEST_VALIDATION_EXCEPTION, String.format("%s. (%s)", fieldError.getDefaultMessage(), fieldError.getField())); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(UnexpectedTypeException.class) + protected ApiResponse handleUnexpectedTypeException(final UnexpectedTypeException e) { + return ApiResponse.error(ErrorType.VALIDATION_WRONG_TYPE_EXCEPTION); + } + + /** + * 500 INTERNEL_SERVER + */ +// @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) +// @ExceptionHandler(Exception.class) +// protected ApiResponse handleException(final Exception e, final HttpServletRequest request) throws IOException { +// //slackApi.sendAlert(e, request); +// return ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR); +// } + + /** + * CUSTOM_ERROR + */ + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleSoptException(CustomException e) { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.error(e.getErrorType(), e.getMessage())); + } +} diff --git a/src/main/java/sopt/org/umbbaServer/error/CustomException.java b/src/main/java/sopt/org/umbbaServer/error/CustomException.java new file mode 100644 index 00000000..3079927e --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/error/CustomException.java @@ -0,0 +1,18 @@ +package sopt.org.umbbaServer.error; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorType errorType; + + public CustomException(ErrorType errorType) { + super(errorType.getMessage()); + this.errorType = errorType; + } + + public int getHttpStatus() { + return errorType.getHttpStatusCode(); + } +} diff --git a/src/main/java/sopt/org/umbbaServer/error/ErrorType.java b/src/main/java/sopt/org/umbbaServer/error/ErrorType.java new file mode 100644 index 00000000..66e9f27c --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/error/ErrorType.java @@ -0,0 +1,51 @@ +package sopt.org.umbbaServer.error; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ErrorType { + + /** + * 400 BAD REQUEST + */ + REQUEST_VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 요청입니다"), + VALIDATION_WRONG_TYPE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 타입이 입력되었습니다"), + EMPTY_PRINCIPLE(HttpStatus.BAD_REQUEST, "Principle 객체가 없습니다. (null)"), + + /** + * 401 UNAUTHORIZED + */ + INVALID_SOCIAL_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 소셜 엑세스 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 엑세스 토큰입니다, 엑세스 토큰을 재발급 받아주세요."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다, 다시 로그인을 해주세요."), + NOTMATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "일치하지 않는 리프레시 토큰입니다."), + + /** + * 404 NOT FOUND + */ + INVALID_USER(HttpStatus.NOT_FOUND, "유효하지 않은 회원입니다."), + + + /** + * 409 CONFLICT + */ + ALREADY_EXIST_USER_EXCEPTION(HttpStatus.CONFLICT, "이미 존재하는 유저입니다"), + + + /** + * 500 INTERNAL SERVER ERROR + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다"), + ; + + private final HttpStatus httpStatus; + private final String message; + + public int getHttpStatusCode() { + return httpStatus.value(); + } +} diff --git a/src/main/java/sopt/org/umbbaServer/error/SuccessType.java b/src/main/java/sopt/org/umbbaServer/error/SuccessType.java new file mode 100644 index 00000000..debad8c7 --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/error/SuccessType.java @@ -0,0 +1,32 @@ +package sopt.org.umbbaServer.error; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum SuccessType { + + /** + * 200 OK + */ + LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다."), + REFRESH_SUCCESS(HttpStatus.OK, "Access 토큰 재발급에 성공했습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공했습니다."), + KAKAO_ACCESS_TOKEN_SUCCESS(HttpStatus.OK, "카카오 엑세스 토큰을 가져오는데 성공했습니다"), + + /** + * 201 CREATED + */ + CREATE_BOARD_SUCCESS(HttpStatus.CREATED, "게시물 생성이 완료됐습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; + + public int getHttpStatusCode() { + return httpStatus.value(); + } +} diff --git a/src/main/java/sopt/org/umbbaServer/util/AuditingTimeEntity.java b/src/main/java/sopt/org/umbbaServer/util/AuditingTimeEntity.java new file mode 100644 index 00000000..ec359aa1 --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/util/AuditingTimeEntity.java @@ -0,0 +1,22 @@ +package sopt.org.umbbaServer.util; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class AuditingTimeEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/sopt/org/umbbaServer/controller/ServerProfileController.java b/src/main/java/sopt/org/umbbaServer/util/ServerProfileController.java similarity index 92% rename from src/main/java/sopt/org/umbbaServer/controller/ServerProfileController.java rename to src/main/java/sopt/org/umbbaServer/util/ServerProfileController.java index cb8a9a34..fb21e63d 100644 --- a/src/main/java/sopt/org/umbbaServer/controller/ServerProfileController.java +++ b/src/main/java/sopt/org/umbbaServer/util/ServerProfileController.java @@ -1,4 +1,4 @@ -package sopt.org.umbbaServer.controller; +package sopt.org.umbbaServer.util; import lombok.RequiredArgsConstructor; import org.springframework.core.env.Environment; @@ -19,4 +19,4 @@ public String getProfile() { .findFirst() .orElse(""); } -} \ No newline at end of file +} diff --git a/src/main/java/sopt/org/umbbaServer/util/slack/SlackApi.java b/src/main/java/sopt/org/umbbaServer/util/slack/SlackApi.java new file mode 100644 index 00000000..136bbb69 --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/util/slack/SlackApi.java @@ -0,0 +1,107 @@ +package sopt.org.umbbaServer.util.slack; + +import com.slack.api.Slack; +import com.slack.api.model.block.Blocks; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.BlockCompositions; +import com.slack.api.webhook.WebhookPayloads; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Date; +import java.util.List; + +import static com.slack.api.model.block.composition.BlockCompositions.plainText; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackApi { + + //application.yml 에 등록해놓은 webhookUrl + @Value("${slack.webhook.url}") + private String webhookUrl; + private final static String NEW_LINE = "\n"; + private final static String DOUBLE_NEW_LINE = "\n\n"; + + private StringBuilder sb = new StringBuilder(); + + + // Slack으로 알림 보내기 + public void sendAlert(Exception error, HttpServletRequest request) throws IOException { + + // 현재 프로파일이 특정 프로파일이 아니면 알림보내지 않기 +// if (!env.getActiveProfiles()[0].equals("set1")) { +// return; +// } + + // 메시지 내용인 LayoutBlock List 생성 + List layoutBlocks = generateLayoutBlock(error, request); + + // 슬랙의 send API과 webhookURL을 통해 생성한 메시지 내용 전송 + Slack.getInstance().send(webhookUrl, WebhookPayloads + .payload(p -> + // 메시지 전송 유저명 + p.username("Exception is detected 🚨") + // 메시지 전송 유저 아이콘 이미지 URL + .iconUrl("https://yt3.googleusercontent.com/ytc/AGIKgqMVUzRrhoo1gDQcqvPo0PxaJz7e0gqDXT0D78R5VQ=s900-c-k-c0x00ffffff-no-rj") + // 메시지 내용 + .blocks(layoutBlocks))); + } + + // 전체 메시지가 담긴 LayoutBlock 생성 + private List generateLayoutBlock(Exception error, HttpServletRequest request) { + return Blocks.asBlocks( + getHeader("서버 측 오류로 예상되는 예외 상황이 발생하였습니다."), + Blocks.divider(), + getSection(generateErrorMessage(error)), + Blocks.divider(), + getSection(generateErrorPointMessage(request)), + Blocks.divider(), + // 이슈 생성을 위해 프로젝트의 Issue URL을 입력하여 바로가기 링크를 생성 + getSection("") + ); + } + + // 예외 정보 메시지 생성 + private String generateErrorMessage(Exception error) { + sb.setLength(0); + sb.append("*[🔥 Exception]*" + NEW_LINE + error.toString() + DOUBLE_NEW_LINE); + sb.append("*[📩 From]*" + NEW_LINE + readRootStackTrace(error) + DOUBLE_NEW_LINE); + + return sb.toString(); + } + + // HttpServletRequest를 사용하여 예외발생 요청에 대한 정보 메시지 생성 + private String generateErrorPointMessage(HttpServletRequest request) { + sb.setLength(0); + sb.append("*[🧾세부정보]*" + NEW_LINE); + sb.append("Request URL : " + request.getRequestURL().toString() + NEW_LINE); + sb.append("Request Method : " + request.getMethod() + NEW_LINE); + sb.append("Request Time : " + new Date() + NEW_LINE); + + return sb.toString(); + } + + // 예외발생 클래스 정보 return + private String readRootStackTrace(Exception error) { + return error.getStackTrace()[0].toString(); + } + + // 에러 로그 메시지의 제목 return + private LayoutBlock getHeader(String text) { + return Blocks.header(h -> h.text( + plainText(pt -> pt.emoji(true) + .text(text)))); + } + + // 에러 로그 메시지 내용 return + private LayoutBlock getSection(String message) { + return Blocks.section(s -> + s.text(BlockCompositions.markdownText(message))); + } +} \ No newline at end of file diff --git a/src/main/java/sopt/org/umbbaServer/util/slack/SlackTestController.java b/src/main/java/sopt/org/umbbaServer/util/slack/SlackTestController.java new file mode 100644 index 00000000..e1e57dab --- /dev/null +++ b/src/main/java/sopt/org/umbbaServer/util/slack/SlackTestController.java @@ -0,0 +1,19 @@ +package sopt.org.umbbaServer.util.slack; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import sopt.org.umbbaServer.error.ApiResponse; + +@RestController +@RequestMapping("/test") +public class SlackTestController { + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse test() { + throw new IllegalArgumentException(); + } +} \ No newline at end of file