Skip to content

Commit a234851

Browse files
authored
Feat: 게시글 북마크/북마크 취소 API 구현 (#210) (#211)
* Feat: 게시글 북마크 API 구현 * Feat: 게시글 북마크 취소 API 구현 * Test: 테스트 작성 * Feat: Swagger 문서 작성
1 parent 829369b commit a234851

File tree

9 files changed

+877
-4
lines changed

9 files changed

+877
-4
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.back.domain.board.post.controller;
2+
3+
import com.back.domain.board.post.dto.PostBookmarkResponse;
4+
import com.back.domain.board.post.service.PostBookmarkService;
5+
import com.back.global.common.dto.RsData;
6+
import com.back.global.security.user.CustomUserDetails;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.*;
11+
12+
@RestController
13+
@RequestMapping("/api/posts/{postId}/bookmark")
14+
@RequiredArgsConstructor
15+
public class PostBookmarkController implements PostBookmarkControllerDocs {
16+
private final PostBookmarkService postBookmarkService;
17+
18+
// 게시글 북마크
19+
@PostMapping
20+
public ResponseEntity<RsData<PostBookmarkResponse>> bookmarkPost(
21+
@PathVariable Long postId,
22+
@AuthenticationPrincipal CustomUserDetails user
23+
) {
24+
PostBookmarkResponse response = postBookmarkService.bookmarkPost(postId, user.getUserId());
25+
return ResponseEntity
26+
.ok(RsData.success(
27+
"게시글 북마크가 등록되었습니다.",
28+
response
29+
));
30+
}
31+
32+
// 게시글 북마크 취소
33+
@DeleteMapping
34+
public ResponseEntity<RsData<PostBookmarkResponse>> cancelBookmarkPost(
35+
@PathVariable Long postId,
36+
@AuthenticationPrincipal CustomUserDetails user
37+
) {
38+
PostBookmarkResponse response = postBookmarkService.cancelBookmarkPost(postId, user.getUserId());
39+
return ResponseEntity
40+
.ok(RsData.success(
41+
"게시글 북마크가 취소되었습니다.",
42+
response
43+
));
44+
}
45+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package com.back.domain.board.post.controller;
2+
3+
import com.back.domain.board.post.dto.PostBookmarkResponse;
4+
import com.back.global.common.dto.RsData;
5+
import com.back.global.security.user.CustomUserDetails;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.media.Content;
8+
import io.swagger.v3.oas.annotations.media.ExampleObject;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.web.bind.annotation.PathVariable;
15+
16+
@Tag(name = "Post Bookmark API", description = "게시글 북마크 등록/취소 API")
17+
public interface PostBookmarkControllerDocs {
18+
19+
@Operation(
20+
summary = "게시글 북마크 등록",
21+
description = "로그인한 사용자가 특정 게시글을 북마크(즐겨찾기)로 등록합니다."
22+
)
23+
@ApiResponses({
24+
@ApiResponse(
25+
responseCode = "200",
26+
description = "게시글 북마크 등록 성공",
27+
content = @Content(mediaType = "application/json",
28+
examples = @ExampleObject(value = """
29+
{
30+
"success": true,
31+
"code": "SUCCESS_200",
32+
"message": "게시글 북마크가 등록되었습니다.",
33+
"data": {
34+
"postId": 101,
35+
"bookmarkCount": 5
36+
}
37+
}
38+
"""))
39+
),
40+
@ApiResponse(
41+
responseCode = "400",
42+
description = "잘못된 요청(파라미터 누락 등)",
43+
content = @Content(mediaType = "application/json",
44+
examples = @ExampleObject(value = """
45+
{
46+
"success": false,
47+
"code": "COMMON_400",
48+
"message": "잘못된 요청입니다.",
49+
"data": null
50+
}
51+
"""))
52+
),
53+
@ApiResponse(
54+
responseCode = "401",
55+
description = "인증 실패 (Access Token 없음/만료/잘못됨)",
56+
content = @Content(mediaType = "application/json",
57+
examples = {
58+
@ExampleObject(name = "토큰 없음", value = """
59+
{
60+
"success": false,
61+
"code": "AUTH_001",
62+
"message": "인증이 필요합니다.",
63+
"data": null
64+
}
65+
"""),
66+
@ExampleObject(name = "잘못된 토큰", value = """
67+
{
68+
"success": false,
69+
"code": "AUTH_002",
70+
"message": "유효하지 않은 액세스 토큰입니다.",
71+
"data": null
72+
}
73+
"""),
74+
@ExampleObject(name = "만료된 토큰", value = """
75+
{
76+
"success": false,
77+
"code": "AUTH_004",
78+
"message": "만료된 액세스 토큰입니다.",
79+
"data": null
80+
}
81+
""")
82+
})
83+
),
84+
@ApiResponse(
85+
responseCode = "404",
86+
description = "존재하지 않는 사용자 또는 게시글",
87+
content = @Content(mediaType = "application/json",
88+
examples = {
89+
@ExampleObject(name = "존재하지 않는 사용자", value = """
90+
{
91+
"success": false,
92+
"code": "USER_001",
93+
"message": "존재하지 않는 사용자입니다.",
94+
"data": null
95+
}
96+
"""),
97+
@ExampleObject(name = "존재하지 않는 게시글", value = """
98+
{
99+
"success": false,
100+
"code": "POST_001",
101+
"message": "존재하지 않는 게시글입니다.",
102+
"data": null
103+
}
104+
""")
105+
})
106+
),
107+
@ApiResponse(
108+
responseCode = "409",
109+
description = "이미 북마크된 게시글",
110+
content = @Content(mediaType = "application/json",
111+
examples = @ExampleObject(value = """
112+
{
113+
"success": false,
114+
"code": "POST_007",
115+
"message": "이미 북마크한 게시글입니다.",
116+
"data": null
117+
}
118+
"""))
119+
),
120+
@ApiResponse(
121+
responseCode = "500",
122+
description = "서버 내부 오류",
123+
content = @Content(mediaType = "application/json",
124+
examples = @ExampleObject(value = """
125+
{
126+
"success": false,
127+
"code": "COMMON_500",
128+
"message": "서버 오류가 발생했습니다.",
129+
"data": null
130+
}
131+
"""))
132+
)
133+
})
134+
ResponseEntity<RsData<PostBookmarkResponse>> bookmarkPost(
135+
@PathVariable Long postId,
136+
@AuthenticationPrincipal CustomUserDetails user
137+
);
138+
139+
@Operation(
140+
summary = "게시글 북마크 취소",
141+
description = "로그인한 사용자가 특정 게시글의 북마크(즐겨찾기)를 취소합니다."
142+
)
143+
@ApiResponses({
144+
@ApiResponse(
145+
responseCode = "200",
146+
description = "게시글 북마크 취소 성공",
147+
content = @Content(mediaType = "application/json",
148+
examples = @ExampleObject(value = """
149+
{
150+
"success": true,
151+
"code": "SUCCESS_200",
152+
"message": "게시글 북마크가 취소되었습니다.",
153+
"data": {
154+
"postId": 101,
155+
"bookmarkCount": 4
156+
}
157+
}
158+
"""))
159+
),
160+
@ApiResponse(
161+
responseCode = "400",
162+
description = "잘못된 요청(파라미터 누락 등)",
163+
content = @Content(mediaType = "application/json",
164+
examples = @ExampleObject(value = """
165+
{
166+
"success": false,
167+
"code": "COMMON_400",
168+
"message": "잘못된 요청입니다.",
169+
"data": null
170+
}
171+
"""))
172+
),
173+
@ApiResponse(
174+
responseCode = "401",
175+
description = "인증 실패 (Access Token 없음/만료/잘못됨)",
176+
content = @Content(mediaType = "application/json",
177+
examples = {
178+
@ExampleObject(name = "토큰 없음", value = """
179+
{
180+
"success": false,
181+
"code": "AUTH_001",
182+
"message": "인증이 필요합니다.",
183+
"data": null
184+
}
185+
"""),
186+
@ExampleObject(name = "잘못된 토큰", value = """
187+
{
188+
"success": false,
189+
"code": "AUTH_002",
190+
"message": "유효하지 않은 액세스 토큰입니다.",
191+
"data": null
192+
}
193+
"""),
194+
@ExampleObject(name = "만료된 토큰", value = """
195+
{
196+
"success": false,
197+
"code": "AUTH_004",
198+
"message": "만료된 액세스 토큰입니다.",
199+
"data": null
200+
}
201+
""")
202+
})
203+
),
204+
@ApiResponse(
205+
responseCode = "404",
206+
description = "존재하지 않는 사용자 / 게시글 / 북마크 내역 없음",
207+
content = @Content(mediaType = "application/json",
208+
examples = {
209+
@ExampleObject(name = "존재하지 않는 사용자", value = """
210+
{
211+
"success": false,
212+
"code": "USER_001",
213+
"message": "존재하지 않는 사용자입니다.",
214+
"data": null
215+
}
216+
"""),
217+
@ExampleObject(name = "존재하지 않는 게시글", value = """
218+
{
219+
"success": false,
220+
"code": "POST_001",
221+
"message": "존재하지 않는 게시글입니다.",
222+
"data": null
223+
}
224+
"""),
225+
@ExampleObject(name = "북마크 내역 없음", value = """
226+
{
227+
"success": false,
228+
"code": "POST_008",
229+
"message": "해당 게시글에 대한 북마크 기록이 없습니다.",
230+
"data": null
231+
}
232+
""")
233+
})
234+
),
235+
@ApiResponse(
236+
responseCode = "500",
237+
description = "서버 내부 오류",
238+
content = @Content(mediaType = "application/json",
239+
examples = @ExampleObject(value = """
240+
{
241+
"success": false,
242+
"code": "COMMON_500",
243+
"message": "서버 오류가 발생했습니다.",
244+
"data": null
245+
}
246+
"""))
247+
)
248+
})
249+
ResponseEntity<RsData<PostBookmarkResponse>> cancelBookmarkPost(
250+
@PathVariable Long postId,
251+
@AuthenticationPrincipal CustomUserDetails user
252+
);
253+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.board.post.dto;
2+
3+
import com.back.domain.board.post.entity.Post;
4+
5+
/**
6+
* 게시글 북마크 응답 DTO
7+
*
8+
* @param postId 게시글 id
9+
* @param bookmarkCount 북마크 수
10+
*/
11+
public record PostBookmarkResponse(
12+
Long postId,
13+
Long bookmarkCount
14+
) {
15+
public static PostBookmarkResponse from(Post post) {
16+
return new PostBookmarkResponse(
17+
post.getId(),
18+
post.getBookmarkCount()
19+
);
20+
}
21+
}

src/main/java/com/back/domain/board/post/entity/PostBookmark.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
import com.back.domain.user.entity.User;
44
import com.back.global.entity.BaseEntity;
5-
import jakarta.persistence.Entity;
6-
import jakarta.persistence.FetchType;
7-
import jakarta.persistence.JoinColumn;
8-
import jakarta.persistence.ManyToOne;
5+
import jakarta.persistence.*;
6+
import lombok.AllArgsConstructor;
97
import lombok.Getter;
108
import lombok.NoArgsConstructor;
119

1210
@Entity
1311
@Getter
1412
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Table(
15+
uniqueConstraints = {
16+
@UniqueConstraint(columnNames = {"post_id", "user_id"})
17+
}
18+
)
1519
public class PostBookmark extends BaseEntity {
1620
@ManyToOne(fetch = FetchType.LAZY)
1721
@JoinColumn(name = "post_id")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.domain.board.post.repository;
2+
3+
import com.back.domain.board.post.entity.PostBookmark;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.Optional;
8+
9+
@Repository
10+
public interface PostBookmarkRepository extends JpaRepository<PostBookmark, Long> {
11+
boolean existsByUserIdAndPostId(Long userId, Long postId);
12+
Optional<PostBookmark> findByUserIdAndPostId(Long userId, Long postId);
13+
}

0 commit comments

Comments
 (0)