diff --git a/.github/workflows/backend_ci.yml b/.github/workflows/backend_ci.yml index ceb015d92..4b583437f 100644 --- a/.github/workflows/backend_ci.yml +++ b/.github/workflows/backend_ci.yml @@ -22,11 +22,7 @@ jobs: mysql version: ${{ secrets.MYSQL_VERSION }} mysql database: ${{ secrets.MYSQL_DATABASE }} mysql root password: ${{ secrets.MYSQL_PASSWORD }} - - - name: DB 설정 파일 가져오기 - working-directory: ./backend/src/main/resources - run: echo "${{ secrets.APPLICATION_DB_YAML }}" > application-db.yml - + - name: gradle 캐싱 uses: gradle/actions/setup-gradle@v4 @@ -36,6 +32,10 @@ jobs: java-version: 17 distribution: temurin + - name: 환경변수 주입 + run: ${{ secrets.APPLICATION_YML }} + working-directory: ./backend/src/test/resources + - name: 테스트 코드 실행 run: ./gradlew test working-directory: ./backend diff --git a/.github/workflows/pr_notification.yml b/.github/workflows/pr_notification.yml index 859c0af00..19c41c323 100644 --- a/.github/workflows/pr_notification.yml +++ b/.github/workflows/pr_notification.yml @@ -18,7 +18,9 @@ jobs: PR_TITLE="${{ github.event.pull_request.title }}" PR_URL="${{ github.event.pull_request.html_url }}" REVIEWERS='${{ toJson(github.event.pull_request.requested_reviewers.*.login) }}' + LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' echo "REVIEWERS: $REVIEWERS" + echo "LABELS: $LABELS" parse_slack_ids() { echo "$SLACK_IDS" | jq -r 'to_entries | map("\(.key):\(.value)") | .[]' @@ -41,6 +43,11 @@ jobs: if [ ! -z "$mentions" ]; then message="$mentions 님, 새로운 PR이 생성되었습니다: <$PR_URL|$PR_TITLE>" + + if echo "$LABELS" | jq -e 'contains(["zap"])' > /dev/null; then + message=":rotating_light: 긴급 PR입니다. 빠른 리뷰 부탁드립니다!:rotating_light:\n$message" + fi + curl -X POST -H 'Content-type: application/json' \ --data "{\"text\":\"$message\"}" \ "$SLACK_WEBHOOK_URL" @@ -48,4 +55,3 @@ jobs: else echo "No reviewers to notify" fi - diff --git a/backend/.gitignore b/backend/.gitignore index d4af9460a..7aa169eb4 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -20,7 +20,7 @@ out/ .DS_Store ### YAML ### -application-db.yml +src/test/resources/application.yml ### compose ### docker/app/*.jar diff --git a/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java b/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java index 4a1febf92..0de62c98a 100644 --- a/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java +++ b/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java @@ -32,7 +32,11 @@ public Member resolveArgument( NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) { + AuthenticationPrinciple parameterAnnotation = parameter.getParameterAnnotation(AuthenticationPrinciple.class); HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + if (!parameterAnnotation.required() && !credentialManager.hasCredential(request)) { + return null; + } String credential = credentialManager.getCredential(request); return credentialProvider.extractMember(credential); } diff --git a/backend/src/main/java/codezap/auth/configuration/AuthenticationPrinciple.java b/backend/src/main/java/codezap/auth/configuration/AuthenticationPrinciple.java index a87455c4c..538e22c16 100644 --- a/backend/src/main/java/codezap/auth/configuration/AuthenticationPrinciple.java +++ b/backend/src/main/java/codezap/auth/configuration/AuthenticationPrinciple.java @@ -8,4 +8,5 @@ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface AuthenticationPrinciple { + boolean required() default true; } diff --git a/backend/src/main/java/codezap/auth/controller/SpringDocAuthController.java b/backend/src/main/java/codezap/auth/controller/SpringDocAuthController.java index f896dff95..8d8b75b95 100644 --- a/backend/src/main/java/codezap/auth/controller/SpringDocAuthController.java +++ b/backend/src/main/java/codezap/auth/controller/SpringDocAuthController.java @@ -31,7 +31,7 @@ public interface SpringDocAuthController { }) @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/login", errorCases = { @ErrorCase(description = "아이디 불일치", exampleMessage = "존재하지 않는 아이디 moly 입니다."), - @ErrorCase(description = "비밀번호 불일치", exampleMessage = "로그인에 실패하였습니다. 아이디 또는 비밀번호를 확인해주세요."), + @ErrorCase(description = "비밀번호 불일치", exampleMessage = "로그인에 실패하였습니다. 비밀번호를 확인해주세요."), }) ResponseEntity login(LoginRequest request, HttpServletResponse response); diff --git a/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java b/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java index bd93d2069..6708952f6 100644 --- a/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java +++ b/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java @@ -4,10 +4,10 @@ import java.security.NoSuchAlgorithmException; import java.util.Base64; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; @Component public class SHA2PasswordEncryptor implements PasswordEncryptor { @@ -17,7 +17,7 @@ public SHA2PasswordEncryptor() { try { digest = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException e) { - throw new CodeZapException(HttpStatus.INTERNAL_SERVER_ERROR, "암호화 알고리즘이 잘못 명시되었습니다."); + throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "암호화 알고리즘이 잘못 명시되었습니다."); } } diff --git a/backend/src/main/java/codezap/auth/manager/CookieCredentialManager.java b/backend/src/main/java/codezap/auth/manager/CookieCredentialManager.java index 571f22640..1fd2dff47 100644 --- a/backend/src/main/java/codezap/auth/manager/CookieCredentialManager.java +++ b/backend/src/main/java/codezap/auth/manager/CookieCredentialManager.java @@ -7,11 +7,11 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; @Component public class CookieCredentialManager implements CredentialManager { @@ -29,15 +29,25 @@ public String getCredential(final HttpServletRequest httpServletRequest) { private void checkCookieExist(final Cookie[] cookies) { if (cookies == null) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요."); + throw new CodeZapException(ErrorCode.UNAUTHORIZED_USER, "쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요."); } } + @Override + public boolean hasCredential(final HttpServletRequest httpServletRequest) { + Cookie[] cookies = httpServletRequest.getCookies(); + if (cookies == null) { + return false; + } + return Arrays.stream(cookies) + .anyMatch(cookie -> cookie.getName().equals(CREDENTIAL_COOKIE_NAME)); + } + private Cookie extractTokenCookie(final Cookie[] cookies) { return Arrays.stream(cookies) .filter(cookie -> cookie.getName().equals(CREDENTIAL_COOKIE_NAME)) .findFirst() - .orElseThrow(() -> new CodeZapException(HttpStatus.UNAUTHORIZED, + .orElseThrow(() -> new CodeZapException(ErrorCode.UNAUTHORIZED_USER, "인증에 대한 쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요.")); } diff --git a/backend/src/main/java/codezap/auth/manager/CredentialManager.java b/backend/src/main/java/codezap/auth/manager/CredentialManager.java index cb3e92053..dc4602ddc 100644 --- a/backend/src/main/java/codezap/auth/manager/CredentialManager.java +++ b/backend/src/main/java/codezap/auth/manager/CredentialManager.java @@ -7,6 +7,8 @@ public interface CredentialManager { String getCredential(HttpServletRequest httpServletRequest); + boolean hasCredential(HttpServletRequest httpServletRequest); + void setCredential(HttpServletResponse httpServletResponse, String credential); void removeCredential(HttpServletResponse httpServletResponse); diff --git a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java index 440128d26..ca47dc432 100644 --- a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java +++ b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java @@ -3,11 +3,11 @@ import java.nio.charset.StandardCharsets; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import codezap.auth.provider.CredentialProvider; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import codezap.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -33,7 +33,7 @@ public Member extractMember(String credential) { private void checkMatchPassword(Member member, String password) { if (!member.matchPassword(password)) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다."); + throw new CodeZapException(ErrorCode.UNAUTHORIZED_PASSWORD, "비밀번호가 일치하지 않습니다."); } } } diff --git a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthDecoder.java b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthDecoder.java index e9b30b796..c86253a86 100644 --- a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthDecoder.java +++ b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthDecoder.java @@ -3,9 +3,8 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; -import org.springframework.http.HttpStatus; - import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -26,13 +25,13 @@ private static String decodeBase64(String base64Credentials) { byte[] credDecoded = Base64.getDecoder().decode(base64Credentials); return new String(credDecoded, StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "잘못된 Base64 인코딩입니다."); + throw new CodeZapException(ErrorCode.UNAUTHORIZED_USER, "잘못된 Base64 인코딩입니다."); } } private static void validateBasicAuth(String[] values) { if (values.length != BASIC_AUTH_LENGTH || values[0].isEmpty() || values[1].isEmpty()) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다. 다시 로그인 해주세요."); + throw new CodeZapException(ErrorCode.UNAUTHORIZED_USER, "인증 정보가 올바르지 않습니다. 다시 로그인 해주세요."); } } } diff --git a/backend/src/main/java/codezap/auth/service/AuthService.java b/backend/src/main/java/codezap/auth/service/AuthService.java index 5af9e21ef..b1d6a9376 100644 --- a/backend/src/main/java/codezap/auth/service/AuthService.java +++ b/backend/src/main/java/codezap/auth/service/AuthService.java @@ -1,6 +1,5 @@ package codezap.auth.service; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import codezap.auth.dto.LoginAndCredentialDto; @@ -9,6 +8,7 @@ import codezap.auth.encryption.PasswordEncryptor; import codezap.auth.provider.CredentialProvider; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import codezap.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; @@ -37,7 +37,7 @@ private void validateCorrectPassword(Member member, String password) { String salt = member.getSalt(); String encryptedPassword = passwordEncryptor.encrypt(password, salt); if (!member.matchPassword(encryptedPassword)) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "로그인에 실패하였습니다. 아이디 또는 비밀번호를 확인해주세요."); + throw new CodeZapException(ErrorCode.UNAUTHORIZED_PASSWORD, "로그인에 실패하였습니다. 비밀번호를 확인해주세요."); } } diff --git a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java index ec8ef9312..377c651f4 100644 --- a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java +++ b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java @@ -31,6 +31,8 @@ public interface SpringDocCategoryController { @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = { @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."), @ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), + }) + @ApiErrorResponse(status = HttpStatus.CONFLICT, instance = "/categories", errorCases = { @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."), }) ResponseEntity createCategory( @@ -49,7 +51,11 @@ ResponseEntity createCategory( @ApiResponse(responseCode = "200", description = "카테고리 수정 성공") @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { @ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), + }) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/categories/1", errorCases = { @ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), + }) + @ApiErrorResponse(status = HttpStatus.CONFLICT, instance = "/categories", errorCases = { @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."), }) @ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/categories/1", errorCases = { diff --git a/backend/src/main/java/codezap/category/domain/Category.java b/backend/src/main/java/codezap/category/domain/Category.java index e1048ea8e..567bf62ec 100644 --- a/backend/src/main/java/codezap/category/domain/Category.java +++ b/backend/src/main/java/codezap/category/domain/Category.java @@ -11,10 +11,9 @@ import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; -import org.springframework.http.HttpStatus; - import codezap.global.auditing.BaseTimeEntity; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -67,7 +66,7 @@ public void updateName(String name) { public void validateAuthorization(Member member) { if (!getMember().equals(member)) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."); + throw new CodeZapException(ErrorCode.FORBIDDEN_ACCESS, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."); } } diff --git a/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java index cf5c96aa0..d692f1a48 100644 --- a/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java +++ b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java @@ -3,10 +3,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.http.HttpStatus; import codezap.category.domain.Category; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; @SuppressWarnings("unused") @@ -14,7 +14,7 @@ public interface CategoryJpaRepository extends CategoryRepository, JpaRepository default Category fetchById(Long id) { return findById(id).orElseThrow( - () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다.")); } List findAllByMemberIdOrderById(Long memberId); diff --git a/backend/src/main/java/codezap/category/service/CategoryService.java b/backend/src/main/java/codezap/category/service/CategoryService.java index 9ea73a9b6..104b07659 100644 --- a/backend/src/main/java/codezap/category/service/CategoryService.java +++ b/backend/src/main/java/codezap/category/service/CategoryService.java @@ -1,6 +1,5 @@ package codezap.category.service; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +10,7 @@ import codezap.category.dto.response.FindAllCategoriesResponse; import codezap.category.repository.CategoryRepository; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import codezap.template.repository.TemplateRepository; import lombok.RequiredArgsConstructor; @@ -52,7 +52,7 @@ public void update(Member member, Long id, UpdateCategoryRequest updateCategoryR private void validateDuplicatedCategory(String categoryName, Member member) { if (categoryRepository.existsByNameAndMember(categoryName, member)) { - throw new CodeZapException(HttpStatus.CONFLICT, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다."); + throw new CodeZapException(ErrorCode.DUPLICATE_CATEGORY, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다."); } } @@ -62,10 +62,10 @@ public void deleteById(Member member, Long id) { category.validateAuthorization(member); if (templateRepository.existsByCategoryId(id)) { - throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."); + throw new CodeZapException(ErrorCode.CATEGORY_HAS_TEMPLATES, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."); } if (category.isDefault()) { - throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다."); + throw new CodeZapException(ErrorCode.DEFAULT_CATEGORY, "기본 카테고리는 삭제할 수 없습니다."); } categoryRepository.deleteById(id); } diff --git a/backend/src/main/java/codezap/global/exception/CodeZapException.java b/backend/src/main/java/codezap/global/exception/CodeZapException.java index bfbf3ed1a..6af078fa9 100644 --- a/backend/src/main/java/codezap/global/exception/CodeZapException.java +++ b/backend/src/main/java/codezap/global/exception/CodeZapException.java @@ -1,16 +1,23 @@ package codezap.global.exception; -import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import lombok.Getter; @Getter public class CodeZapException extends RuntimeException { - private final HttpStatusCode httpStatusCode; + private final ErrorCode errorCode; - public CodeZapException(HttpStatusCode httpStatusCode, String message) { + public CodeZapException(ErrorCode errorCode, String message) { super(message); - this.httpStatusCode = httpStatusCode; + this.errorCode = errorCode; + } + + public ProblemDetail toProblemDetail() { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + errorCode.getHttpStatus(), + getMessage()); + return GlobalExceptionHandler.setProperties(problemDetail, errorCode.getCode()); } } diff --git a/backend/src/main/java/codezap/global/exception/ErrorCode.java b/backend/src/main/java/codezap/global/exception/ErrorCode.java new file mode 100644 index 000000000..548427caa --- /dev/null +++ b/backend/src/main/java/codezap/global/exception/ErrorCode.java @@ -0,0 +1,31 @@ +package codezap.global.exception; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + SPRING_GLOBAL_EXCEPTION(1000, HttpStatus.BAD_REQUEST), + + INVALID_REQUEST(1101, HttpStatus.BAD_REQUEST), + CATEGORY_HAS_TEMPLATES(1102, HttpStatus.BAD_REQUEST), + DEFAULT_CATEGORY(1103, HttpStatus.BAD_REQUEST), + + RESOURCE_NOT_FOUND(1201, HttpStatus.NOT_FOUND), + DUPLICATE_ID(1202, HttpStatus.CONFLICT), + DUPLICATE_CATEGORY(1203, HttpStatus.CONFLICT), + + UNAUTHORIZED_USER(1301, HttpStatus.UNAUTHORIZED), + UNAUTHORIZED_ID(1302, HttpStatus.UNAUTHORIZED), + UNAUTHORIZED_PASSWORD(1303, HttpStatus.UNAUTHORIZED), + FORBIDDEN_ACCESS(1304, HttpStatus.FORBIDDEN), + + INTERNAL_SERVER_ERROR(2000, HttpStatus.INTERNAL_SERVER_ERROR); + + private final int code; + private final HttpStatus httpStatus; +} diff --git a/backend/src/main/java/codezap/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/codezap/global/exception/GlobalExceptionHandler.java index 6c2a67307..622303309 100644 --- a/backend/src/main/java/codezap/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/codezap/global/exception/GlobalExceptionHandler.java @@ -1,13 +1,16 @@ package codezap.global.exception; +import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.lang.Nullable; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -15,46 +18,87 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonMappingException.Reference; + import lombok.extern.slf4j.Slf4j; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final String PROPERTY_ERROR_CODE = "errorCode"; + private static final String PROPERTY_TIMESTAMP = "timestamp"; + private static final String DEFAULT_DETAIL_MASSAGE = "디테일 값이 존재하지 않습니다."; + @ExceptionHandler public ResponseEntity handleCodeZapException(CodeZapException codeZapException) { log.info("[CodeZapException] {}가 발생했습니다.", codeZapException.getClass().getName(), codeZapException); - return ResponseEntity.status(codeZapException.getHttpStatusCode()) - .body(ProblemDetail.forStatusAndDetail( - codeZapException.getHttpStatusCode(), - codeZapException.getMessage()) - ); + + return ResponseEntity.status(codeZapException.getErrorCode().getHttpStatus()) + .body(codeZapException.toProblemDetail()); } @Override protected ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException exception, HttpHeaders headers, HttpStatusCode status, WebRequest request ) { + log.info("[MethodArgumentNotValidException] {}가 발생했습니다. \n", exception.getClass().getName(), exception); + BindingResult bindingResult = exception.getBindingResult(); List errorMessages = bindingResult.getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .toList(); + CodeZapException codeZapException = + new CodeZapException(ErrorCode.INVALID_REQUEST, String.join("\n", errorMessages)); - log.info("[MethodArgumentNotValidException] {}가 발생했습니다. \n", exception.getClass().getName(), exception); - return ResponseEntity.badRequest() - .body(ProblemDetail.forStatusAndDetail( - HttpStatus.BAD_REQUEST, - String.join("\n", errorMessages)) - ); + return ResponseEntity.status(codeZapException.getErrorCode().getHttpStatus()) + .body(codeZapException.toProblemDetail()); + } + + @Override + protected ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String exceptionMessage = "잘못된 JSON 형식입니다."; + if (ex.getCause() instanceof JsonMappingException jsonMappingException) { + exceptionMessage = jsonMappingException.getPath().stream() + .map(Reference::getFieldName) + .collect(Collectors.joining(" ")) + " 필드의 형식이 잘못되었습니다."; + } + + CodeZapException codeZapException = + new CodeZapException(ErrorCode.INVALID_REQUEST, String.join("\n", exceptionMessage)); + + return ResponseEntity.status(codeZapException.getErrorCode().getHttpStatus()) + .body(codeZapException.toProblemDetail()); } @ExceptionHandler public ResponseEntity handleException(Exception exception) { log.error("[Exception] 예상치 못한 오류 {} 가 발생했습니다.", exception.getClass().getName(), exception); + CodeZapException codeZapException = + new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "서버에서 예상치 못한 오류가 발생하였습니다."); return ResponseEntity.internalServerError() - .body(ProblemDetail.forStatusAndDetail( - HttpStatus.INTERNAL_SERVER_ERROR, - "서버에서 예상치 못한 오류가 발생하였습니다.") - ); + .body(codeZapException.toProblemDetail()); + } + + @Override + protected ResponseEntity createResponseEntity( + @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request + ) { + if (body instanceof ProblemDetail) { + return ResponseEntity.status(statusCode) + .body(setProperties((ProblemDetail) body, ErrorCode.SPRING_GLOBAL_EXCEPTION.getCode())); + } + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(statusCode, DEFAULT_DETAIL_MASSAGE); + return ResponseEntity.status(statusCode) + .body(setProperties(problemDetail, ErrorCode.SPRING_GLOBAL_EXCEPTION.getCode())); + } + + public static ProblemDetail setProperties(ProblemDetail problemDetail, int code) { + problemDetail.setProperty(PROPERTY_ERROR_CODE, code); + problemDetail.setProperty(PROPERTY_TIMESTAMP, LocalDateTime.now().toString()); + + return problemDetail; } } diff --git a/backend/src/main/java/codezap/global/rds/DataSourceConfig.java b/backend/src/main/java/codezap/global/rds/DataSourceConfig.java index 989cbfd99..fa1f9a6d6 100644 --- a/backend/src/main/java/codezap/global/rds/DataSourceConfig.java +++ b/backend/src/main/java/codezap/global/rds/DataSourceConfig.java @@ -21,7 +21,6 @@ @Configuration @RequiredArgsConstructor -@Profile("prod") @EnableJpaRepositories(basePackages = "codezap") public class DataSourceConfig { diff --git a/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java b/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java index e2622bb6c..8828e6c39 100644 --- a/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java +++ b/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java @@ -9,6 +9,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.examples.Example; @@ -56,14 +58,16 @@ private ApiResponses generateAuthErrorResponse(Operation operation) { Example noTokenCookieExample = new Example() .externalValue("인증 쿠키 없음") .description("쿠키는 있지만 Authorization 대한 담은 쿠키가 없는 경우") - .value(getExampleJsonString(ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, - "인증에 대한 쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요."))); + .value(getExampleJsonString( + new CodeZapException(ErrorCode.UNAUTHORIZED_USER, + "인증에 대한 쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요.").toProblemDetail())); Example noCookiesExample = new Example() .externalValue("모든 쿠키 없음") .description("쿠키 자체가 null인 경우") - .value(getExampleJsonString(ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, - "쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요."))); + .value(getExampleJsonString( + new CodeZapException(ErrorCode.UNAUTHORIZED_USER, + "쿠키가 없어서 회원 정보를 찾을 수 없습니다. 다시 로그인해주세요.").toProblemDetail())); MediaType mediaType = new MediaType().schema(new Schema<>().$ref("#/components/schemas/Error")); mediaType.addExamples("인증 쿠키 없음", noTokenCookieExample); diff --git a/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java b/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java index e78afc450..9e75de583 100644 --- a/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java +++ b/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import codezap.likes.domain.Likes; @@ -12,5 +13,5 @@ public interface LikesJpaRepository extends LikesRepository, JpaRepository templateIds); + void deleteAllByTemplateIds(@Param(value = "templateIds") List templateIds); } diff --git a/backend/src/main/java/codezap/likes/repository/LikesRepository.java b/backend/src/main/java/codezap/likes/repository/LikesRepository.java index 26df06b0a..3b6837bef 100644 --- a/backend/src/main/java/codezap/likes/repository/LikesRepository.java +++ b/backend/src/main/java/codezap/likes/repository/LikesRepository.java @@ -16,5 +16,5 @@ public interface LikesRepository { void deleteByMemberAndTemplate(Member member, Template template); - void deleteByTemplateIds(List templateIds); + void deleteAllByTemplateIds(List templateIds); } diff --git a/backend/src/main/java/codezap/likes/service/LikesService.java b/backend/src/main/java/codezap/likes/service/LikesService.java index 79dfce166..8562d00bb 100644 --- a/backend/src/main/java/codezap/likes/service/LikesService.java +++ b/backend/src/main/java/codezap/likes/service/LikesService.java @@ -42,6 +42,6 @@ public void cancelLike(Member member, long templateId) { @Transactional public void deleteAllByTemplateIds(List templateIds) { - likesRepository.deleteByTemplateIds(templateIds); + likesRepository.deleteAllByTemplateIds(templateIds); } } diff --git a/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java index 427cdf52f..0d5e1d0da 100644 --- a/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java +++ b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java @@ -5,9 +5,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; @SuppressWarnings("unused") @@ -15,17 +15,17 @@ public interface MemberJpaRepository extends MemberRepository, JpaRepository new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 멤버가 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "식별자 " + id + "에 해당하는 멤버가 존재하지 않습니다.")); } default Member fetchByName(String name) { return findByName(name) - .orElseThrow(() -> new CodeZapException(HttpStatus.UNAUTHORIZED, "존재하지 않는 아이디 " + name + " 입니다.")); + .orElseThrow(() -> new CodeZapException(ErrorCode.UNAUTHORIZED_ID, "존재하지 않는 아이디 " + name + " 입니다.")); } default Member fetchByTemplateId(Long templateId) { return findByTemplateId(templateId) - .orElseThrow(() -> new CodeZapException(HttpStatus.NOT_FOUND, "템플릿에 대한 멤버가 존재하지 않습니다.")); + .orElseThrow(() -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "템플릿에 대한 멤버가 존재하지 않습니다.")); } @Query("SELECT t.member FROM Template t WHERE t.id = :templateId") diff --git a/backend/src/main/java/codezap/member/service/MemberService.java b/backend/src/main/java/codezap/member/service/MemberService.java index 5d3d474ec..1eb236eff 100644 --- a/backend/src/main/java/codezap/member/service/MemberService.java +++ b/backend/src/main/java/codezap/member/service/MemberService.java @@ -4,7 +4,6 @@ import jakarta.transaction.Transactional; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import codezap.auth.encryption.PasswordEncryptor; @@ -12,6 +11,7 @@ import codezap.category.domain.Category; import codezap.category.repository.CategoryRepository; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import codezap.member.dto.request.SignupRequest; import codezap.member.dto.response.FindMemberResponse; @@ -39,7 +39,7 @@ public Long signup(SignupRequest request) { public void assertUniqueName(String name) { if (memberRepository.existsByName(name)) { - throw new CodeZapException(HttpStatus.CONFLICT, "아이디가 이미 존재합니다."); + throw new CodeZapException(ErrorCode.DUPLICATE_ID, "아이디가 이미 존재합니다."); } } @@ -50,7 +50,7 @@ public FindMemberResponse findMember(Member member, Long id) { private void checkSameMember(Member member, Long id) { if (!Objects.equals(member.getId(), id)) { - throw new CodeZapException(HttpStatus.FORBIDDEN, "본인의 정보만 조회할 수 있습니다."); + throw new CodeZapException(ErrorCode.FORBIDDEN_ACCESS, "본인의 정보만 조회할 수 있습니다."); } } diff --git a/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java b/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java index 06a00d318..cbb826afe 100644 --- a/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java +++ b/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java @@ -6,9 +6,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.tag.domain.Tag; @SuppressWarnings("unused") @@ -16,12 +16,12 @@ public interface TagJpaRepository extends TagRepository, JpaRepository new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 태그가 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "식별자 " + id + "에 해당하는 태그가 존재하지 않습니다.")); } default Tag fetchByName(String name) { return findByName(name).orElseThrow( - () -> new CodeZapException(HttpStatus.NOT_FOUND, "이름이 " + name + "인 태그는 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "이름이 " + name + "인 태그는 존재하지 않습니다.")); } @Query(value = "SELECT * FROM tag WHERE tag.name = BINARY :name", nativeQuery = true) diff --git a/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java b/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java index 2e81c2722..0fb949b8b 100644 --- a/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java +++ b/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java @@ -20,7 +20,7 @@ public interface TemplateTagJpaRepository extends TemplateTagRepository, JpaRepo JOIN TemplateTag tt ON t.id = tt.id.tagId WHERE tt.template = :template """) - List findAllTagsByTemplate(Template template); + List findAllTagsByTemplate(@Param("template") Template template); @Query(""" SELECT tt, t @@ -28,7 +28,7 @@ public interface TemplateTagJpaRepository extends TemplateTagRepository, JpaRepo JOIN FETCH tt.tag t WHERE tt.id.templateId = :templateId """) - List findAllByTemplateId(Long templateId); + List findAllByTemplateId(@Param("templateId") Long templateId); @Query(""" SELECT tt, t @@ -36,7 +36,7 @@ public interface TemplateTagJpaRepository extends TemplateTagRepository, JpaRepo JOIN FETCH tt.tag t WHERE tt.id.templateId in :templateIds """) - List findAllByTemplateIdsIn(List templateIds); + List findAllByTemplateIdsIn(@Param("templateIds") List templateIds); @Query(""" SELECT DISTINCT t @@ -49,9 +49,9 @@ WHERE tt.id.templateId IN ( ) ) """) - List findAllTagDistinctByMemberId(Long memberId); + List findAllTagDistinctByMemberId(@Param("memberId") Long memberId); @Modifying(clearAutomatically = true) @Query("DELETE FROM TemplateTag t WHERE t.template.id in :templateIds") - void deleteByTemplateIds(@Param("templateIds") List templateIds); + void deleteAllByTemplateIds(@Param("templateIds") List templateIds); } diff --git a/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java b/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java index e8dfc92f1..2983b2213 100644 --- a/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java +++ b/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java @@ -24,5 +24,5 @@ public interface TemplateTagRepository { void deleteAllByTemplateId(Long templateId); - void deleteByTemplateIds(List templateIds); + void deleteAllByTemplateIds(List templateIds); } diff --git a/backend/src/main/java/codezap/tag/service/TagService.java b/backend/src/main/java/codezap/tag/service/TagService.java index 44d279b34..b14282d09 100644 --- a/backend/src/main/java/codezap/tag/service/TagService.java +++ b/backend/src/main/java/codezap/tag/service/TagService.java @@ -72,6 +72,6 @@ public void updateTags(Template template, List tags) { @Transactional public void deleteAllByTemplateIds(List templateIds) { - templateTagRepository.deleteByTemplateIds(templateIds); + templateTagRepository.deleteAllByTemplateIds(templateIds); } } diff --git a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java index 1537e39e6..7076558e7 100644 --- a/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java +++ b/backend/src/main/java/codezap/template/controller/SpringDocTemplateController.java @@ -25,7 +25,7 @@ public interface SpringDocTemplateController { @SecurityRequirement(name = "쿠키 인증 토큰") @Operation(summary = "템플릿 생성", description = """ 새로운 템플릿을 생성합니다. \n - 템플릿명, 템플릿 설명, 소스 코드 목록, 썸네일 순서, 카테고리 ID, 태그 목록이 필요합니다. \n + 템플릿명, 템플릿 설명, 소스 코드 목록, 썸네일 순서, 카테고리 ID, 태그 목록, 템플릿 공개 범위가 필요합니다. \n * 템플릿 이름은 비어있거나 공백일 수 없다. 소스 코드 목록은 파일명, 소스 코드, 소스 코드 순서가 필요합니다. \n @@ -44,7 +44,7 @@ public interface SpringDocTemplateController { @ErrorCase(description = "소스 코드 순서가 잘못된 경우", exampleMessage = "소스 코드 순서가 잘못되었습니다."), @ErrorCase(description = "소스 코드가 0개 입력된 경우", exampleMessage = "소스 코드는 최소 1개 입력 해야 합니다."), }) - @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/templates/1", errorCases = { + @ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/templates/1", errorCases = { @ErrorCase(description = "카테고리 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."), }) @ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/templates/1", errorCases = { @@ -55,47 +55,8 @@ public interface SpringDocTemplateController { }) ResponseEntity createTemplate(Member member, CreateTemplateRequest createTemplateRequest); - @Operation(summary = "템플릿 조회 (비회원)", description = """ - 조건에 맞는 모든 템플릿을 조회합니다. \n - - 조건 \n - - 멤버 ID - - 검색 키워드 (템플릿명, 템플릿 설명, 파일명, 소스 코드) - - 카테고리 ID - - 태그 ID들 \n - - 페이징 조건을 줄 수 있습니다. 페이지 번호는 1, 템플릿 개수는 20, 정렬 방식은 최신순이 기본 값입니다. \n - - 페이징 조건 \n - - 페이지 번호(pageNumber) - - 한 페이지에 템플릿 개수(pageSize) - - 페이지 정렬 방식(sort) \n - - - 정렬 방식 \n - - 최신순 (modifiedAt,asc) - - 오래된순 (modifiedAt,desc) - - 좋아요 순 (likesCount, desc) \n - """) - @ApiResponse(responseCode = "200", description = "템플릿 검색 성공") - @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, - instance = "/templates?memberId=1&keyword=\"java\"&tagIds=", errorCases = { - @ErrorCase(description = "태그 ID가 0개인 경우", exampleMessage = "태그 ID가 0개입니다. 필터링 하지 않을 경우 null로 전달해주세요."), - @ErrorCase(description = "페이지 번호가 1보다 작을 경우", exampleMessage = "페이지 번호는 1 이상이어야 합니다."), - }) - @ApiErrorResponse(status = HttpStatus.NOT_FOUND, - instance = "/templates?memberId=1&keyword=\"java\"&categoryId=1&tagIds=1,2", errorCases = { - @ErrorCase(description = "멤버가 없는 경우", exampleMessage = "식별자 1에 해당하는 멤버가 존재하지 않습니다."), - @ErrorCase(description = "카테고리가 없는 경우", exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), - @ErrorCase(description = "태그가 없는 경우", exampleMessage = "식별자 1에 해당하는 태그가 존재하지 않습니다."), - }) - ResponseEntity findAllTemplates( - Long memberId, - String keyword, - Long categoryId, - List tagIds, - Pageable pageable - ); - @SecurityRequirement(name = "쿠키 인증 토큰") - @Operation(summary = "템플릿 조회 (회원)", description = """ + @Operation(summary = "템플릿 조회", description = """ 조건에 맞는 모든 템플릿을 조회합니다. \n - 조건 \n - 멤버 ID @@ -103,6 +64,10 @@ ResponseEntity findAllTemplates( - 카테고리 ID - 태그 ID들 \n + 조건에 멤버 ID가 있을 경우 + - 멤버 ID가 로그인된 멤버 정보와 동일하면 공개 템플릿, 비공개 템플릿 모두 반환 + - 멤버 ID가 로그인된 멤버 정보와 동일하지 않으면 공개 템플릿만 반환 + 페이징 조건을 줄 수 있습니다. 페이지 번호는 1, 템플릿 개수는 20, 정렬 방식은 최신순이 기본 값입니다. \n - 페이징 조건 \n - 페이지 번호(pageNumber) @@ -112,7 +77,7 @@ ResponseEntity findAllTemplates( - 정렬 방식 \n - 최신순 (modifiedAt,asc) - 오래된순 (modifiedAt,desc) - - 좋아요 순 (likesCount, desc) \n + - 좋아요순 (likesCount, desc) \n """) @ApiResponse(responseCode = "200", description = "템플릿 검색 성공") @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, @@ -126,7 +91,7 @@ ResponseEntity findAllTemplates( @ErrorCase(description = "카테고리가 없는 경우", exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), @ErrorCase(description = "태그가 없는 경우", exampleMessage = "식별자 1에 해당하는 태그가 존재하지 않습니다."), }) - ResponseEntity getTemplatesWithMember( + ResponseEntity findAllTemplates( Member member, Long memberId, String keyword, @@ -135,20 +100,13 @@ ResponseEntity getTemplatesWithMember( Pageable pageable ); - @Operation(summary = "템플릿 단건 조회 (비회원)", description = "해당하는 식별자의 템플릿을 조회합니다.") - @ApiResponse(responseCode = "200", description = "템플릿 단건 조회 성공") - @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = { - @ErrorCase(description = "해당하는 ID 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."), - }) - ResponseEntity findTemplateById(Long id); - - @SecurityRequirement(name = "쿠키 인증 토큰 (회원)") + @SecurityRequirement(name = "쿠키 인증 토큰") @Operation(summary = "템플릿 단건 조회", description = "해당하는 식별자의 템플릿을 조회합니다.") @ApiResponse(responseCode = "200", description = "템플릿 단건 조회 성공") @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1/login", errorCases = { @ErrorCase(description = "해당하는 ID 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."), }) - ResponseEntity getTemplateByIdWithMember(Member member, Long id); + ResponseEntity findTemplateById(Member member, Long id); @SecurityRequirement(name = "쿠키 인증 토큰") @Operation(summary = "템플릿 수정", description = "해당하는 식별자의 템플릿을 수정합니다.") @@ -164,7 +122,7 @@ ResponseEntity getTemplatesWithMember( @ErrorCase(description = "해당 템플릿의 실제 소스 코드 수와 인자로 받은 소스 코드 수가 다를 경우", exampleMessage = "소스 코드의 정보가 정확하지 않습니다."), }) - @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/templates/1", errorCases = { + @ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/templates/1", errorCases = { @ErrorCase(description = "자신의 템플릿이 아닐 경우", exampleMessage = "해당 템플릿에 대한 권한이 없습니다."), @ErrorCase(description = "카테고리 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."), }) @@ -182,7 +140,7 @@ ResponseEntity getTemplatesWithMember( @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1,1", errorCases = { @ErrorCase(description = "템플릿 ID가 중복된 경우", exampleMessage = "삭제하고자 하는 템플릿 ID가 중복되었습니다."), }) - @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/templates/1", errorCases = { + @ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/templates/1", errorCases = { @ErrorCase(description = "자신의 템플릿이 아닐 경우", exampleMessage = "해당 템플릿에 대한 권한이 없습니다."), }) @ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/templates/1", errorCases = { diff --git a/backend/src/main/java/codezap/template/controller/TemplateController.java b/backend/src/main/java/codezap/template/controller/TemplateController.java index 9fd689b98..eb63c426e 100644 --- a/backend/src/main/java/codezap/template/controller/TemplateController.java +++ b/backend/src/main/java/codezap/template/controller/TemplateController.java @@ -44,23 +44,26 @@ public ResponseEntity createTemplate( @GetMapping public ResponseEntity findAllTemplates( + @AuthenticationPrinciple(required = false) Member member, @RequestParam(required = false) Long memberId, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) List tagIds, @PageableDefault(size = 20) Pageable pageable ) { - FindAllTemplatesResponse response = templateApplicationService.findAllBy( - memberId, - keyword, - categoryId, - tagIds, - pageable); - return ResponseEntity.ok(response); + if (member == null) { + return ResponseEntity.ok( + templateApplicationService.findAllBy(memberId, keyword, categoryId, tagIds, pageable) + ); + } + return ResponseEntity.ok( + templateApplicationService.findAllBy(memberId, keyword, categoryId, tagIds, pageable, member) + ); } + @Deprecated @GetMapping("/login") - public ResponseEntity getTemplatesWithMember( + public ResponseEntity findAllTemplatesWithMember( @AuthenticationPrinciple Member member, @RequestParam(required = false) Long memberId, @RequestParam(required = false) String keyword, @@ -68,26 +71,27 @@ public ResponseEntity getTemplatesWithMember( @RequestParam(required = false) List tagIds, @PageableDefault(size = 20) Pageable pageable ) { - return ResponseEntity.ok(templateApplicationService.findAllByWithMember( - memberId, - keyword, - categoryId, - tagIds, - pageable, - member)); + return findAllTemplates(member, memberId, keyword, categoryId, tagIds, pageable); } @GetMapping("/{id}") - public ResponseEntity findTemplateById(@PathVariable Long id) { - return ResponseEntity.ok(templateApplicationService.findById(id)); + public ResponseEntity findTemplateById( + @AuthenticationPrinciple(required = false) Member member, + @PathVariable Long id + ) { + if (member == null) { + return ResponseEntity.ok(templateApplicationService.findById(id)); + } + return ResponseEntity.ok(templateApplicationService.findById(id, member)); } + @Deprecated @GetMapping("/{id}/login") - public ResponseEntity getTemplateByIdWithMember( + public ResponseEntity findTemplateByIdWithMember( @AuthenticationPrinciple Member member, @PathVariable Long id ) { - return ResponseEntity.ok(templateApplicationService.findByIdWithMember(id, member)); + return findTemplateById(member, id); } @PostMapping("/{id}") @@ -105,7 +109,7 @@ public ResponseEntity deleteTemplates( @AuthenticationPrinciple Member member, @PathVariable List ids ) { - templateApplicationService.deleteByMemberAndIds(member, ids); + templateApplicationService.deleteAllByMemberAndTemplateIds(member, ids); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/codezap/template/domain/Template.java b/backend/src/main/java/codezap/template/domain/Template.java index 1445b519b..121c25f41 100644 --- a/backend/src/main/java/codezap/template/domain/Template.java +++ b/backend/src/main/java/codezap/template/domain/Template.java @@ -5,6 +5,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -13,11 +15,11 @@ import jakarta.persistence.OneToMany; import org.hibernate.annotations.Formula; -import org.springframework.http.HttpStatus; import codezap.category.domain.Category; import codezap.global.auditing.BaseTimeEntity; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.member.domain.Member; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -54,11 +56,20 @@ public class Template extends BaseTimeEntity { @Formula("(select count(*) from likes where likes.template_id = id)") private Long likesCount; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Visibility visibility; + public Template(Member member, String title, String description, Category category) { + this(member, title, description, category, Visibility.PUBLIC); + } + + public Template(Member member, String title, String description, Category category, Visibility visibility) { this.member = member; this.title = title; this.description = description; this.category = category; + this.visibility = visibility; } public void updateTemplate(String title, String description, Category category) { @@ -69,7 +80,7 @@ public void updateTemplate(String title, String description, Category category) public void validateAuthorization(Member member) { if (!getMember().equals(member)) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "해당 템플릿에 대한 권한이 없습니다."); + throw new CodeZapException(ErrorCode.FORBIDDEN_ACCESS, "해당 템플릿에 대한 권한이 없습니다."); } } } diff --git a/backend/src/main/java/codezap/template/domain/Visibility.java b/backend/src/main/java/codezap/template/domain/Visibility.java new file mode 100644 index 000000000..19ffdce5b --- /dev/null +++ b/backend/src/main/java/codezap/template/domain/Visibility.java @@ -0,0 +1,8 @@ +package codezap.template.domain; + +public enum Visibility { + + PUBLIC, + PRIVATE, + ; +} diff --git a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java index 8d18cceab..c4591b96a 100644 --- a/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java +++ b/backend/src/main/java/codezap/template/dto/request/CreateTemplateRequest.java @@ -10,6 +10,7 @@ import codezap.global.validation.ByteLength; import codezap.global.validation.ValidationGroups.NotNullGroup; import codezap.global.validation.ValidationGroups.SizeCheckGroup; +import codezap.template.domain.Visibility; import codezap.template.dto.request.validation.ValidatedSourceCodesOrdinalRequest; import io.swagger.v3.oas.annotations.media.Schema; @@ -42,7 +43,11 @@ public record CreateTemplateRequest( @NotNull(message = "태그 목록이 null 입니다.", groups = NotNullGroup.class) @ByteLength(max = 30, message = "태그 명은 최대 30자까지 입력 가능합니다.", groups = SizeCheckGroup.class) @Valid - List tags + List tags, + + @Schema(description = "템플릿 공개 여부", example = "PUBLIC") + @NotNull(message = "템플릿 공개 여부가 null 입니다.", groups = NotNullGroup.class) + Visibility visibility ) implements ValidatedSourceCodesOrdinalRequest { @Override diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllTemplateItemResponse.java b/backend/src/main/java/codezap/template/dto/response/FindAllTemplateItemResponse.java index 492109f92..10d4774a6 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindAllTemplateItemResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindAllTemplateItemResponse.java @@ -7,6 +7,7 @@ import codezap.tag.dto.response.FindTagResponse; import codezap.template.domain.SourceCode; import codezap.template.domain.Template; +import codezap.template.domain.Visibility; import io.swagger.v3.oas.annotations.media.Schema; public record FindAllTemplateItemResponse( @@ -28,6 +29,9 @@ public record FindAllTemplateItemResponse( @Schema(description = "썸네일") FindThumbnailResponse thumbnail, + @Schema(description = "공개 범위", example = "PUBLIC") + Visibility visibility, + @Schema(description = "좋아요 수", example = "134") Long likesCount, @Schema(description = "조회 멤버의 좋아요 여부", example = "true") @@ -54,6 +58,7 @@ public static FindAllTemplateItemResponse of( .map(FindTagResponse::from) .toList(), FindThumbnailResponse.from(thumbnailSourceCode), + template.getVisibility(), template.getLikesCount(), isLiked, template.getCreatedAt(), diff --git a/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java index 6f59a9e57..f7621c8e2 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java +++ b/backend/src/main/java/codezap/template/dto/response/FindTemplateResponse.java @@ -8,6 +8,7 @@ import codezap.tag.dto.response.FindTagResponse; import codezap.template.domain.SourceCode; import codezap.template.domain.Template; +import codezap.template.domain.Visibility; import io.swagger.v3.oas.annotations.media.Schema; public record FindTemplateResponse( @@ -32,6 +33,9 @@ public record FindTemplateResponse( @Schema(description = "태그 목록") List tags, + @Schema(description = "공개 범위", example = "PUBLIC") + Visibility visibility, + @Schema(description = "좋아요 수", example = "134") Long likesCount, @@ -62,6 +66,7 @@ public static FindTemplateResponse of( tags.stream() .map(FindTagResponse::from) .toList(), + template.getVisibility(), template.getLikesCount(), isLiked, template.getCreatedAt(), diff --git a/backend/src/main/java/codezap/template/repository/SourceCodeJpaRepository.java b/backend/src/main/java/codezap/template/repository/SourceCodeJpaRepository.java index 36c472788..bb9e5833e 100644 --- a/backend/src/main/java/codezap/template/repository/SourceCodeJpaRepository.java +++ b/backend/src/main/java/codezap/template/repository/SourceCodeJpaRepository.java @@ -6,9 +6,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.http.HttpStatus; +import org.springframework.data.repository.query.Param; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.template.domain.SourceCode; import codezap.template.domain.Template; @@ -17,7 +18,7 @@ public interface SourceCodeJpaRepository extends SourceCodeRepository, JpaReposi default SourceCode fetchById(Long id) { return findById(id).orElseThrow( - () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 소스 코드가 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "식별자 " + id + "에 해당하는 소스 코드가 존재하지 않습니다.")); } List findAllByTemplate(Template template); @@ -25,7 +26,8 @@ default SourceCode fetchById(Long id) { default SourceCode fetchByTemplateAndOrdinal(Template template, int ordinal) { return findByTemplateAndOrdinal(template, ordinal) .orElseThrow( - () -> new CodeZapException(HttpStatus.NOT_FOUND, "템플릿에 " + ordinal + "번째 소스 코드가 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, + "템플릿에 " + ordinal + "번째 소스 코드가 존재하지 않습니다.")); } Optional findByTemplateAndOrdinal(Template template, int ordinal); @@ -36,5 +38,5 @@ default SourceCode fetchByTemplateAndOrdinal(Template template, int ordinal) { @Modifying(clearAutomatically = true) @Query("DELETE FROM SourceCode s WHERE s.template.id in :templateIds") - void deleteByTemplateIds(List templateIds); + void deleteAllByTemplateIds(@Param("templateIds") List templateIds); } diff --git a/backend/src/main/java/codezap/template/repository/SourceCodeRepository.java b/backend/src/main/java/codezap/template/repository/SourceCodeRepository.java index 745f3ae77..a605d7348 100644 --- a/backend/src/main/java/codezap/template/repository/SourceCodeRepository.java +++ b/backend/src/main/java/codezap/template/repository/SourceCodeRepository.java @@ -26,5 +26,5 @@ public interface SourceCodeRepository { void deleteById(Long id); - void deleteByTemplateIds(List templateIds); + void deleteAllByTemplateIds(List templateIds); } diff --git a/backend/src/main/java/codezap/template/repository/TemplateJpaRepository.java b/backend/src/main/java/codezap/template/repository/TemplateJpaRepository.java index 84c057ac7..ee85472b0 100644 --- a/backend/src/main/java/codezap/template/repository/TemplateJpaRepository.java +++ b/backend/src/main/java/codezap/template/repository/TemplateJpaRepository.java @@ -7,9 +7,9 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; import codezap.template.domain.Template; @SuppressWarnings("unused") @@ -18,7 +18,7 @@ public interface TemplateJpaRepository extends TemplateRepository, JpaRepository default Template fetchById(Long id) { return findById(id).orElseThrow( - () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 템플릿이 존재하지 않습니다.")); + () -> new CodeZapException(ErrorCode.RESOURCE_NOT_FOUND, "식별자 " + id + "에 해당하는 템플릿이 존재하지 않습니다.")); } List