Skip to content

Commit 96b3bb4

Browse files
authored
Merge pull request #34 from SEMOSAN/feat/#32-free-post
[Feat] 자유게시판 구현
2 parents 1aba2bc + 1ec0652 commit 96b3bb4

21 files changed

Lines changed: 694 additions & 4 deletions

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,14 @@ public enum ErrorStatus implements BaseStatus {
9090
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_404_1", "게시글을 찾을 수 없습니다."),
9191
POST_DELETED(HttpStatus.NOT_FOUND, "POST_404_2", "삭제된 게시글입니다."),
9292
POST_FORBIDDEN(HttpStatus.FORBIDDEN, "POST_403_1", "본인의 게시글만 처리할 수 있습니다."),
93+
POST_CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "POST_400_1", "본문은 비어있을 수 없습니다."),
94+
POST_IMAGE_INDEX_INVALID(HttpStatus.BAD_REQUEST, "POST_400_2", "대표 이미지 인덱스가 잘못되었습니다."),
9395

9496
/**
9597
* Comment (댓글/대댓글)
9698
*/
9799
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CMT_404_1", "댓글을 찾을 수 없습니다."),
100+
COMMENT_DELETED(HttpStatus.NOT_FOUND, "CMT_404_2", "삭제된 댓글입니다."),
98101
COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."),
99102
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다.");
100103

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,16 @@ public enum SuccessStatus implements BaseStatus {
6363
* Post Like (좋아요)
6464
*/
6565
POST_LIKE_TOGGLE_SUCCESS(HttpStatus.OK, "LIKE_200_1", "좋아요 처리에 성공했습니다."),
66-
POST_LIKE_COUNT_SUCCESS(HttpStatus.OK, "LIKE_200_2", "좋아요 수 조회에 성공했습니다.");
66+
POST_LIKE_COUNT_SUCCESS(HttpStatus.OK, "LIKE_200_2", "좋아요 수 조회에 성공했습니다."),
67+
68+
/**
69+
* Free Post (자유게시판 게시글)
70+
*/
71+
FREE_POST_CREATE_SUCCESS(HttpStatus.CREATED, "FPOST_201_1", "자유게시판 게시글이 작성되었습니다."),
72+
FREE_POST_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_1", "자유게시판 게시글 목록 조회에 성공했습니다."),
73+
FREE_POST_MY_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_2", "내 자유게시판 게시글 목록 조회에 성공했습니다."),
74+
FREE_POST_DETAIL_SUCCESS(HttpStatus.OK, "FPOST_200_3", "자유게시판 게시글 상세 조회에 성공했습니다."),
75+
FREE_POST_DELETE_SUCCESS(HttpStatus.OK, "FPOST_200_4", "자유게시판 게시글이 삭제되었습니다.");
6776

6877
private final HttpStatus httpStatus;
6978
private final String code;

src/main/java/com/semosan/api/domain/community/comment/controller/CommentController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.semosan.api.common.response.ApiResponse;
44
import com.semosan.api.common.response.PageResponse;
55
import com.semosan.api.common.status.SuccessStatus;
6+
import com.semosan.api.domain.community.comment.controller.docs.CommentControllerDocs;
67
import com.semosan.api.domain.community.comment.dto.CommentCreateRequest;
78
import com.semosan.api.domain.community.comment.dto.CommentReplyRequest;
89
import com.semosan.api.domain.community.comment.dto.CommentResponse;
@@ -23,7 +24,7 @@
2324
@RestController
2425
@RequestMapping("/api/community")
2526
@RequiredArgsConstructor
26-
public class CommentController {
27+
public class CommentController implements CommentControllerDocs {
2728

2829
private final CommentService commentService;
2930

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.semosan.api.domain.community.comment.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.domain.community.comment.dto.CommentCreateRequest;
6+
import com.semosan.api.domain.community.comment.dto.CommentReplyRequest;
7+
import com.semosan.api.domain.community.comment.dto.CommentResponse;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
import jakarta.validation.Valid;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
18+
import java.util.List;
19+
20+
@Tag(name = "Comment", description = "댓글/대댓글 API (자유게시판/기록공유 공용)")
21+
public interface CommentControllerDocs {
22+
23+
@Operation(summary = "댓글 작성", description = "게시글에 1뎁스 댓글을 작성합니다.")
24+
@ApiResponses({
25+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "작성 성공"),
26+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 또는 유저 없음")
27+
})
28+
ResponseEntity<ApiResponse<CommentResponse>> create(
29+
@AuthenticationPrincipal Long userId,
30+
@PathVariable Long postId,
31+
@Valid @RequestBody CommentCreateRequest request
32+
);
33+
34+
@Operation(
35+
summary = "대댓글 작성",
36+
description = "특정 댓글에 답글을 답니다. 부모가 대댓글이면 1뎁스 댓글로 자동 정규화 (트리 깊이 2 유지)."
37+
)
38+
@ApiResponses({
39+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "작성 성공"),
40+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "부모 댓글이 다른 게시글의 댓글"),
41+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글/댓글/유저 없음")
42+
})
43+
ResponseEntity<ApiResponse<CommentResponse>> reply(
44+
@AuthenticationPrincipal Long userId,
45+
@PathVariable Long postId,
46+
@Valid @RequestBody CommentReplyRequest request
47+
);
48+
49+
@Operation(summary = "댓글 목록", description = "특정 게시글의 1뎁스 댓글 목록 (페이징).")
50+
@ApiResponses({
51+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")
52+
})
53+
ResponseEntity<ApiResponse<PageResponse<CommentResponse>>> getComments(
54+
@PathVariable Long postId,
55+
Pageable pageable
56+
);
57+
58+
@Operation(summary = "대댓글 목록", description = "특정 1뎁스 댓글의 대댓글 목록 (시간순).")
59+
@ApiResponses({
60+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")
61+
})
62+
ResponseEntity<ApiResponse<List<CommentResponse>>> getReplies(@PathVariable Long commentId);
63+
64+
@Operation(summary = "댓글 삭제", description = "본인의 댓글/대댓글을 soft delete 합니다.")
65+
@ApiResponses({
66+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"),
67+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 댓글 아님"),
68+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "댓글 없음")
69+
})
70+
ResponseEntity<ApiResponse<Void>> delete(
71+
@AuthenticationPrincipal Long userId,
72+
@PathVariable Long commentId
73+
);
74+
}

