Skip to content

Commit 9a64bb6

Browse files
authored
Feat: 게시글 생성 API 구현 (#131) (#148)
* Feat: 게시글 생성 API 구현 * Test: 테스트 작성 * Docs: Swagger 문서 작성
1 parent cfa8a0e commit 9a64bb6

File tree

15 files changed

+667
-0
lines changed

15 files changed

+667
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.back.domain.board.controller;
2+
3+
import com.back.domain.board.dto.PostRequest;
4+
import com.back.domain.board.dto.PostResponse;
5+
import com.back.domain.board.service.PostService;
6+
import com.back.global.common.dto.RsData;
7+
import com.back.global.security.user.CustomUserDetails;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.HttpStatus;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
@RestController
16+
@RequestMapping("/api/posts")
17+
@RequiredArgsConstructor
18+
public class PostController implements PostControllerDocs {
19+
private final PostService postService;
20+
21+
// 게시글 생성
22+
@PostMapping
23+
public ResponseEntity<RsData<PostResponse>> createPost(
24+
@RequestBody @Valid PostRequest request,
25+
@AuthenticationPrincipal CustomUserDetails user
26+
) {
27+
PostResponse response = postService.createPost(request, user.getUserId());
28+
return ResponseEntity
29+
.status(HttpStatus.CREATED)
30+
.body(RsData.success(
31+
"게시글이 생성되었습니다.",
32+
response
33+
));
34+
}
35+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.back.domain.board.controller;
2+
3+
import com.back.domain.board.dto.PostRequest;
4+
import com.back.domain.board.dto.PostResponse;
5+
import com.back.global.common.dto.RsData;
6+
import com.back.global.security.user.CustomUserDetails;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.ExampleObject;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.RequestBody;
16+
17+
@Tag(name = "Post API", description = "게시글 관련 API")
18+
public interface PostControllerDocs {
19+
20+
@Operation(
21+
summary = "게시글 생성",
22+
description = "로그인한 사용자가 새 게시글을 작성합니다."
23+
)
24+
@ApiResponses({
25+
@ApiResponse(
26+
responseCode = "201",
27+
description = "게시글 생성 성공",
28+
content = @Content(
29+
mediaType = "application/json",
30+
examples = @ExampleObject(value = """
31+
{
32+
"success": true,
33+
"code": "SUCCESS_200",
34+
"message": "게시글이 생성되었습니다.",
35+
"data": {
36+
"postId": 101,
37+
"author": {
38+
"id": 5,
39+
"nickname": "홍길동"
40+
},
41+
"title": "첫 번째 게시글",
42+
"content": "안녕하세요, 첫 글입니다!",
43+
"categories": [
44+
{ "id": 1, "name": "공지사항" },
45+
{ "id": 2, "name": "자유게시판" }
46+
],
47+
"createdAt": "2025-09-22T10:30:00",
48+
"updatedAt": "2025-09-22T10:30:00"
49+
}
50+
}
51+
""")
52+
)
53+
),
54+
@ApiResponse(
55+
responseCode = "400",
56+
description = "잘못된 요청 (필드 누락 등)",
57+
content = @Content(
58+
mediaType = "application/json",
59+
examples = @ExampleObject(value = """
60+
{
61+
"success": false,
62+
"code": "COMMON_400",
63+
"message": "잘못된 요청입니다.",
64+
"data": null
65+
}
66+
""")
67+
)
68+
),
69+
@ApiResponse(
70+
responseCode = "401",
71+
description = "인증 실패 (토큰 없음/잘못됨/만료)",
72+
content = @Content(
73+
mediaType = "application/json",
74+
examples = {
75+
@ExampleObject(name = "토큰 없음", value = """
76+
{
77+
"success": false,
78+
"code": "AUTH_001",
79+
"message": "인증이 필요합니다.",
80+
"data": null
81+
}
82+
"""),
83+
@ExampleObject(name = "잘못된 토큰", value = """
84+
{
85+
"success": false,
86+
"code": "AUTH_002",
87+
"message": "유효하지 않은 액세스 토큰입니다.",
88+
"data": null
89+
}
90+
"""),
91+
@ExampleObject(name = "만료된 토큰", value = """
92+
{
93+
"success": false,
94+
"code": "AUTH_004",
95+
"message": "만료된 액세스 토큰입니다.",
96+
"data": null
97+
}
98+
""")
99+
}
100+
)
101+
),
102+
@ApiResponse(
103+
responseCode = "404",
104+
description = "존재하지 않는 사용자 또는 카테고리",
105+
content = @Content(
106+
mediaType = "application/json",
107+
examples = {
108+
@ExampleObject(name = "존재하지 않는 사용자", value = """
109+
{
110+
"success": false,
111+
"code": "USER_001",
112+
"message": "존재하지 않는 사용자입니다.",
113+
"data": null
114+
}
115+
"""),
116+
@ExampleObject(name = "존재하지 않는 카테고리", value = """
117+
{
118+
"success": false,
119+
"code": "POST_003",
120+
"message": "존재하지 않는 카테고리입니다.",
121+
"data": null
122+
}
123+
""")
124+
}
125+
)
126+
),
127+
@ApiResponse(
128+
responseCode = "500",
129+
description = "서버 내부 오류",
130+
content = @Content(
131+
mediaType = "application/json",
132+
examples = @ExampleObject(value = """
133+
{
134+
"success": false,
135+
"code": "COMMON_500",
136+
"message": "서버 오류가 발생했습니다.",
137+
"data": null
138+
}
139+
""")
140+
)
141+
)
142+
})
143+
ResponseEntity<RsData<PostResponse>> createPost(
144+
@RequestBody PostRequest request,
145+
@AuthenticationPrincipal CustomUserDetails user
146+
);
147+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.board.dto;
2+
3+
import com.back.domain.user.entity.User;
4+
5+
/**
6+
* 작성자 응답 DTO
7+
*
8+
* @param id 작성자 ID
9+
* @param nickname 작성자 닉네임
10+
*/
11+
public record AuthorResponse(
12+
Long id,
13+
String nickname
14+
) {
15+
public static AuthorResponse from(User user) {
16+
return new AuthorResponse(
17+
user.getId(),
18+
user.getUserProfile().getNickname()
19+
);
20+
}
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.board.dto;
2+
3+
import com.back.domain.board.entity.PostCategory;
4+
5+
/**
6+
* 카테고리 응답 DTO
7+
*
8+
* @param id 카테고리 ID
9+
* @param name 카테고리 이름
10+
*/
11+
public record CategoryResponse(
12+
Long id,
13+
String name
14+
) {
15+
public static CategoryResponse from(PostCategory category) {
16+
return new CategoryResponse(
17+
category.getId(),
18+
category.getName()
19+
);
20+
}
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.back.domain.board.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
import java.util.List;
6+
7+
/**
8+
* 게시글 생성 및 수정을 위한 요청 DTO
9+
*
10+
* @param title 게시글 제목
11+
* @param content 게시글 내용
12+
* @param categoryIds 카테고리 ID 리스트
13+
*/
14+
public record PostRequest(
15+
@NotBlank String title,
16+
@NotBlank String content,
17+
List<Long> categoryIds
18+
) {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.back.domain.board.dto;
2+
3+
import com.back.domain.board.entity.Post;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
7+
/**
8+
* 게시글 응답 DTO
9+
*
10+
* @param postId 게시글 ID
11+
* @param author 작성자 정보
12+
* @param title 게시글 제목
13+
* @param content 게시글 내용
14+
* @param categories 게시글 카테고리 목록
15+
* @param createdAt 게시글 생성 일시
16+
* @param updatedAt 게시글 수정 일시
17+
*/
18+
public record PostResponse(
19+
Long postId,
20+
AuthorResponse author,
21+
String title,
22+
String content,
23+
List<CategoryResponse> categories,
24+
LocalDateTime createdAt,
25+
LocalDateTime updatedAt
26+
) {
27+
public static PostResponse from(Post post) {
28+
return new PostResponse(
29+
post.getId(),
30+
AuthorResponse.from(post.getUser()),
31+
post.getTitle(),
32+
post.getContent(),
33+
post.getCategories().stream()
34+
.map(CategoryResponse::from)
35+
.toList(),
36+
post.getCreatedAt(),
37+
post.getUpdatedAt()
38+
);
39+
}
40+
}

src/main/java/com/back/domain/board/entity/Post.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,28 @@ public class Post extends BaseEntity {
3232

3333
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
3434
private List<Comment> comments = new ArrayList<>();
35+
36+
// -------------------- 생성자 --------------------
37+
public Post(User user, String title, String content) {
38+
this.user = user;
39+
this.title = title;
40+
this.content = content;
41+
}
42+
43+
// -------------------- 비즈니스 메서드 --------------------
44+
// 카테고리 업데이트
45+
public void updateCategories(List<PostCategory> categories) {
46+
this.postCategoryMappings.clear();
47+
categories.forEach(category ->
48+
this.postCategoryMappings.add(new PostCategoryMapping(this, category))
49+
);
50+
}
51+
52+
// -------------------- 헬퍼 메서드 --------------------
53+
// 게시글에 연결된 카테고리 목록 조회
54+
public List<PostCategory> getCategories() {
55+
return postCategoryMappings.stream()
56+
.map(PostCategoryMapping::getCategory)
57+
.toList();
58+
}
3559
}

src/main/java/com/back/domain/board/entity/PostCategory.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import lombok.Getter;
88
import lombok.NoArgsConstructor;
99

10+
import java.util.ArrayList;
1011
import java.util.List;
1112

1213
@Entity
@@ -17,4 +18,10 @@ public class PostCategory extends BaseEntity {
1718

1819
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
1920
private List<PostCategoryMapping> postCategoryMappings;
21+
22+
// -------------------- 생성자 --------------------
23+
public PostCategory(String name) {
24+
this.name = name;
25+
this.postCategoryMappings = new ArrayList<>();
26+
}
2027
}

src/main/java/com/back/domain/board/entity/PostCategoryMapping.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,10 @@ public class PostCategoryMapping {
1818
@ManyToOne(fetch = FetchType.LAZY)
1919
@JoinColumn(name = "category_id")
2020
private PostCategory category;
21+
22+
// -------------------- 생성자 --------------------
23+
public PostCategoryMapping(Post post, PostCategory category) {
24+
this.post = post;
25+
this.category = category;
26+
}
2127
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.domain.board.repository;
2+
3+
import com.back.domain.board.entity.PostCategory;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
@Repository
8+
public interface PostCategoryRepository extends JpaRepository<PostCategory, Long> {
9+
}

0 commit comments

Comments
 (0)