diff --git a/src/main/java/com/example/feeda/config/SecurityConfig.java b/src/main/java/com/example/feeda/config/SecurityConfig.java index f0d13ff..80ac914 100644 --- a/src/main/java/com/example/feeda/config/SecurityConfig.java +++ b/src/main/java/com/example/feeda/config/SecurityConfig.java @@ -1,9 +1,10 @@ package com.example.feeda.config; import com.example.feeda.filter.JwtFilter; +import com.example.feeda.security.handler.CustomAccessDeniedHandler; +import com.example.feeda.security.handler.CustomAuthenticationEntryPoint; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtUtil; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,8 @@ public class SecurityConfig { private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { @@ -43,10 +46,6 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .requestMatchers("/error").permitAll() .requestMatchers("/api/**").authenticated() - // 비로그인 시 GET 만 허용 -// .requestMatchers(HttpMethod.GET, "/api/**").permitAll() -// .requestMatchers("/api/**").permitAll() - .anyRequest().denyAll() ) @@ -55,20 +54,8 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .exceptionHandling(configurer -> configurer - .authenticationEntryPoint((request, response, authException) -> { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"인증 실패: " + authException.getMessage() + "\"}"; - response.getWriter().write(message); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"접근 거부: " + accessDeniedException.getMessage() + "\"}"; - response.getWriter().write(message); - }) + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) ) .build(); diff --git a/src/main/java/com/example/feeda/domain/account/controller/AccountController.java b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java index a9dcf48..2773b65 100644 --- a/src/main/java/com/example/feeda/domain/account/controller/AccountController.java +++ b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java @@ -1,10 +1,11 @@ package com.example.feeda.domain.account.controller; -import com.example.feeda.domain.account.sevice.AccountService; +import com.example.feeda.domain.account.sevice.AccountServiceImpl; import com.example.feeda.domain.account.dto.*; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtPayload; import com.example.feeda.security.jwt.JwtUtil; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -16,12 +17,12 @@ @RequestMapping("/api") @RequiredArgsConstructor public class AccountController { - private final AccountService accountService; + private final AccountServiceImpl accountService; private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; @PostMapping("/accounts") - public ResponseEntity signup(@RequestBody SignUpRequestDTO requestDTO) { + public ResponseEntity signup(@RequestBody @Valid SignUpRequestDTO requestDTO) { return new ResponseEntity<>(accountService.signup(requestDTO), HttpStatus.CREATED); } @@ -29,7 +30,7 @@ public ResponseEntity signup(@RequestBody SignUpRequestDTO requ public ResponseEntity deleteAccount( @RequestHeader("Authorization") String bearerToken, @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody DeleteAccountRequestDTO requestDTO + @RequestBody @Valid DeleteAccountRequestDTO requestDTO ) { accountService.deleteAccount(jwtPayload.getAccountId(), requestDTO.getPassword()); @@ -42,14 +43,14 @@ public ResponseEntity deleteAccount( @PatchMapping("/accounts/password") public ResponseEntity updatePassword( @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody UpdatePasswordRequestDTO requestDTO + @RequestBody @Valid UpdatePasswordRequestDTO requestDTO ) { return new ResponseEntity<>(accountService.updatePassword(jwtPayload.getAccountId(), requestDTO), HttpStatus.OK); } @PostMapping("/accounts/login") public ResponseEntity login( - @RequestBody LogInRequestDTO requestDTO + @RequestBody @Valid LogInRequestDTO requestDTO ) { UserResponseDTO responseDTO = accountService.login(requestDTO); diff --git a/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java index 69ef961..9c18c4a 100644 --- a/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java @@ -1,94 +1,16 @@ package com.example.feeda.domain.account.sevice; import com.example.feeda.domain.account.dto.LogInRequestDTO; +import com.example.feeda.domain.account.dto.SignUpRequestDTO; import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; import com.example.feeda.domain.account.dto.UserResponseDTO; -import com.example.feeda.domain.account.dto.SignUpRequestDTO; -import com.example.feeda.domain.account.entity.Account; -import com.example.feeda.domain.account.repository.AccountRepository; -import com.example.feeda.domain.profile.entity.Profile; -import com.example.feeda.domain.profile.repository.ProfileRepository; -import com.example.feeda.security.PasswordEncoder; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - - - -@Service -@RequiredArgsConstructor -public class AccountService { - private final AccountRepository accountRepository; - private final PasswordEncoder passwordEncoder; - private final ProfileRepository profileRepository; - - @Transactional - public UserResponseDTO signup(SignUpRequestDTO requestDTO) { - if(accountRepository.findByEmail(requestDTO.getEmail()).isPresent()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다. : " + requestDTO.getEmail()); - } - - if(profileRepository.findByNickname(requestDTO.getNickName()).isPresent()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 닉네임 입니다. : " + requestDTO.getNickName()); - } - - Account account = new Account(requestDTO.getEmail(), requestDTO.getPassword()); - account.setPassword(passwordEncoder.encode(account.getPassword())); - - Profile profile = new Profile(requestDTO.getNickName(), requestDTO.getBirth(), requestDTO.getBio()); - - // 양방향 연결 - account.setProfile(profile); - profile.setAccount(account); - - Account saveProfile = accountRepository.save(account); - - return new UserResponseDTO(saveProfile); - } - - @Transactional - public void deleteAccount(Long id, String password) { - Account account = getAccountById(id); - - if(!passwordEncoder.matches(password, account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); - } - - accountRepository.delete(account); - } - - @Transactional - public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO) { - Account account = getAccountById(id); - - if(!passwordEncoder.matches(requestDTO.getOldPassword(), account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); - } - - account.setPassword(passwordEncoder.encode(requestDTO.getNewPassword())); - - // DB 에 변경 사항 강제 반영 - accountRepository.flush(); - - return new UserResponseDTO(account); - } - - public UserResponseDTO login(LogInRequestDTO requestDTO) { - return new UserResponseDTO(accountRepository.findByEmail(requestDTO.getEmail()) - .filter(findAccount -> passwordEncoder.matches(requestDTO.getPassword(), findAccount.getPassword())) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다.")) - ); - } +public interface AccountService { + UserResponseDTO signup(SignUpRequestDTO requestDTO); + void deleteAccount(Long id, String password); - /* 유틸(?): 서비스 내에서만 사용 */ + UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO); - public Account getAccountById(Long id) { - return accountRepository.findById(id).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 유저가 존재하지 않습니다. : " + id) - ); - } + UserResponseDTO login(LogInRequestDTO requestDTO); } diff --git a/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java new file mode 100644 index 0000000..fb2c7aa --- /dev/null +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java @@ -0,0 +1,96 @@ +package com.example.feeda.domain.account.sevice; + +import com.example.feeda.domain.account.dto.LogInRequestDTO; +import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; +import com.example.feeda.domain.account.dto.UserResponseDTO; +import com.example.feeda.domain.account.dto.SignUpRequestDTO; +import com.example.feeda.domain.account.entity.Account; +import com.example.feeda.domain.account.repository.AccountRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import com.example.feeda.security.PasswordEncoder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class AccountServiceImpl implements AccountService { + private final AccountRepository accountRepository; + private final PasswordEncoder passwordEncoder; + private final ProfileRepository profileRepository; + + @Override + @Transactional + public UserResponseDTO signup(SignUpRequestDTO requestDTO) { + if(accountRepository.findByEmail(requestDTO.getEmail()).isPresent()) { + throw new CustomResponseException(ResponseError.EMAIL_ALREADY_EXISTS); + } + + if(profileRepository.findByNickname(requestDTO.getNickName()).isPresent()) { + throw new CustomResponseException(ResponseError.NICKNAME_ALREADY_EXISTS); + } + + Account account = new Account(requestDTO.getEmail(), requestDTO.getPassword()); + account.setPassword(passwordEncoder.encode(account.getPassword())); + + Profile profile = new Profile(requestDTO.getNickName(), requestDTO.getBirth(), requestDTO.getBio()); + + // 양방향 연결 + account.setProfile(profile); + profile.setAccount(account); + + Account saveProfile = accountRepository.save(account); + + return new UserResponseDTO(saveProfile); + } + + @Override + @Transactional + public void deleteAccount(Long id, String password) { + Account account = getAccountById(id); + + if(!passwordEncoder.matches(password, account.getPassword())) { + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); + } + + accountRepository.delete(account); + } + + @Override + @Transactional + public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO) { + Account account = getAccountById(id); + + if(!passwordEncoder.matches(requestDTO.getOldPassword(), account.getPassword())) { + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); + } + + account.setPassword(passwordEncoder.encode(requestDTO.getNewPassword())); + + // DB 에 변경 사항 강제 반영 + accountRepository.flush(); + + return new UserResponseDTO(account); + } + + @Override + public UserResponseDTO login(LogInRequestDTO requestDTO) { + return new UserResponseDTO(accountRepository.findByEmail(requestDTO.getEmail()) + .filter(findAccount -> passwordEncoder.matches(requestDTO.getPassword(), findAccount.getPassword())) + .orElseThrow(() -> new CustomResponseException(ResponseError.INVALID_EMAIL_OR_PASSWORD)) + ); + } + + + /* 유틸(?): 서비스 내에서만 사용 */ + + public Account getAccountById(Long id) { + return accountRepository.findById(id).orElseThrow(() -> + new CustomResponseException(ResponseError.ACCOUNT_NOT_FOUND) + ); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java index 236509d..0746e92 100644 --- a/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java @@ -7,10 +7,10 @@ import com.example.feeda.domain.comment.repository.CommentRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import java.util.Optional; @@ -25,15 +25,15 @@ public class CommentLikeService { public LikeCommentResponseDTO likeComment(Long commentId, Long profileId) { Optional findCommentLike = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); if(findCommentLike.isPresent()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다. : " + commentId); + throw new CustomResponseException(ResponseError.ALREADY_LIKED_COMMENT); } Comment findComment = commentRepository.findById(commentId).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 게시글이 존재하지 않습니다. : " + commentId) + new CustomResponseException(ResponseError.COMMENT_NOT_FOUND) ); Profile findProfile = profileRepository.findById(profileId).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 유저가 존재하지 않습니다. : " + profileId) + new CustomResponseException(ResponseError.PROFILE_NOT_FOUND) ); CommentLike commentLike = new CommentLike(findComment, findProfile); @@ -45,7 +45,7 @@ public LikeCommentResponseDTO likeComment(Long commentId, Long profileId) { public void unlikeComment(Long commentId, Long profileId) { Optional findCommentLikeOptional = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); if(findCommentLikeOptional.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아직 좋아요하지 않은 댓글 입니다. : " + commentId); + throw new CustomResponseException(ResponseError.NOT_YET_LIKED_COMMENT); } CommentLike commentLike = findCommentLikeOptional.get(); diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java index f4e1631..765d8b5 100644 --- a/src/main/java/com/example/feeda/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java @@ -9,14 +9,13 @@ import com.example.feeda.domain.post.repository.PostRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -29,9 +28,9 @@ public class CommentService { @Transactional public CommentResponse createComment(Long postId, Long profileId, CreateCommentRequest request) { Post post = postRepository.findById(postId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); Comment comment = new Comment(post, profile, request.getContent()); commentRepository.save(comment); @@ -49,22 +48,22 @@ public List getCommentsByPostId(Long postId, String sort) { return comments.stream() .map(CommentResponse::from) - .collect(Collectors.toList()); + .toList(); } public CommentResponse getCommentById(Long commentId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); return CommentResponse.from(comment); } @Transactional public CommentResponse updateComment(Long commentId, Long requesterProfileId, UpdateCommentRequest request) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); if (!comment.getProfile().getId().equals(requesterProfileId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인의 댓글만 수정할 수 있습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); } comment.updateContent(request.getContent()); @@ -74,13 +73,13 @@ public CommentResponse updateComment(Long commentId, Long requesterProfileId, Up @Transactional public void deleteComment(Long commentId, Long requesterProfileId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); Long authorId = comment.getProfile().getId(); Long postOwnerId = comment.getPost().getProfile().getId(); if (!authorId.equals(requesterProfileId) && !postOwnerId.equals(requesterProfileId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); } commentRepository.delete(comment); diff --git a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java index c13d348..ea37010 100644 --- a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java @@ -7,6 +7,8 @@ import com.example.feeda.domain.profile.dto.ProfileListResponseDto; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import com.example.feeda.security.jwt.JwtPayload; import java.util.List; import java.util.Optional; @@ -14,10 +16,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; @Service @Slf4j @@ -38,8 +38,7 @@ public FollowsResponseDto follow(JwtPayload jwtPayload, Long profileId) { Optional follow = followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); if (follow.isPresent()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "이미 팔로우한 계정입니다."); + throw new CustomResponseException(ResponseError.ALREADY_FOLLOWED); } Follows newFollow = Follows.builder() @@ -63,8 +62,7 @@ public void unfollow(JwtPayload jwtPayload, Long followingId) { Optional follows = followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); if (follows.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, - "존재하지 않는 팔로우입니다."); + throw new CustomResponseException(ResponseError.FOLLOW_NOT_FOUND); } followsRepository.delete(follows.get()); @@ -131,8 +129,7 @@ private Profile getProfileOrThrow(Long profileId) { profileRepository.findById(profileId); if (optionalProfile.isEmpty()) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, "존재하지 않는 프로필입니다. id = " + profileId); + throw new CustomResponseException(ResponseError.PROFILE_NOT_FOUND); } return optionalProfile.get(); @@ -140,8 +137,7 @@ private Profile getProfileOrThrow(Long profileId) { private void validateNotSelf(Profile me, Long profileId) { if (me.getId().equals(profileId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "본인 프로필은 팔로우/언팔로우 할 수 없습니다"); + throw new CustomResponseException(ResponseError.CANNOT_FOLLOW_SELF); } } } diff --git a/src/main/java/com/example/feeda/domain/post/controller/PostController.java b/src/main/java/com/example/feeda/domain/post/controller/PostController.java index 3a2943e..db0f727 100644 --- a/src/main/java/com/example/feeda/domain/post/controller/PostController.java +++ b/src/main/java/com/example/feeda/domain/post/controller/PostController.java @@ -51,7 +51,6 @@ public ResponseEntity makeLikes( @PathVariable Long id, @AuthenticationPrincipal JwtPayload jwtPayload) { - Long profileId = jwtPayload.getProfileId(); return new ResponseEntity<>(postService.makeLikes(id, jwtPayload), HttpStatus.OK); } diff --git a/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java index 89e181d..fdcfa93 100644 --- a/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java +++ b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java @@ -1,9 +1,10 @@ package com.example.feeda.domain.post.dto; import com.example.feeda.domain.post.entity.Post; -import java.time.LocalDateTime; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class PostResponseDto { @@ -33,8 +34,4 @@ public PostResponseDto(Post post, Long likes, Long comments) { this.createdAt = post.getCreatedAt(); this.updatedAt = post.getUpdatedAt(); } - - public static PostResponseDto toDto(Post post, Long likes, Long comments) { - return new PostResponseDto(post, likes, comments); - } } diff --git a/src/main/java/com/example/feeda/domain/post/entity/Post.java b/src/main/java/com/example/feeda/domain/post/entity/Post.java index af81853..5fe3393 100644 --- a/src/main/java/com/example/feeda/domain/post/entity/Post.java +++ b/src/main/java/com/example/feeda/domain/post/entity/Post.java @@ -13,6 +13,7 @@ import jakarta.validation.constraints.Size; import lombok.Getter; + @Getter @Entity @Table(name = "posts") @@ -38,12 +39,6 @@ public class Post extends BaseEntity { @JoinColumn(name = "profile_id") private Profile profile; - public void update(String title, String content, String category) { - this.title = title; - this.content = content; - this.category = category; - } - public Post(String title, String content, String category, Profile profile) { this.title = title; this.content = content; @@ -53,4 +48,10 @@ public Post(String title, String content, String category, Profile profile) { protected Post() { } + + public void update(String title, String content, String category) { + this.title = title; + this.content = content; + this.category = category; + } } diff --git a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java index 83fab0b..f30cf02 100644 --- a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java @@ -12,6 +12,8 @@ import com.example.feeda.domain.post.repository.PostRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import com.example.feeda.security.jwt.JwtPayload; import java.time.LocalDate; import java.util.List; @@ -21,11 +23,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - @Service @RequiredArgsConstructor @@ -42,7 +41,7 @@ public PostResponseDto createPost(PostRequestDto postRequestDto, JwtPayload jwtP Profile profile = profileRepository.findById(jwtPayload.getProfileId()) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 프로필입니다.")); + () -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); Post post = new Post(postRequestDto.getTitle(), postRequestDto.getContent(), postRequestDto.getCategory(), profile); @@ -55,15 +54,12 @@ public PostResponseDto createPost(PostRequestDto postRequestDto, JwtPayload jwtP @Override @Transactional public PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload) { - Post post = postRepository.findById(id).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); - Profile profile = profileRepository.findById(jwtPayload.getProfileId()).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "프로필이 존재하지 않습니다.")); - ; + Post post = postRepository.findById(id).orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + Profile profile = profileRepository.findById(jwtPayload.getProfileId()).orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); // 중복 좋아요 방지 postLikeRepository.findByPostAndProfile(post, profile).ifPresent(like -> { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 좋아요를 눌렀습니다."); + throw new CustomResponseException(ResponseError.ALREADY_LIKED_POST); }); PostLike savePost = postLikeRepository.save(new PostLike(post, profile)); @@ -74,15 +70,13 @@ public PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload) { @Override public void deleteLikes(Long id, Long profileId) { Post post = postRepository.findById(id) - .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 프로필")); + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); PostLike postLike = postLikeRepository.findByPostAndProfile(post, profile) - .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 사용자의 좋아요가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.NOT_YET_LIKED_POST)); postLikeRepository.delete(postLike); } @@ -93,7 +87,7 @@ public PostResponseDto findPostById(Long id) { Optional optionalPost = postRepository.findById(id); if (optionalPost.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"); + throw new CustomResponseException(ResponseError.POST_NOT_FOUND); } Post findPost = optionalPost.get(); @@ -111,21 +105,25 @@ public Page findAll(Pageable pageable, String keyword, if ((startUpdatedAt == null && endUpdatedAt != null) || (startUpdatedAt != null && endUpdatedAt == null)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "startUpdatedAt과 endUpdatedAt은 둘 다 있어야 하거나 둘 다 없어야 합니다."); + throw new CustomResponseException(ResponseError.INVALID_DATE_PARAMETERS); } if (startUpdatedAt != null) { return postRepository.findAllByTitleContainingAndUpdatedAtBetween( keyword, startUpdatedAt.atStartOfDay(), endUpdatedAt.atTime(23, 59, 59), pageable) - .map(post -> PostResponseDto.toDto(post, postLikeRepository.countByPost(post), - commentRepository.countByPost(post))); + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); } return postRepository.findAllByTitleContaining(keyword, pageable) - .map(post -> PostResponseDto.toDto(post, postLikeRepository.countByPost(post), - commentRepository.countByPost(post))); + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); } @Override @@ -143,8 +141,11 @@ public Page findFollowingAllPost(Pageable pageable, JwtPayload Sort.Direction.DESC, "updatedAt"); return postRepository.findAllByProfile_IdIn(followingProfileIds, newPageable) - .map(post -> PostResponseDto.toDto(post, postLikeRepository.countByPost(post), - commentRepository.countByPost(post))); + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); } @Override @@ -152,13 +153,13 @@ public Page findFollowingAllPost(Pageable pageable, JwtPayload public PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload jwtPayload) { Post findPost = postRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 게시글")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); } - findPost.update(requestDto.getTitle(), requestDto.getCategory(), requestDto.getCategory()); + findPost.update(requestDto.getTitle(), requestDto.getContent(), requestDto.getCategory()); Post savedPost = postRepository.save(findPost); Long likeCount = postLikeRepository.countByPost(findPost); @@ -172,10 +173,10 @@ public PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload public void deletePost(Long id, JwtPayload jwtPayload) { Post findPost = postRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 게시글")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); } postRepository.delete(findPost); diff --git a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java index be10c44..b134265 100644 --- a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java +++ b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java @@ -1,7 +1,7 @@ package com.example.feeda.domain.profile.controller; import com.example.feeda.domain.profile.dto.*; -import com.example.feeda.domain.profile.service.ProfileService; +import com.example.feeda.domain.profile.service.ProfileServiceImpl; import com.example.feeda.security.jwt.JwtPayload; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -13,9 +13,9 @@ @RequestMapping("/api") public class ProfileController { - private final ProfileService profileService; + private final ProfileServiceImpl profileService; - public ProfileController(ProfileService profileService) { + public ProfileController(ProfileServiceImpl profileService) { this.profileService = profileService; } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java index b6d8d43..098637e 100644 --- a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java @@ -1,119 +1,14 @@ package com.example.feeda.domain.profile.service; -import com.example.feeda.domain.follow.repository.FollowsRepository; -import com.example.feeda.domain.profile.dto.*; -import com.example.feeda.domain.profile.entity.Profile; -import com.example.feeda.domain.profile.repository.ProfileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; +import com.example.feeda.domain.profile.dto.GetProfileWithFollowCountResponseDto; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.domain.profile.dto.UpdateProfileRequestDto; +import com.example.feeda.domain.profile.dto.UpdateProfileResponseDto; -import java.util.List; +public interface ProfileService { + GetProfileWithFollowCountResponseDto getProfile(Long id); -@Service -@RequiredArgsConstructor -public class ProfileService { - private final ProfileRepository profileRepository; - private final FollowsRepository followsRepository; + ProfileListResponseDto getProfiles(String keyword, int page, int size); - /** - * 프로필 단건 조회 기능 - */ - @Transactional(readOnly = true) - public GetProfileWithFollowCountResponseDto getProfile(Long id) { - - Profile profile = profileRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다." - )); - - Long followerCount = followsRepository.countByFollowings_Id(id); - Long followingCount = followsRepository.countByFollowers_Id(id); - - return GetProfileWithFollowCountResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - followerCount, - followingCount, - profile.getCreatedAt(), - profile.getUpdatedAt() - ); - } - - /** - * 프로필 다건 조회 기능(검색,페이징) - */ - - @Transactional(readOnly = true) - public ProfileListResponseDto getProfiles(String keyword, int page, int size) { - - if (page < 1 || size < 1) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다." - ); - } - - Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); - - Page profilePage; - if (keyword == null || keyword.trim().isEmpty()) { - profilePage = profileRepository.findAll(pageable); - } else { - profilePage = profileRepository.findByNicknameContaining(keyword, pageable); - } - - List responseDtoList = profilePage.stream() - .map(profile -> GetProfileResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - profile.getCreatedAt(), - profile.getUpdatedAt() - )) - .toList(); - - return ProfileListResponseDto.of( - responseDtoList, - profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 - profilePage.getTotalPages(), - profilePage.getTotalElements() - ); - } - - - /** - * 프로필 수정 기능 - */ - @Transactional - public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { - - Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 없음.")); - - if (!profile.getAccount().getId().equals(userId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."); - } - - if (requestDto.getNickname() != null || requestDto.getBirth() != null || requestDto.getBio() != null) { - profile.updateProfile( - requestDto.getNickname(), - requestDto.getBirth(), - requestDto.getBio() - ); - } - - profileRepository.save(profile); - - return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); - } + UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto); } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java new file mode 100644 index 0000000..0941ac1 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java @@ -0,0 +1,116 @@ +package com.example.feeda.domain.profile.service; + +import com.example.feeda.domain.follow.repository.FollowsRepository; +import com.example.feeda.domain.profile.dto.*; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProfileServiceImpl implements ProfileService { + private final ProfileRepository profileRepository; + private final FollowsRepository followsRepository; + + /** + * 프로필 단건 조회 기능 + */ + @Override + @Transactional(readOnly = true) + public GetProfileWithFollowCountResponseDto getProfile(Long id) { + + Profile profile = profileRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + Long followerCount = followsRepository.countByFollowings_Id(id); + Long followingCount = followsRepository.countByFollowers_Id(id); + + return GetProfileWithFollowCountResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + followerCount, + followingCount, + profile.getCreatedAt(), + profile.getUpdatedAt() + ); + } + + /** + * 프로필 다건 조회 기능(검색,페이징) + */ + @Override + @Transactional(readOnly = true) + public ProfileListResponseDto getProfiles(String keyword, int page, int size) { + + if (page < 1 || size < 1) { + throw new CustomResponseException(ResponseError.INVALID_PAGINATION_PARAMETERS); + } + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); + + Page profilePage; + if (keyword == null || keyword.trim().isEmpty()) { + profilePage = profileRepository.findAll(pageable); + } else { + profilePage = profileRepository.findByNicknameContaining(keyword, pageable); + } + + List responseDtoList = profilePage.stream() + .map(profile -> GetProfileResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + profile.getCreatedAt(), + profile.getUpdatedAt() + )) + .toList(); + + return ProfileListResponseDto.of( + responseDtoList, + profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 + profilePage.getTotalPages(), + profilePage.getTotalElements() + ); + } + + + /** + * 프로필 수정 기능 + */ + @Override + @Transactional + public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { + + Profile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + if (!profile.getAccount().getId().equals(userId)) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); + } + + if (requestDto.getNickname() != null || requestDto.getBirth() != null || requestDto.getBio() != null) { + profile.updateProfile( + requestDto.getNickname(), + requestDto.getBirth(), + requestDto.getBio() + ); + } + + profileRepository.save(profile); + + return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); + } +} diff --git a/src/main/java/com/example/feeda/exception/CustomResponseException.java b/src/main/java/com/example/feeda/exception/CustomResponseException.java new file mode 100644 index 0000000..efcf233 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/CustomResponseException.java @@ -0,0 +1,17 @@ +package com.example.feeda.exception; + +import com.example.feeda.exception.enums.ResponseError; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CustomResponseException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String errorMessage; + + public CustomResponseException(ResponseError responseError) { + this.httpStatus = responseError.getHttpStatus(); + this.errorMessage = responseError.getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e787dab --- /dev/null +++ b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java @@ -0,0 +1,67 @@ +package com.example.feeda.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler({ + MethodArgumentNotValidException.class, + BindException.class, + ConstraintViolationException.class + }) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { + String message = ex.getBindingResult().getFieldErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase()); + body.put("message", message); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(CustomResponseException.class) + public ResponseEntity> handleCustomResponseException(CustomResponseException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", ex.getHttpStatus().value()); + body.put("error", ex.getHttpStatus().getReasonPhrase()); + body.put("message", ex.getMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, ex.getHttpStatus()); + } + + @ExceptionHandler(TokenNotFoundException.class) + public ResponseEntity> handleTokenNotFoundException(TokenNotFoundException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.UNAUTHORIZED.value()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ex.getMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.UNAUTHORIZED); + } +} + + diff --git a/src/main/java/com/example/feeda/exception/JwtValidationException.java b/src/main/java/com/example/feeda/exception/JwtValidationException.java deleted file mode 100644 index 9912176..0000000 --- a/src/main/java/com/example/feeda/exception/JwtValidationException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.feeda.exception; - -import lombok.Getter; - -@Getter -public class JwtValidationException extends RuntimeException { - private final int statusCode; - - public JwtValidationException(String message, int statusCode) { - super(message); - this.statusCode = statusCode; - } -} diff --git a/src/main/java/com/example/feeda/exception/enums/ResponseError.java b/src/main/java/com/example/feeda/exception/enums/ResponseError.java new file mode 100644 index 0000000..a442fad --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ResponseError.java @@ -0,0 +1,46 @@ +package com.example.feeda.exception.enums; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ResponseError { + // 회원 관리 관련 오류 + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + INVALID_EMAIL_OR_PASSWORD(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다."), + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "계정이 존재하지 않습니다."), + + // 프로필 관련 오류 + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 닉네임 입니다."), + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "프로필이 존재하지 않습니다."), + + // 팔로우 관련 오류 + ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, "이미 팔로우한 계정입니다."), + FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 팔로우입니다."), + CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, "본인 프로필은 팔로우/언팔로우 할 수 없습니다"), + + // 게시글 관련 오류 + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다"), + ALREADY_LIKED_POST(HttpStatus.BAD_REQUEST, "이미 좋아요한 게시글 입니다."), + NOT_YET_LIKED_POST(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 게시글 입니다."), + ALREADY_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다."), + NOT_YET_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 댓글 입니다."), + + // 페이징 & 검색 관련 오류 + INVALID_PAGINATION_PARAMETERS(HttpStatus.BAD_REQUEST, "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다."), + INVALID_DATE_PARAMETERS(HttpStatus.BAD_REQUEST, "startUpdatedAt과 endUpdatedAt은 둘 다 있어야 하거나 둘 다 없어야 합니다."), + + // 권한 관련 오류 + NO_PERMISSION_TO_EDIT(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."), + NO_PERMISSION_TO_DELETE(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + ResponseError(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java new file mode 100644 index 0000000..57a16a7 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java @@ -0,0 +1,28 @@ +package com.example.feeda.exception.enums; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; + +@Getter +public enum ServletResponseError { + // JWT 관련 오류 + INVALID_JWT_SIGNATURE(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."), + EXPIRED_JWT_TOKEN(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."), + UNSUPPORTED_JWT(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."), + INVALID_JWT(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."), + + // Security 관련 오류 + UNAUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED, "인증이 필요합니다."), + ACCESS_DENIED(HttpServletResponse.SC_FORBIDDEN, "접근이 거부되었습니다."), + + // 내부 서버 오류 + INTERNAL_SERVER_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."); + + private final int httpStatus; + private final String message; + + ServletResponseError(int httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/filter/JwtFilter.java b/src/main/java/com/example/feeda/filter/JwtFilter.java index 5ca93bd..33d4cd6 100644 --- a/src/main/java/com/example/feeda/filter/JwtFilter.java +++ b/src/main/java/com/example/feeda/filter/JwtFilter.java @@ -1,6 +1,7 @@ package com.example.feeda.filter; -import com.example.feeda.exception.JwtValidationException; +import com.example.feeda.exception.enums.ServletResponseError; +import com.example.feeda.exception.TokenNotFoundException; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtPayload; import com.example.feeda.security.jwt.JwtUtil; @@ -47,13 +48,13 @@ protected void doFilterInternal( // JWT 유효성 검사와 claims 추출 Claims claims = jwtUtil.extractClaims(jwt); if (claims == null) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); + response.sendError(ServletResponseError.INVALID_JWT.getHttpStatus(), ServletResponseError.INVALID_JWT.getMessage()); return; } // 블랙리스트 검증 if (jwtBlacklistService.isBlacklisted(jwt)) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); return; } @@ -73,14 +74,14 @@ protected void doFilterInternal( chain.doFilter(request, response); - } catch (SecurityException | MalformedJwtException e) { - throw new JwtValidationException("유효하지 않는 JWT 서명입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (SecurityException | MalformedJwtException | TokenNotFoundException e) { + response.sendError(ServletResponseError.INVALID_JWT_SIGNATURE.getHttpStatus(), ServletResponseError.INVALID_JWT_SIGNATURE.getMessage()); } catch (ExpiredJwtException e) { - throw new JwtValidationException("만료된 JWT 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED); + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); } catch (UnsupportedJwtException e) { - throw new JwtValidationException("지원되지 않는 JWT 토큰입니다.", HttpServletResponse.SC_BAD_REQUEST); + response.sendError(ServletResponseError.UNSUPPORTED_JWT.getHttpStatus(), ServletResponseError.UNSUPPORTED_JWT.getMessage()); } catch (Exception e) { - throw new JwtValidationException("내부 서버 오류", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.sendError(ServletResponseError.INTERNAL_SERVER_ERROR.getHttpStatus(), ServletResponseError.INTERNAL_SERVER_ERROR.getMessage()); } } } diff --git a/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..a7f77ab --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(ServletResponseError.ACCESS_DENIED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.ACCESS_DENIED.getHttpStatus()); + body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase()); + body.put("message", ServletResponseError.ACCESS_DENIED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..5bf4a37 --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setStatus(ServletResponseError.UNAUTHORIZED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.UNAUTHORIZED.getHttpStatus()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ServletResponseError.UNAUTHORIZED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java index 5ad5357..ba73898 100644 --- a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java +++ b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java @@ -49,7 +49,7 @@ public String extractToken(String tokenValue) { return tokenValue.substring(BEARER_PREFIX.length()); // "Bearer " 제거 후 반환 } - throw new TokenNotFoundException("Not Found Token"); + throw new TokenNotFoundException("토큰을 찾을 수 없습니다."); } public Claims extractClaims(String token) {