Skip to content

Commit 062f2cc

Browse files
authored
feat: 게시판 CRUD 구현
[FEAT] 게시판 CRUD API 구현
2 parents 24ad8a9 + 7d752c0 commit 062f2cc

10 files changed

Lines changed: 362 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.bootsignal.domain.post.controller;
2+
3+
import com.bootsignal.domain.post.dto.PostCreateRequest;
4+
import com.bootsignal.domain.post.dto.PostResponse;
5+
import com.bootsignal.domain.post.dto.PostUpdateRequest;
6+
import com.bootsignal.domain.post.entity.PostType;
7+
import com.bootsignal.domain.post.service.PostService;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.data.domain.Sort;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.web.bind.annotation.DeleteMapping;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PatchMapping;
18+
import org.springframework.web.bind.annotation.PathVariable;
19+
import org.springframework.web.bind.annotation.PostMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RequestParam;
23+
import org.springframework.web.bind.annotation.ResponseStatus;
24+
import org.springframework.web.bind.annotation.RestController;
25+
26+
@RestController
27+
@RequestMapping("/api/posts")
28+
@RequiredArgsConstructor
29+
public class PostController {
30+
31+
private final PostService postService;
32+
33+
@PostMapping
34+
@ResponseStatus(HttpStatus.CREATED)
35+
public PostResponse create(@RequestBody @Valid PostCreateRequest request) {
36+
return postService.create(request);
37+
}
38+
39+
@GetMapping
40+
public Page<PostResponse> getList(
41+
@RequestParam(required = false) PostType postType,
42+
@RequestParam(required = false) Long courseId,
43+
@RequestParam(required = false) String keyword,
44+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
45+
) {
46+
return postService.getList(postType, courseId, keyword, pageable);
47+
}
48+
49+
@GetMapping("/{postId}")
50+
public PostResponse get(@PathVariable Long postId) {
51+
return postService.get(postId);
52+
}
53+
54+
@PatchMapping("/{postId}")
55+
public PostResponse update(
56+
@PathVariable Long postId,
57+
@RequestBody @Valid PostUpdateRequest request
58+
) {
59+
return postService.update(postId, request);
60+
}
61+
62+
@DeleteMapping("/{postId}")
63+
@ResponseStatus(HttpStatus.NO_CONTENT)
64+
public void delete(@PathVariable Long postId) {
65+
postService.delete(postId);
66+
}
67+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
import com.bootsignal.domain.post.entity.PostType;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
public record PostCreateRequest(
8+
Long courseId,
9+
@NotNull PostType postType,
10+
String category,
11+
@NotBlank String title,
12+
@NotBlank String content
13+
) {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
import com.bootsignal.domain.post.entity.Post;
4+
import com.bootsignal.domain.post.entity.PostType;
5+
import java.time.LocalDateTime;
6+
7+
public record PostResponse(
8+
Long postId,
9+
Long userId,
10+
String userNickname,
11+
Long courseId,
12+
PostType postType,
13+
String category,
14+
String title,
15+
String content,
16+
LocalDateTime createdAt,
17+
LocalDateTime updatedAt
18+
) {
19+
public static PostResponse from(Post post) {
20+
return new PostResponse(
21+
post.getId(),
22+
post.getUser().getId(),
23+
post.getUser().getNickname(),
24+
post.getCourse() != null ? post.getCourse().getId() : null,
25+
post.getPostType(),
26+
post.getCategory(),
27+
post.getTitle(),
28+
post.getContent(),
29+
post.getCreatedAt(),
30+
post.getUpdatedAt()
31+
);
32+
}
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
public record PostUpdateRequest(
4+
String title,
5+
String content,
6+
String category
7+
) {}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.bootsignal.domain.post.entity;
2+
3+
import com.bootsignal.domain.course.entity.Course;
4+
import com.bootsignal.domain.user.entity.User;
5+
import com.bootsignal.global.entity.BaseEntity;
6+
import jakarta.persistence.Column;
7+
import jakarta.persistence.Entity;
8+
import jakarta.persistence.EnumType;
9+
import jakarta.persistence.Enumerated;
10+
import jakarta.persistence.FetchType;
11+
import jakarta.persistence.GeneratedValue;
12+
import jakarta.persistence.GenerationType;
13+
import jakarta.persistence.Id;
14+
import jakarta.persistence.JoinColumn;
15+
import jakarta.persistence.ManyToOne;
16+
import java.time.LocalDateTime;
17+
import lombok.AccessLevel;
18+
import lombok.Builder;
19+
import lombok.Getter;
20+
import lombok.NoArgsConstructor;
21+
22+
@Entity
23+
@Getter
24+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
25+
public class Post extends BaseEntity {
26+
27+
@Id
28+
@GeneratedValue(strategy = GenerationType.IDENTITY)
29+
private Long id;
30+
31+
@ManyToOne(fetch = FetchType.LAZY)
32+
@JoinColumn(name = "user_id", nullable = false)
33+
private User user;
34+
35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "course_id")
37+
private Course course;
38+
39+
@Enumerated(EnumType.STRING)
40+
@Column(nullable = false)
41+
private PostType postType;
42+
43+
private String category;
44+
45+
@Column(nullable = false)
46+
private String title;
47+
48+
@Column(nullable = false, columnDefinition = "TEXT")
49+
private String content;
50+
51+
@Column(nullable = false)
52+
private boolean isValid = true;
53+
54+
private LocalDateTime deletedAt;
55+
56+
@Builder
57+
private Post(User user, Course course, PostType postType, String category, String title, String content) {
58+
this.user = user;
59+
this.course = course;
60+
this.postType = postType;
61+
this.category = category;
62+
this.title = title;
63+
this.content = content;
64+
}
65+
66+
public void update(String title, String content, String category) {
67+
if (title != null) this.title = title;
68+
if (content != null) this.content = content;
69+
if (category != null) this.category = category;
70+
}
71+
72+
public void softDelete() {
73+
this.deletedAt = LocalDateTime.now();
74+
this.isValid = false;
75+
}
76+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.bootsignal.domain.post.entity;
2+
3+
public enum PostType {
4+
PROJECT_RECRUIT, QNA, ARTICLE, BOARD
5+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.bootsignal.domain.post.repository;
2+
3+
import com.bootsignal.domain.post.entity.Post;
4+
import com.bootsignal.domain.post.entity.PostType;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
11+
public interface PostRepository extends JpaRepository<Post, Long> {
12+
13+
@Query(
14+
value = """
15+
SELECT p FROM Post p
16+
JOIN FETCH p.user u
17+
LEFT JOIN FETCH p.course c
18+
WHERE p.deletedAt IS NULL
19+
AND p.isValid = true
20+
AND (:postType IS NULL OR p.postType = :postType)
21+
AND (:courseId IS NULL OR (p.course IS NOT NULL AND p.course.id = :courseId))
22+
AND (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%)
23+
""",
24+
countQuery = """
25+
SELECT count(p) FROM Post p
26+
WHERE p.deletedAt IS NULL
27+
AND p.isValid = true
28+
AND (:postType IS NULL OR p.postType = :postType)
29+
AND (:courseId IS NULL OR (p.course IS NOT NULL AND p.course.id = :courseId))
30+
AND (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%)
31+
"""
32+
)
33+
Page<Post> findAllActive(
34+
@Param("postType") PostType postType,
35+
@Param("courseId") Long courseId,
36+
@Param("keyword") String keyword,
37+
Pageable pageable
38+
);
39+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.bootsignal.domain.post.service;
2+
3+
import com.bootsignal.domain.course.entity.Course;
4+
import com.bootsignal.domain.course.repository.CourseRepository;
5+
import com.bootsignal.domain.post.dto.PostCreateRequest;
6+
import com.bootsignal.domain.post.dto.PostResponse;
7+
import com.bootsignal.domain.post.dto.PostUpdateRequest;
8+
import com.bootsignal.domain.post.entity.Post;
9+
import com.bootsignal.domain.post.entity.PostType;
10+
import com.bootsignal.domain.post.repository.PostRepository;
11+
import com.bootsignal.domain.user.entity.User;
12+
import com.bootsignal.domain.user.entity.UserRole;
13+
import com.bootsignal.domain.user.repository.UserRepository;
14+
import com.bootsignal.global.exception.BootSignalException;
15+
import com.bootsignal.global.exception.ErrorCode;
16+
import com.bootsignal.global.security.SecurityUtil;
17+
import lombok.RequiredArgsConstructor;
18+
import org.springframework.data.domain.Page;
19+
import org.springframework.data.domain.Pageable;
20+
import org.springframework.stereotype.Service;
21+
import org.springframework.transaction.annotation.Transactional;
22+
23+
@Service
24+
@RequiredArgsConstructor
25+
@Transactional(readOnly = true)
26+
public class PostService {
27+
28+
private final PostRepository postRepository;
29+
private final UserRepository userRepository;
30+
private final CourseRepository courseRepository;
31+
32+
@Transactional
33+
public PostResponse create(PostCreateRequest request) {
34+
User user = getAuthenticatedUser();
35+
36+
Course course = null;
37+
if (request.courseId() != null) {
38+
course = courseRepository.findById(request.courseId())
39+
.orElseThrow(() -> new BootSignalException(ErrorCode.NOT_FOUND, "해당 코스를 찾을 수 없습니다."));
40+
}
41+
42+
Post post = Post.builder()
43+
.user(user)
44+
.course(course)
45+
.postType(request.postType())
46+
.category(request.category())
47+
.title(request.title())
48+
.content(request.content())
49+
.build();
50+
51+
return PostResponse.from(postRepository.save(post));
52+
}
53+
54+
public Page<PostResponse> getList(PostType postType, Long courseId, String keyword, Pageable pageable) {
55+
return postRepository.findAllActive(postType, courseId, keyword, pageable)
56+
.map(PostResponse::from);
57+
}
58+
59+
public PostResponse get(Long postId) {
60+
return PostResponse.from(findActivePost(postId));
61+
}
62+
63+
@Transactional
64+
public PostResponse update(Long postId, PostUpdateRequest request) {
65+
User user = getAuthenticatedUser();
66+
Post post = findActivePost(postId);
67+
68+
if (!post.getUser().getId().equals(user.getId())) {
69+
throw new BootSignalException(ErrorCode.FORBIDDEN);
70+
}
71+
72+
post.update(request.title(), request.content(), request.category());
73+
return PostResponse.from(post);
74+
}
75+
76+
@Transactional
77+
public void delete(Long postId) {
78+
User user = getAuthenticatedUser();
79+
Post post = findActivePost(postId);
80+
81+
if (!post.getUser().getId().equals(user.getId()) && user.getRole() != UserRole.ADMIN) {
82+
throw new BootSignalException(ErrorCode.FORBIDDEN);
83+
}
84+
85+
post.softDelete();
86+
}
87+
88+
private User getAuthenticatedUser() {
89+
String email = SecurityUtil.getCurrentUserEmail();
90+
return userRepository.findByEmail(email)
91+
.orElseThrow(() -> new BootSignalException(ErrorCode.UNAUTHORIZED));
92+
}
93+
94+
private Post findActivePost(Long postId) {
95+
return postRepository.findById(postId)
96+
.filter(p -> p.getDeletedAt() == null && p.isValid())
97+
.orElseThrow(() -> new BootSignalException(ErrorCode.POST_NOT_FOUND));
98+
}
99+
}

src/main/java/com/bootsignal/global/exception/ErrorCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
public enum ErrorCode {
66
BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다."),
77
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "요청 값이 올바르지 않습니다."),
8+
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "로그인이 필요합니다."),
9+
FORBIDDEN(HttpStatus.FORBIDDEN, "FORBIDDEN", "접근 권한이 없습니다."),
810
NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
11+
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", "게시글을 찾을 수 없습니다."),
912
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."),
1013
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "지원하지 않는 미디어 타입입니다."),
1114
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "이미 가입된 이메일입니다."),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.bootsignal.global.security;
2+
3+
import com.bootsignal.global.exception.BootSignalException;
4+
import com.bootsignal.global.exception.ErrorCode;
5+
import org.springframework.security.core.Authentication;
6+
import org.springframework.security.core.context.SecurityContextHolder;
7+
8+
public class SecurityUtil {
9+
10+
private SecurityUtil() {}
11+
12+
public static String getCurrentUserEmail() {
13+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
14+
if (authentication == null || !authentication.isAuthenticated()
15+
|| "anonymousUser".equals(authentication.getPrincipal())) {
16+
throw new BootSignalException(ErrorCode.UNAUTHORIZED);
17+
}
18+
return authentication.getName();
19+
}
20+
}

0 commit comments

Comments
 (0)