-
Notifications
You must be signed in to change notification settings - Fork 20
[3주차] 이예서/[feat] 게시글 API 구현 #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 이예서/main
Are you sure you want to change the base?
The head ref may contain hidden characters: "\uC774\uC608\uC11C/3\uC8FC\uCC28"
Changes from all commits
d42938b
23dd966
3471ee8
05bc1b9
9aef557
b1c2968
f4e7669
87d2edd
e954012
290fd8d
484959c
dd8c25a
eaf9c6b
7791ea0
a88bb9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Getting Started | ||
|
|
||
| ### Reference Documentation | ||
| For further reference, please consider the following sections: | ||
|
|
||
| * [Official Gradle documentation](https://docs.gradle.org) | ||
| * [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.12/gradle-plugin) | ||
| * [Create an OCI image](https://docs.spring.io/spring-boot/3.5.12/gradle-plugin/packaging-oci-image.html) | ||
| * [Spring Web](https://docs.spring.io/spring-boot/3.5.12/reference/web/servlet.html) | ||
| * [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.12/reference/data/sql.html#data.sql.jpa-and-spring-data) | ||
|
|
||
| ### Guides | ||
| The following guides illustrate how to use some features concretely: | ||
|
|
||
| * [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) | ||
| * [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) | ||
| * [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) | ||
| * [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) | ||
| * [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) | ||
|
|
||
| ### Additional Links | ||
| These additional references should also help you: | ||
|
|
||
| * [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.example.blog.domain.post.controller; | ||
|
|
||
| import com.example.blog.domain.post.service.PostService; | ||
| import com.example.blog.dto.post.PostCreateRequest; | ||
| import com.example.blog.dto.post.PostDetailResponse; | ||
| import com.example.blog.dto.post.PostSummaryResponse; | ||
| import com.example.blog.dto.post.PostUpdateRequest; | ||
| import com.example.blog.global.response.ApiResponse; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/posts") | ||
| @RequiredArgsConstructor | ||
| public class PostController { | ||
|
|
||
| private final PostService postService; | ||
|
|
||
| // GET /posts - 게시글 목록 조회 | ||
| @GetMapping | ||
| public ApiResponse<List<PostSummaryResponse>> getPosts() { | ||
| return ApiResponse.onSuccess(postService.getPosts()); | ||
| } | ||
|
|
||
| // GET /posts/{postId} - 게시글 상세 조회 | ||
| @GetMapping("/{postId}") | ||
| public ApiResponse<PostDetailResponse> getPost(@PathVariable Long postId) { | ||
| return ApiResponse.onSuccess(postService.getPost(postId)); | ||
| } | ||
|
|
||
| // POST /posts - 게시글 작성 | ||
| @PostMapping | ||
| public ApiResponse<PostDetailResponse> createPost( | ||
| @Valid @RequestBody PostCreateRequest request) { | ||
| return ApiResponse.onSuccess(postService.createPost(request)); | ||
| } | ||
|
|
||
| // PATCH /posts/{postId} - 게시글 수정 | ||
| @PatchMapping("/{postId}") | ||
| public ApiResponse<PostDetailResponse> updatePost( | ||
| @PathVariable Long postId, | ||
| @Valid @RequestBody PostUpdateRequest request) { | ||
| return ApiResponse.onSuccess(postService.updatePost(postId, request)); | ||
| } | ||
|
|
||
| // DELETE /posts/{postId} - 게시글 삭제 | ||
| @DeleteMapping("/{postId}") | ||
| public ApiResponse<Void> deletePost( | ||
| @PathVariable Long postId, | ||
| @RequestParam Long userId) { | ||
| postService.deletePost(postId, userId); | ||
| return ApiResponse.onSuccess(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.example.blog.domain.post.converter; | ||
|
|
||
| import com.example.blog.domain.post.entity.Post; | ||
| import com.example.blog.domain.user.entity.User; | ||
| import com.example.blog.dto.post.PostCreateRequest; | ||
| import com.example.blog.dto.post.PostDetailResponse; | ||
| import com.example.blog.dto.post.PostSummaryResponse; | ||
|
|
||
| public class PostConverter { | ||
|
|
||
| // PostCreateRequest + User → Post Entity | ||
| public static Post toEntity(PostCreateRequest request, User user) { | ||
| return Post.builder() | ||
| .user(user) | ||
| .title(request.getTitle()) | ||
| .content(request.getContent()) | ||
| .imageUrl(request.getImageUrl()) | ||
| .build(); | ||
| } | ||
|
|
||
| // Post Entity → PostDetailResponse | ||
| public static PostDetailResponse toDetailResponse(Post post) { | ||
| return PostDetailResponse.builder() | ||
| .id(post.getId()) | ||
| .title(post.getTitle()) | ||
| .content(post.getContent()) | ||
| .imageUrl(post.getImageUrl()) | ||
| .authorNickname(post.getUser().getNickname()) | ||
| .createdAt(post.getCreatedAt()) | ||
| .updatedAt(post.getUpdatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| // Post Entity → PostSummaryResponse (목록용) | ||
| public static PostSummaryResponse toSummaryResponse(Post post) { | ||
| return PostSummaryResponse.builder() | ||
| .id(post.getId()) | ||
| .title(post.getTitle()) | ||
| .authorNickname(post.getUser().getNickname()) | ||
| .createdAt(post.getCreatedAt()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.example.blog.domain.post.repository; | ||
|
|
||
| import com.example.blog.domain.post.entity.Post; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| public interface PostRepository extends JpaRepository<Post, Long> { | ||
|
|
||
| // soft delete: deletedAt이 null인 것만 조회 | ||
| List<Post> findAllByDeletedAtIsNull(); | ||
|
|
||
| Optional<Post> findByIdAndDeletedAtIsNull(Long id); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package com.example.blog.domain.post.service; | ||
|
|
||
| import com.example.blog.domain.post.converter.PostConverter; | ||
| import com.example.blog.domain.post.entity.Post; | ||
| import com.example.blog.domain.post.repository.PostRepository; | ||
| import com.example.blog.domain.user.entity.User; | ||
| import com.example.blog.domain.user.repository.UserRepository; | ||
| import com.example.blog.dto.post.PostCreateRequest; | ||
| import com.example.blog.dto.post.PostDetailResponse; | ||
| import com.example.blog.dto.post.PostSummaryResponse; | ||
| import com.example.blog.dto.post.PostUpdateRequest; | ||
| import com.example.blog.exception.PostForbiddenException; | ||
| import com.example.blog.exception.PostNotFoundException; | ||
| import com.example.blog.exception.UserNotFoundException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class PostService { | ||
|
|
||
| private final PostRepository postRepository; | ||
| private final UserRepository userRepository; | ||
|
|
||
| // 게시글 목록 조회 | ||
| @Transactional(readOnly = true) | ||
| public List<PostSummaryResponse> getPosts() { | ||
| return postRepository.findAllByDeletedAtIsNull() | ||
| .stream() | ||
| .map(PostConverter::toSummaryResponse) | ||
| .collect(Collectors.toList()); | ||
| } | ||
|
|
||
| // 게시글 상세 조회 | ||
| @Transactional(readOnly = true) | ||
| public PostDetailResponse getPost(Long postId) { | ||
| Post post = postRepository.findByIdAndDeletedAtIsNull(postId) | ||
| .orElseThrow(() -> new PostNotFoundException(postId)); | ||
| return PostConverter.toDetailResponse(post); | ||
| } | ||
|
|
||
| // 게시글 작성 | ||
| @Transactional | ||
| public PostDetailResponse createPost(PostCreateRequest request) { | ||
| User user = userRepository.findById(request.getUserId()) | ||
| .orElseThrow(() -> new UserNotFoundException(request.getUserId())); | ||
| Post post = PostConverter.toEntity(request, user); | ||
| postRepository.save(post); | ||
| return PostConverter.toDetailResponse(post); | ||
| } | ||
|
|
||
| // 게시글 수정 | ||
| @Transactional | ||
| public PostDetailResponse updatePost(Long postId, PostUpdateRequest request) { | ||
| Post post = postRepository.findByIdAndDeletedAtIsNull(postId) | ||
| .orElseThrow(() -> new PostNotFoundException(postId)); | ||
|
|
||
| // 작성자 권한 확인 | ||
| if (!post.getUser().getId().equals(request.getUserId())) { | ||
| throw new PostForbiddenException(); | ||
| } | ||
|
|
||
| post.update(request.getTitle(), request.getContent(), request.getImageUrl()); | ||
| return PostConverter.toDetailResponse(post); | ||
| } | ||
|
|
||
| // 게시글 삭제 (soft delete) | ||
| @Transactional | ||
| public void deletePost(Long postId, Long userId) { | ||
| Post post = postRepository.findByIdAndDeletedAtIsNull(postId) | ||
| .orElseThrow(() -> new PostNotFoundException(postId)); | ||
|
|
||
| // 작성자 권한 확인 | ||
| if (!post.getUser().getId().equals(userId)) { | ||
| throw new PostForbiddenException(); | ||
| } | ||
|
|
||
| post.delete(); // BaseEntity의 soft delete | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.example.blog.domain.user.repository; | ||
|
|
||
| import com.example.blog.domain.user.entity.User; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface UserRepository extends JpaRepository<User, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.example.blog.dto.post; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| public class PostCreateRequest { | ||
|
|
||
| @NotNull(message = "사용자 ID는 필수입니다.") | ||
| private Long userId; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재는 Request DTO 안에 userId가 포함되어 있는데, 나중에 Spring Security 등을 도입하게 된다면 컨트롤러에서 인증된 유저 정보를 서비스에 인자로 넘겨주는 방식이 더 안전할 수 있어 이러한 방향도 고민해 보면 좋을 것 같습니다! |
||
|
|
||
| @NotBlank(message = "제목은 필수입니다.") | ||
| @Size(max = 255, message = "제목은 최대 255자까지 입력 가능합니다.") | ||
| private String title; | ||
|
|
||
| @NotBlank(message = "본문은 필수입니다.") | ||
| private String content; | ||
|
|
||
| private String imageUrl; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.example.blog.dto.post; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class PostDetailResponse { | ||
|
|
||
| private Long id; | ||
| private String title; | ||
| private String content; | ||
| private String imageUrl; | ||
| private String authorNickname; | ||
| private LocalDateTime createdAt; | ||
| private LocalDateTime updatedAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.example.blog.dto.post; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class PostSummaryResponse { | ||
|
|
||
| private Long id; | ||
| private String title; | ||
| private String authorNickname; | ||
| private LocalDateTime createdAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.example.blog.dto.post; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| public class PostUpdateRequest { | ||
|
|
||
| @NotNull(message = "사용자 ID는 필수입니다.") | ||
| private Long userId; | ||
|
|
||
| @NotBlank(message = "제목은 필수입니다.") | ||
| @Size(max = 255, message = "제목은 최대 255자까지 입력 가능합니다.") | ||
| private String title; | ||
|
|
||
| @NotBlank(message = "본문은 필수입니다.") | ||
| private String content; | ||
|
|
||
| private String imageUrl; | ||
| } |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 각 에러별로 코드랑 주석처리 하신게 너무 좋습니다! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.example.blog.exception; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| public enum ErrorCode { | ||
|
|
||
| // 400 | ||
| BAD_REQUEST(HttpStatus.BAD_REQUEST, "400_000", "잘못된 요청입니다."), | ||
| MISSING_REQUIRED_VALUE(HttpStatus.BAD_REQUEST, "400_001", "필수 입력값이 누락되었습니다."), | ||
| TITLE_TOO_LONG(HttpStatus.BAD_REQUEST, "400_002", "제목은 최대 255자까지 입력 가능합니다."), | ||
| INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "400_004", "데이터 타입이 올바르지 않습니다."), | ||
|
|
||
| // 403 | ||
| FORBIDDEN(HttpStatus.FORBIDDEN, "403_000", "접근 권한이 없습니다."), | ||
| NOT_POST_OWNER(HttpStatus.FORBIDDEN, "403_001", "해당 게시글의 작성자가 아닙니다."), | ||
|
|
||
| // 404 | ||
| NOT_FOUND_END_POINT(HttpStatus.NOT_FOUND, "404_000", "존재하지 않는 API 경로입니다."), | ||
| POST_NOT_FOUND(HttpStatus.NOT_FOUND, "404_001", "요청하신 게시글을 찾을 수 없습니다."), | ||
| USER_NOT_FOUND(HttpStatus.NOT_FOUND, "404_002", "사용자를 찾을 수 없습니다."), | ||
|
|
||
| // 500 | ||
| INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500_000", "서버 내부 오류가 발생했습니다."); | ||
|
|
||
| private final HttpStatus status; | ||
| private final String code; | ||
| private final String message; | ||
|
|
||
| ErrorCode(HttpStatus status, String code, String message) { | ||
| this.status = status; | ||
| this.code = code; | ||
| this.message = message; | ||
| } | ||
|
|
||
| public HttpStatus getStatus() { | ||
| return status; | ||
| } | ||
|
|
||
| public String getCode() { | ||
| return code; | ||
| } | ||
|
|
||
| public String getMessage() { | ||
| return message; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Service를 Intetface로 구성하고 ServiceImpl로 구현 클래스를 따로 두는 방식도 좋은 유지보수 코드가 될 것 같아요!