Skip to content

Commit f51cb87

Browse files
authored
Merge branch 'develop' into feat/#39-onboarding-access-control
2 parents 7cceaae + bb8b855 commit f51cb87

40 files changed

Lines changed: 1569 additions & 3 deletions

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,15 @@ k8s/postgres/secret.yaml
4949
# OMC
5050
.omc
5151

52+
# Claude Code
53+
.claude/
54+
5255
# Firebase
5356
src/main/resources/firebase/
5457

5558
## docs
56-
docs/**
59+
docs/**
60+
61+
## Local data / scripts (산림청 수집 데이터, 수집 스크립트)
62+
data/
63+
scripts/

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,30 @@ public enum ErrorStatus implements BaseStatus {
9494
* Withdraw
9595
*/
9696
KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_3", "카카오 연동 해제에 실패했습니다."),
97-
APPLE_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 토큰 폐기에 실패했습니다.");
97+
APPLE_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 토큰 폐기에 실패했습니다."),
98+
99+
/**
100+
* Hiking (등산 기록)
101+
*/
102+
HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."),
103+
HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."),
104+
105+
/**
106+
* Post (게시글 공통)
107+
*/
108+
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_404_1", "게시글을 찾을 수 없습니다."),
109+
POST_DELETED(HttpStatus.NOT_FOUND, "POST_404_2", "삭제된 게시글입니다."),
110+
POST_FORBIDDEN(HttpStatus.FORBIDDEN, "POST_403_1", "본인의 게시글만 처리할 수 있습니다."),
111+
POST_CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "POST_400_1", "본문은 비어있을 수 없습니다."),
112+
POST_IMAGE_INDEX_INVALID(HttpStatus.BAD_REQUEST, "POST_400_2", "대표 이미지 인덱스가 잘못되었습니다."),
113+
114+
/**
115+
* Comment (댓글/대댓글)
116+
*/
117+
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CMT_404_1", "댓글을 찾을 수 없습니다."),
118+
COMMENT_DELETED(HttpStatus.NOT_FOUND, "CMT_404_2", "삭제된 댓글입니다."),
119+
COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."),
120+
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다.");
98121

