Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.bootsignal.domain.post.controller;

import com.bootsignal.domain.post.dto.PostCreateRequest;
import com.bootsignal.domain.post.dto.PostResponse;
import com.bootsignal.domain.post.dto.PostUpdateRequest;
import com.bootsignal.domain.post.entity.PostType;
import com.bootsignal.domain.post.service.PostService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

private final PostService postService;

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PostResponse create(@RequestBody @Valid PostCreateRequest request) {
return postService.create(request);
}

@GetMapping
public Page<PostResponse> getList(
@RequestParam(required = false) PostType postType,
@RequestParam(required = false) Long courseId,
@RequestParam(required = false) String keyword,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
return postService.getList(postType, courseId, keyword, pageable);
}

@GetMapping("/{postId}")
public PostResponse get(@PathVariable Long postId) {
return postService.get(postId);
}

@PatchMapping("/{postId}")
public PostResponse update(
@PathVariable Long postId,
@RequestBody @Valid PostUpdateRequest request
) {
return postService.update(postId, request);
}

@DeleteMapping("/{postId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long postId) {
postService.delete(postId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.bootsignal.domain.post.dto;

import com.bootsignal.domain.post.entity.PostType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record PostCreateRequest(
Long courseId,
@NotNull PostType postType,
String category,
@NotBlank String title,
@NotBlank String content
) {}
33 changes: 33 additions & 0 deletions src/main/java/com/bootsignal/domain/post/dto/PostResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.bootsignal.domain.post.dto;

import com.bootsignal.domain.post.entity.Post;
import com.bootsignal.domain.post.entity.PostType;
import java.time.LocalDateTime;

public record PostResponse(
Long postId,
Long userId,
String userNickname,
Long courseId,
PostType postType,
String category,
String title,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static PostResponse from(Post post) {
return new PostResponse(
post.getId(),
post.getUser().getId(),
post.getUser().getNickname(),
post.getCourse() != null ? post.getCourse().getId() : null,
post.getPostType(),
post.getCategory(),
post.getTitle(),
post.getContent(),
post.getCreatedAt(),
post.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.bootsignal.domain.post.dto;

public record PostUpdateRequest(
String title,
String content,
String category
) {}
76 changes: 76 additions & 0 deletions src/main/java/com/bootsignal/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.bootsignal.domain.post.entity;

import com.bootsignal.domain.course.entity.Course;
import com.bootsignal.domain.user.entity.User;
import com.bootsignal.global.entity.BaseEntity;
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;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostType postType;

private String category;

@Column(nullable = false)
private String title;

@Column(nullable = false, columnDefinition = "TEXT")
private String content;

@Column(nullable = false)
private boolean isValid = true;

private LocalDateTime deletedAt;

@Builder
private Post(User user, Course course, PostType postType, String category, String title, String content) {
this.user = user;
this.course = course;
this.postType = postType;
this.category = category;
this.title = title;
this.content = content;
}

public void update(String title, String content, String category) {
if (title != null) this.title = title;
if (content != null) this.content = content;
if (category != null) this.category = category;
}

public void softDelete() {
this.deletedAt = LocalDateTime.now();
this.isValid = false;
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/bootsignal/domain/post/entity/PostType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.bootsignal.domain.post.entity;

public enum PostType {
PROJECT_RECRUIT, QNA, ARTICLE, BOARD
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.bootsignal.domain.post.repository;

import com.bootsignal.domain.post.entity.Post;
import com.bootsignal.domain.post.entity.PostType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository<Post, Long> {

@Query(
value = """
SELECT p FROM Post p
JOIN FETCH p.user u
LEFT JOIN FETCH p.course c
WHERE p.deletedAt IS NULL
AND p.isValid = true
AND (:postType IS NULL OR p.postType = :postType)
AND (:courseId IS NULL OR (p.course IS NOT NULL AND p.course.id = :courseId))
AND (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%)
""",
countQuery = """
SELECT count(p) FROM Post p
WHERE p.deletedAt IS NULL
AND p.isValid = true
AND (:postType IS NULL OR p.postType = :postType)
AND (:courseId IS NULL OR (p.course IS NOT NULL AND p.course.id = :courseId))
AND (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%)
"""
)
Page<Post> findAllActive(
@Param("postType") PostType postType,
@Param("courseId") Long courseId,
@Param("keyword") String keyword,
Pageable pageable
);
}
99 changes: 99 additions & 0 deletions src/main/java/com/bootsignal/domain/post/service/PostService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.bootsignal.domain.post.service;

import com.bootsignal.domain.course.entity.Course;
import com.bootsignal.domain.course.repository.CourseRepository;
import com.bootsignal.domain.post.dto.PostCreateRequest;
import com.bootsignal.domain.post.dto.PostResponse;
import com.bootsignal.domain.post.dto.PostUpdateRequest;
import com.bootsignal.domain.post.entity.Post;
import com.bootsignal.domain.post.entity.PostType;
import com.bootsignal.domain.post.repository.PostRepository;
import com.bootsignal.domain.user.entity.User;
import com.bootsignal.domain.user.entity.UserRole;
import com.bootsignal.domain.user.repository.UserRepository;
import com.bootsignal.global.exception.BootSignalException;
import com.bootsignal.global.exception.ErrorCode;
import com.bootsignal.global.security.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {

private final PostRepository postRepository;
private final UserRepository userRepository;
private final CourseRepository courseRepository;

@Transactional
public PostResponse create(PostCreateRequest request) {
User user = getAuthenticatedUser();

Course course = null;
if (request.courseId() != null) {
course = courseRepository.findById(request.courseId())
.orElseThrow(() -> new BootSignalException(ErrorCode.NOT_FOUND, "해당 코스를 찾을 수 없습니다."));
}

Post post = Post.builder()
.user(user)
.course(course)
.postType(request.postType())
.category(request.category())
.title(request.title())
.content(request.content())
.build();

return PostResponse.from(postRepository.save(post));
}

public Page<PostResponse> getList(PostType postType, Long courseId, String keyword, Pageable pageable) {
return postRepository.findAllActive(postType, courseId, keyword, pageable)
.map(PostResponse::from);
}

public PostResponse get(Long postId) {
return PostResponse.from(findActivePost(postId));
}

@Transactional
public PostResponse update(Long postId, PostUpdateRequest request) {
User user = getAuthenticatedUser();
Post post = findActivePost(postId);

if (!post.getUser().getId().equals(user.getId())) {
throw new BootSignalException(ErrorCode.FORBIDDEN);
}

post.update(request.title(), request.content(), request.category());
return PostResponse.from(post);
}

@Transactional
public void delete(Long postId) {
User user = getAuthenticatedUser();
Post post = findActivePost(postId);

if (!post.getUser().getId().equals(user.getId()) && user.getRole() != UserRole.ADMIN) {
throw new BootSignalException(ErrorCode.FORBIDDEN);
}

post.softDelete();
}

private User getAuthenticatedUser() {
String email = SecurityUtil.getCurrentUserEmail();
return userRepository.findByEmail(email)
.orElseThrow(() -> new BootSignalException(ErrorCode.UNAUTHORIZED));
}

private Post findActivePost(Long postId) {
return postRepository.findById(postId)
.filter(p -> p.getDeletedAt() == null && p.isValid())
.orElseThrow(() -> new BootSignalException(ErrorCode.POST_NOT_FOUND));
}
}
3 changes: 3 additions & 0 deletions src/main/java/com/bootsignal/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
public enum ErrorCode {
BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다."),
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "요청 값이 올바르지 않습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "로그인이 필요합니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "FORBIDDEN", "접근 권한이 없습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", "게시글을 찾을 수 없습니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."),
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "지원하지 않는 미디어 타입입니다."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "이미 가입된 이메일입니다."),
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/bootsignal/global/security/SecurityUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.bootsignal.global.security;

import com.bootsignal.global.exception.BootSignalException;
import com.bootsignal.global.exception.ErrorCode;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

public class SecurityUtil {

private SecurityUtil() {}

public static String getCurrentUserEmail() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| "anonymousUser".equals(authentication.getPrincipal())) {
throw new BootSignalException(ErrorCode.UNAUTHORIZED);
}
return authentication.getName();
}
}
Loading