src/main/java/com/semosan/api/domain/community/comment/repository/CommentRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import org.springframework.data.domain.Page;
66
import org.springframework.data.domain.Pageable;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
810

911
import java.util.List;
1012
import java.util.Optional;
@@ -16,4 +18,9 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
1618
Page<Comment> findByPostAndParentIsNullAndDeletedFalse(Post post, Pageable pageable);
1719

1820
List<Comment> findByParentAndDeletedFalseOrderByCreatedAtAsc(Comment parent);
21+
22+
long countByPostAndDeletedFalse(Post post);
23+
24+
@Query("SELECT c.post.id, COUNT(c) FROM Comment c WHERE c.post.id IN :postIds AND c.deleted = false GROUP BY c.post.id")
25+
List<Object[]> countByPostIdsGrouped(@Param("postIds") List<Long> postIds);
1926
}

src/main/java/com/semosan/api/domain/community/like/controller/PostLikeController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.semosan.api.common.response.ApiResponse;
44
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.community.like.controller.docs.PostLikeControllerDocs;
56
import com.semosan.api.domain.community.like.dto.PostLikeToggleResponse;
67
import com.semosan.api.domain.community.like.service.PostLikeService;
78
import lombok.RequiredArgsConstructor;
@@ -12,7 +13,7 @@
1213
@RestController
1314
@RequestMapping("/api/community/posts/{postId}/likes")
1415
@RequiredArgsConstructor
15-
public class PostLikeController {
16+
public class PostLikeController implements PostLikeControllerDocs {
1617

1718
private final PostLikeService postLikeService;
1819

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.semosan.api.domain.community.like.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.domain.community.like.dto.PostLikeToggleResponse;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.PathVariable;
11+
12+
@Tag(name = "Post Like", description = "게시글 좋아요 API (자유게시판/기록공유 공용)")
13+
public interface PostLikeControllerDocs {
14+
15+
@Operation(
16+
summary = "좋아요 토글",
17+
description = "이미 눌렀으면 취소, 안 눌렀으면 좋아요. 응답으로 결과 상태와 총 카운트 반환."
18+
)
19+
@ApiResponses({
20+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "토글 성공"),
21+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 또는 유저 없음")
22+
})
23+
ResponseEntity<ApiResponse<PostLikeToggleResponse>> toggle(
24+
@AuthenticationPrincipal Long userId,
25+
@PathVariable Long postId
26+
);
27+
28+
@Operation(summary = "좋아요 수 조회", description = "특정 게시글의 좋아요 수.")
29+
@ApiResponses({
30+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
31+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음")
32+
})
33+
ResponseEntity<ApiResponse<Long>> getCount(@PathVariable Long postId);
34+
}

src/main/java/com/semosan/api/domain/community/like/repository/PostLikeRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import com.semosan.api.domain.community.post.entity.Post;
55
import com.semosan.api.domain.user.entity.User;
66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
79

10+
import java.util.List;
811
import java.util.Optional;
912

1013
public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
@@ -14,4 +17,7 @@ public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
1417
long countByPost(Post post);
1518

1619
Optional<PostLike> findByPostAndUser(Post post, User user);
20+
21+
@Query("SELECT pl.post.id, COUNT(pl) FROM PostLike pl WHERE pl.post.id IN :postIds GROUP BY pl.post.id")
22+
List<Object[]> countByPostIdsGrouped(@Param("postIds") List<Long> postIds);
1723
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.semosan.api.domain.community.post.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.common.status.SuccessStatus;
6+
import com.semosan.api.domain.community.post.controller.docs.FreePostControllerDocs;
7+
import com.semosan.api.domain.community.post.dto.FreePostCreateRequest;
8+
import com.semosan.api.domain.community.post.dto.FreePostDetailResponse;
9+
import com.semosan.api.domain.community.post.dto.FreePostListResponse;
10+
import com.semosan.api.domain.community.post.service.FreePostService;
11+
import jakarta.validation.Valid;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.data.domain.Pageable;
14+
import org.springframework.data.domain.Sort;
15+
import org.springframework.data.web.PageableDefault;
16+
import org.springframework.http.ResponseEntity;
17+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
18+
import org.springframework.web.bind.annotation.*;
19+
20+
@RestController
21+
@RequestMapping("/api/community/free-posts")
22+
@RequiredArgsConstructor
23+
public class FreePostController implements FreePostControllerDocs {
24+
25+
private final FreePostService freePostService;
26+
27+
@PostMapping
28+
public ResponseEntity<ApiResponse<FreePostDetailResponse>> create(
29+
@AuthenticationPrincipal Long userId,
30+
@Valid @RequestBody FreePostCreateRequest request
31+
) {
32+
33+
return ApiResponse.success(SuccessStatus.FREE_POST_CREATE_SUCCESS, freePostService.create(
34+
userId,
35+
request.title(),
36+
request.content(),
37+
request.imageUrls(),
38+
request.mainImageIndex()
39+
));
40+
}
41+
42+
@GetMapping
43+
public ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> getList(
44+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
45+
) {
46+
return ApiResponse.success(
47+
SuccessStatus.FREE_POST_LIST_SUCCESS,
48+
PageResponse.from(freePostService.getList(pageable))
49+
);
50+
}
51+
52+
@GetMapping("/me")
53+
public ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> getMyList(
54+
@AuthenticationPrincipal Long userId,
55+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
56+
) {
57+
return ApiResponse.success(
58+
SuccessStatus.FREE_POST_MY_LIST_SUCCESS,
59+
PageResponse.from(freePostService.getMyList(userId, pageable))
60+
);
61+
}
62+
63+
@GetMapping("/{postId}")
64+
public ResponseEntity<ApiResponse<FreePostDetailResponse>> getDetail(
65+
@PathVariable Long postId
66+
) {
67+
return ApiResponse.success(
68+
SuccessStatus.FREE_POST_DETAIL_SUCCESS,
69+
freePostService.getDetail(postId)
70+
);
71+
}
72+
73+
@DeleteMapping("/{postId}")
74+
public ResponseEntity<ApiResponse<Void>> delete(
75+
@AuthenticationPrincipal Long userId,
76+
@PathVariable Long postId
77+
) {
78+
freePostService.delete(postId, userId);
79+
return ApiResponse.success(SuccessStatus.FREE_POST_DELETE_SUCCESS);
80+
}
81+
}

src/main/java/com/semosan/api/domain/community/post/controller/RecordPostController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.semosan.api.common.response.ApiResponse;
44
import com.semosan.api.common.response.PageResponse;
55
import com.semosan.api.common.status.SuccessStatus;
6+
import com.semosan.api.domain.community.post.controller.docs.RecordPostControllerDocs;
67
import com.semosan.api.domain.community.post.dto.RecordPostCreateRequest;
78
import com.semosan.api.domain.community.post.dto.RecordPostResponse;
89
import com.semosan.api.domain.community.post.entity.RecordPost;
@@ -20,7 +21,7 @@
2021
@RestController
2122
@RequestMapping("/api/community/record-posts")
2223
@RequiredArgsConstructor
23-
public class RecordPostController {
24+
public class RecordPostController implements RecordPostControllerDocs {
2425

2526
private final RecordPostService recordPostService;
2627

0 commit comments

Comments
 (0)