99122
private final HttpStatus httpStatus;
100123
private final String code;

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,40 @@ public enum SuccessStatus implements BaseStatus {
5858
*/
5959
FCM_TOKEN_REGISTER_SUCCESS(HttpStatus.OK, "FCM_200_1", "FCM 토큰이 등록되었습니다."),
6060
FCM_TOKEN_DELETE_SUCCESS(HttpStatus.OK, "FCM_200_2", "FCM 토큰이 삭제되었습니다."),
61-
NOTIFICATION_SEND_SUCCESS(HttpStatus.OK, "NOTIF_200_1", "알림 발송 요청에 성공했습니다.");
61+
NOTIFICATION_SEND_SUCCESS(HttpStatus.OK, "NOTIF_200_1", "알림 발송 요청에 성공했습니다."),
62+
63+
/**
64+
* Record Post (기록공유 게시글)
65+
*/
66+
RECORD_POST_CREATE_SUCCESS(HttpStatus.CREATED, "RPOST_201_1", "기록공유 게시글이 작성되었습니다."),
67+
RECORD_POST_LIST_SUCCESS(HttpStatus.OK, "RPOST_200_1", "기록공유 게시글 목록 조회에 성공했습니다."),
68+
RECORD_POST_MY_LIST_SUCCESS(HttpStatus.OK, "RPOST_200_2", "내 기록공유 게시글 목록 조회에 성공했습니다."),
69+
RECORD_POST_DETAIL_SUCCESS(HttpStatus.OK, "RPOST_200_3", "기록공유 게시글 상세 조회에 성공했습니다."),
70+
RECORD_POST_DELETE_SUCCESS(HttpStatus.OK, "RPOST_200_4", "기록공유 게시글이 삭제되었습니다."),
71+
72+
/**
73+
* Comment (댓글/대댓글)
74+
*/
75+
COMMENT_CREATE_SUCCESS(HttpStatus.CREATED, "CMT_201_1", "댓글이 작성되었습니다."),
76+
COMMENT_REPLY_SUCCESS(HttpStatus.CREATED, "CMT_201_2", "대댓글이 작성되었습니다."),
77+
COMMENT_LIST_SUCCESS(HttpStatus.OK, "CMT_200_1", "댓글 목록 조회에 성공했습니다."),
78+
COMMENT_REPLY_LIST_SUCCESS(HttpStatus.OK, "CMT_200_2", "대댓글 목록 조회에 성공했습니다."),
79+
COMMENT_DELETE_SUCCESS(HttpStatus.OK, "CMT_200_3", "댓글이 삭제되었습니다."),
80+
81+
/**
82+
* Post Like (좋아요)
83+
*/
84+
POST_LIKE_TOGGLE_SUCCESS(HttpStatus.OK, "LIKE_200_1", "좋아요 처리에 성공했습니다."),
85+
POST_LIKE_COUNT_SUCCESS(HttpStatus.OK, "LIKE_200_2", "좋아요 수 조회에 성공했습니다."),
86+
87+
/**
88+
* Free Post (자유게시판 게시글)
89+
*/
90+
FREE_POST_CREATE_SUCCESS(HttpStatus.CREATED, "FPOST_201_1", "자유게시판 게시글이 작성되었습니다."),
91+
FREE_POST_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_1", "자유게시판 게시글 목록 조회에 성공했습니다."),
92+
FREE_POST_MY_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_2", "내 자유게시판 게시글 목록 조회에 성공했습니다."),
93+
FREE_POST_DETAIL_SUCCESS(HttpStatus.OK, "FPOST_200_3", "자유게시판 게시글 상세 조회에 성공했습니다."),
94+
FREE_POST_DELETE_SUCCESS(HttpStatus.OK, "FPOST_200_4", "자유게시판 게시글이 삭제되었습니다.");
6295

6396
private final HttpStatus httpStatus;
6497
private final String code;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.semosan.api.domain.community.comment.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.comment.controller.docs.CommentControllerDocs;
7+
import com.semosan.api.domain.community.comment.dto.CommentCreateRequest;
8+
import com.semosan.api.domain.community.comment.dto.CommentReplyRequest;
9+
import com.semosan.api.domain.community.comment.dto.CommentResponse;
10+
import com.semosan.api.domain.community.comment.entity.Comment;
11+
import com.semosan.api.domain.community.comment.service.CommentService;
12+
import jakarta.validation.Valid;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.data.domain.Page;
15+
import org.springframework.data.domain.Pageable;
16+
import org.springframework.data.domain.Sort;
17+
import org.springframework.data.web.PageableDefault;
18+
import org.springframework.http.ResponseEntity;
19+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
20+
import org.springframework.web.bind.annotation.*;
21+
22+
import java.util.List;
23+
24+
@RestController
25+
@RequestMapping("/api/community")
26+
@RequiredArgsConstructor
27+
public class CommentController implements CommentControllerDocs {
28+
29+
private final CommentService commentService;
30+
31+
@PostMapping("/posts/{postId}/comments")
32+
public ResponseEntity<ApiResponse<CommentResponse>> create(
33+
@AuthenticationPrincipal Long userId,
34+
@PathVariable Long postId,
35+
@Valid @RequestBody CommentCreateRequest request
36+
) {
37+
Comment comment = commentService.create(postId, userId, request.content());
38+
return ApiResponse.success(SuccessStatus.COMMENT_CREATE_SUCCESS, CommentResponse.from(comment));
39+
}
40+
41+
@PostMapping("/posts/{postId}/comments/replies")
42+
public ResponseEntity<ApiResponse<CommentResponse>> reply(
43+
@AuthenticationPrincipal Long userId,
44+
@PathVariable Long postId,
45+
@Valid @RequestBody CommentReplyRequest request
46+
) {
47+
Comment reply = commentService.reply(
48+
postId, userId, request.parentId(), request.mentionedUserId(), request.content()
49+
);
50+
return ApiResponse.success(SuccessStatus.COMMENT_REPLY_SUCCESS, CommentResponse.from(reply));
51+
}
52+
53+
@GetMapping("/posts/{postId}/comments")
54+
public ResponseEntity<ApiResponse<PageResponse<CommentResponse>>> getComments(
55+
@PathVariable Long postId,
56+
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable
57+
) {
58+
Page<CommentResponse> page = commentService.getCommentsByPost(postId, pageable).map(CommentResponse::from);
59+
return ApiResponse.success(SuccessStatus.COMMENT_LIST_SUCCESS, PageResponse.from(page));
60+
}
61+
62+
@GetMapping("/comments/{commentId}/replies")
63+
public ResponseEntity<ApiResponse<List<CommentResponse>>> getReplies(
64+
@PathVariable Long commentId
65+
) {
66+
List<CommentResponse> replies = commentService.getReplies(commentId).stream()
67+
.map(CommentResponse::from)
68+
.toList();
69+
return ApiResponse.success(SuccessStatus.COMMENT_REPLY_LIST_SUCCESS, replies);
70+
}
71+
72+
@DeleteMapping("/comments/{commentId}")
73+
public ResponseEntity<ApiResponse<Void>> delete(
74+
@AuthenticationPrincipal Long userId,
75+
@PathVariable Long commentId
76+
) {
77+
commentService.delete(commentId, userId);
78+
return ApiResponse.success(SuccessStatus.COMMENT_DELETE_SUCCESS);
79+
}
80+
}
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+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.semosan.api.domain.community.comment.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record CommentCreateRequest(
6+
@NotBlank String content
7+
) {
8+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.semosan.api.domain.community.comment.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record CommentReplyRequest(
7+
@NotNull Long parentId,
8+
Long mentionedUserId,
9+
@NotBlank String content
10+
) {
11+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.semosan.api.domain.community.comment.dto;
2+
3+
import com.semosan.api.domain.community.comment.entity.Comment;
4+
import com.semosan.api.domain.community.post.dto.AuthorResponse;
5+
6+
import java.time.LocalDateTime;
7+
8+
public record CommentResponse(
9+
Long id,
10+
AuthorResponse author,
11+
String content,
12+
Long parentId,
13+
AuthorResponse mentionedUser,
14+
LocalDateTime createdAt
15+
) {
16+
public static CommentResponse from(Comment comment) {
17+
return new CommentResponse(
18+
comment.getId(),
19+
AuthorResponse.from(comment.getAuthor()),
20+
comment.getContent(),
21+
comment.getParent() != null ? comment.getParent().getId() : null,
22+
comment.getMentionedUser() != null ? AuthorResponse.from(comment.getMentionedUser()) : null,
23+
comment.getCreatedAt()
24+
);
25+
}
26+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.semosan.api.domain.community.comment.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.community.post.entity.Post;
5+
import com.semosan.api.domain.user.entity.User;
6+
import jakarta.persistence.*;
7+
import lombok.*;
8+
9+
@Table(name = "comments")
10+
@Getter
11+
@Entity
12+
@Builder(access = AccessLevel.PROTECTED)
13+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
public class Comment extends BaseEntity {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@ManyToOne(fetch = FetchType.LAZY)
22+
@JoinColumn(name = "post_id", nullable = false)
23+
private Post post;
24+
25+
@ManyToOne(fetch = FetchType.LAZY)
26+
@JoinColumn(name = "author_id", nullable = false)
27+
private User author;
28+
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "parent_id")
31+
private Comment parent;
32+
33+
@ManyToOne(fetch = FetchType.LAZY)
34+
@JoinColumn(name = "mentioned_user_id")
35+
private User mentionedUser;
36+
37+
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
38+
private String content;
39+
40+
@Column(name = "is_deleted", nullable = false)
41+
@Builder.Default
42+
private boolean deleted = false;
43+
44+
public static Comment create(Post post, User author, String content) {
45+
return Comment.builder()
46+
.post(post)
47+
.author(author)
48+
.content(content)
49+
.build();
50+
}
51+
52+
public static Comment reply(
53+
Post post,
54+
User author,
55+
Comment parent,
56+
User mentionedUser,
57+
String content
58+
) {
59+
return Comment.builder()
60+
.post(post)
61+
.author(author)
62+
.parent(parent)
63+
.mentionedUser(mentionedUser)
64+
.content(content)
65+
.build();
66+
}
67+
68+
public void softDelete() {
69+
this.deleted = true;
70+
}
71+
72+
public boolean isReply() {
73+
return this.parent != null;
74+
}
75+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.semosan.api.domain.community.comment.repository;
2+
3+
import com.semosan.api.domain.community.comment.entity.Comment;
4+
import com.semosan.api.domain.community.post.entity.Post;
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+
import java.util.List;
12+
import java.util.Optional;
13+
14+
public interface CommentRepository extends JpaRepository<Comment, Long> {
15+
16+
Optional<Comment> findByIdAndDeletedFalse(Long id);
17+
18+
Page<Comment> findByPostAndParentIsNullAndDeletedFalse(Post post, Pageable pageable);
19+
20+
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);
26+
}

0 commit comments

Comments
 (0)