Skip to content

Commit cbd8ec4

Browse files
authored
Merge pull request #51 from DMU-DebugVisual/fix/comment-notification
fix: 댓글 작성 403 및 알림 생성 DB 오류 수정
2 parents 333bfdc + 0533217 commit cbd8ec4

File tree

6 files changed

+90
-52
lines changed

6 files changed

+90
-52
lines changed

src/main/java/com/dmu/debug_visual/community/entity/Notification.java

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
@Entity
1010
@Getter
11-
@Setter // 참고: 엔티티에 @Setter를 사용하는 것은 객체의 상태를 쉽게 변경할 수 있어 위험할 수 있습니다. 가능하면 markAsRead() 같은 명확한 메소드를 사용하는 것이 좋습니다.
11+
@Setter
1212
@Builder
1313
@NoArgsConstructor
1414
@AllArgsConstructor
@@ -18,7 +18,7 @@ public class Notification {
1818
private Long id;
1919

2020
@ManyToOne(fetch = FetchType.LAZY, optional = false)
21-
@JoinColumn(name = "receiver_id") // 외래키 컬럼 이름을 명시적으로 지정해주는 것이 좋습니다.
21+
@JoinColumn(name = "receiver_user_num")
2222
private User receiver;
2323

2424
@Column(nullable = false)
@@ -30,17 +30,15 @@ public class Notification {
3030

3131
private LocalDateTime createdAt;
3232

33-
// ✨ [추가] 알림 타입을 구분하기 위한 필드 (예: 댓글, 좋아요)
3433
@Enumerated(EnumType.STRING)
3534
@Column(nullable = false)
3635
private NotificationType notificationType;
3736

3837
public enum NotificationType {
39-
COMMENT, // 댓글
40-
LIKE // 좋아요
38+
COMMENT,
39+
LIKE
4140
}
4241

43-
// ✨ [추가] 알림을 클릭했을 때 이동할 게시물의 ID
4442
@Column
4543
private Long postId;
4644

@@ -49,8 +47,7 @@ public void prePersist() {
4947
this.createdAt = LocalDateTime.now();
5048
}
5149

52-
// 읽음 처리 편의 메소드
5350
public void markAsRead() {
5451
this.isRead = true;
5552
}
56-
}
53+
}

src/main/java/com/dmu/debug_visual/community/repository/CommentRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@
33
import com.dmu.debug_visual.community.entity.Comment;
44
import com.dmu.debug_visual.community.entity.Post;
55
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
68

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

912
public interface CommentRepository extends JpaRepository<Comment, Long> {
1013
List<Comment> findByPostAndParentIsNull(Post post);
14+
15+
@Query("SELECT c FROM Comment c JOIN FETCH c.writer WHERE c.id = :commentId")
16+
Optional<Comment> findByIdWithWriter(@Param("commentId") Long commentId);
17+
18+
@Query("SELECT c FROM Comment c JOIN FETCH c.writer WHERE c.post = :post AND c.parent IS NULL")
19+
List<Comment> findByPostAndParentIsNullWithWriter(@Param("post") Post post);
1120
}

src/main/java/com/dmu/debug_visual/community/repository/PostRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
import com.dmu.debug_visual.community.entity.Post;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
7+
8+
import java.util.Optional;
59

610
public interface PostRepository extends JpaRepository<Post, Long> {
11+
/**
12+
* Post를 조회할 때 writer(User) 객체를 함께 Fetch Join 합니다.
13+
* N+1 문제를 방지하고 LazyInitializationException을 해결합니다.
14+
*/
15+
@Query("SELECT p FROM Post p JOIN FETCH p.writer WHERE p.id = :postId")
16+
Optional<Post> findByIdWithWriter(@Param("postId") Long postId);
717
}

src/main/java/com/dmu/debug_visual/community/service/CommentService.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,26 @@ public class CommentService {
1919
private final PostRepository postRepository;
2020
private final NotificationService notificationService;
2121

22-
Comment parent = null;
22+
// ★ [수정] 싱글톤 서비스에서 상태를 가지는 필드는 스레드 충돌을 일으키므로 삭제
23+
// Comment parent = null;
2324

2425
@Transactional
2526
public Long createComment(CommentRequestDTO dto, User user) {
26-
Post post = postRepository.findById(dto.getPostId())
27+
// ★ [수정] postRepository.findById() -> findByIdWithWriter()로 변경
28+
Post post = postRepository.findByIdWithWriter(dto.getPostId())
2729
.orElseThrow(() -> new RuntimeException("게시글 없음"));
2830

2931
Comment.CommentBuilder builder = Comment.builder()
3032
.post(post)
3133
.writer(user)
3234
.content(dto.getContent());
3335

36+
// 메서드 내의 지역 변수로 사용하는 것이 올바른 방법입니다.
3437
Comment parent = null;
3538

3639
if (dto.getParentId() != null && dto.getParentId() != 0) {
37-
parent = commentRepository.findById(dto.getParentId())
40+
// ★ [수정] commentRepository.findById() -> findByIdWithWriter()로 변경
41+
parent = commentRepository.findByIdWithWriter(dto.getParentId())
3842
.orElseThrow(() -> new RuntimeException("상위 댓글 없음"));
3943

4044
if (parent.getParent() != null) {
@@ -44,16 +48,14 @@ public Long createComment(CommentRequestDTO dto, User user) {
4448
builder.parent(parent);
4549

4650
if (!user.getUserNum().equals(parent.getWriter().getUserNum())) {
47-
// 대댓글 알림 시 postId 추가
4851
notificationService.notify(
4952
parent.getWriter(),
5053
user.getName() + "님이 댓글에 답글을 남겼습니다.",
51-
post.getId() // 게시물 ID 전달
54+
post.getId()
5255
);
5356
}
5457
}
5558

56-
// 게시글 작성자에게 알림 (작성자 본인이 아닌 경우)
5759
if (!user.getUserNum().equals(post.getWriter().getUserNum())) {
5860
notificationService.notify(
5961
post.getWriter(),
@@ -70,7 +72,8 @@ public List<CommentResponseDTO> getComments(Long postId) {
7072
Post post = postRepository.findById(postId)
7173
.orElseThrow(() -> new RuntimeException("게시글 없음"));
7274

73-
List<Comment> rootComments = commentRepository.findByPostAndParentIsNull(post);
75+
// ★ [수정] N+1 문제를 일부 해결하기 위해 writer를 함께 조회하는 메서드 사용
76+
List<Comment> rootComments = commentRepository.findByPostAndParentIsNullWithWriter(post);
7477

7578
return rootComments.stream()
7679
.map(this::mapToDTO)
@@ -83,6 +86,7 @@ private CommentResponseDTO mapToDTO(Comment comment) {
8386
.writer(comment.getWriter().getName())
8487
.content(comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent())
8588
.createdAt(comment.getCreatedAt())
89+
// 참고: 이 부분(children)은 여전히 N+1 문제가 발생할 수 있습니다.
8690
.replies(comment.getChildren().stream()
8791
.map(this::mapToDTO)
8892
.collect(Collectors.toList()))
@@ -93,15 +97,11 @@ public void deleteComment(Long commentId, User user) {
9397
Comment comment = commentRepository.findById(commentId)
9498
.orElseThrow(() -> new RuntimeException("댓글 없음"));
9599

96-
// 본인 확인
97100
if (!comment.getWriter().getUserNum().equals(user.getUserNum())) {
98101
throw new RuntimeException("댓글 삭제 권한 없음");
99102
}
100103

101-
// 논리 삭제
102104
comment.setDeleted(true);
103105
commentRepository.save(comment);
104106
}
105-
106-
107-
}
107+
}

src/main/java/com/dmu/debug_visual/community/service/PostService.java

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.dmu.debug_visual.user.User;
1010
import lombok.RequiredArgsConstructor;
1111
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional; // ★ Transactional 추가
1213

1314
import java.util.List;
1415
import java.util.Optional;
@@ -22,6 +23,7 @@ public class PostService {
2223
private final LikeRepository likeRepository;
2324
private final NotificationService notificationService;
2425

26+
@Transactional // ★ 추가
2527
public Long createPost(PostRequestDTO dto, User user) {
2628
Post post = Post.builder()
2729
.title(dto.getTitle())
@@ -30,9 +32,9 @@ public Long createPost(PostRequestDTO dto, User user) {
3032
.writer(user)
3133
.build();
3234

33-
if (!post.getWriter().getUserNum().equals(user.getUserNum())) {
34-
notificationService.notify(post.getWriter(), user.getName() + "님이 게시글을 좋아합니다.");
35-
}
35+
// ★ [수정] createPost 시 알림 로직 삭제
36+
// (이 로직은 본인(writer)과 user가 같으므로 항상 false가 되어 실행되지 않음)
37+
// if (!post.getWriter().getUserNum().equals(user.getUserNum())) { ... }
3638

3739
return postRepository.save(post).getId();
3840
}
@@ -43,44 +45,45 @@ public List<PostResponseDTO> getAllPosts() {
4345
.id(post.getId())
4446
.title(post.getTitle())
4547
.content(post.getContent())
46-
.writer(post.getWriter().getName()) // 수정: username → name
48+
.writer(post.getWriter().getName())
4749
.tags(post.getTags())
48-
// .imageUrl(post.getImageUrl())
4950
.createdAt(post.getCreatedAt())
5051
.likeCount(likeRepository.countByPost(post))
5152
.build())
5253
.collect(Collectors.toList());
5354
}
5455

5556
public PostResponseDTO getPost(Long id) {
56-
Post post = postRepository.findById(id)
57+
// ★ [수정] N+1 문제 해결을 위해 findByIdWithWriter 사용 권장
58+
// (PostRepository에 findByIdWithWriter가 있다고 가정)
59+
Post post = postRepository.findByIdWithWriter(id)
5760
.orElseThrow(() -> new RuntimeException("Post not found"));
5861
return PostResponseDTO.builder()
5962
.id(post.getId())
6063
.title(post.getTitle())
6164
.content(post.getContent())
62-
.writer(post.getWriter().getName()) // 수정
65+
.writer(post.getWriter().getName())
6366
.tags(post.getTags())
6467
.createdAt(post.getCreatedAt())
6568
.likeCount(likeRepository.countByPost(post))
6669
.build();
6770
}
6871

69-
70-
72+
@Transactional // ★ 추가
7173
public void deletePost(Long id, User user) {
7274
Post post = postRepository.findById(id)
7375
.orElseThrow(() -> new RuntimeException("Post not found"));
7476

75-
if (!post.getWriter().getUserNum().equals(user.getUserNum())) { // 수정
77+
if (!post.getWriter().getUserNum().equals(user.getUserNum())) {
7678
throw new RuntimeException("권한 없음");
7779
}
7880
postRepository.delete(post);
7981
}
8082

81-
83+
@Transactional // ★ 추가
8284
public boolean toggleLike(Long postId, User user) {
83-
Post post = postRepository.findById(postId)
85+
// ★ [수정] N+1 문제 해결을 위해 findByIdWithWriter 사용
86+
Post post = postRepository.findByIdWithWriter(postId)
8487
.orElseThrow(() -> new RuntimeException("게시글 없음"));
8588

8689
Optional<Like> existing = likeRepository.findByPostAndUser(post, user);
@@ -94,6 +97,16 @@ public boolean toggleLike(Long postId, User user) {
9497
.user(user)
9598
.build();
9699
likeRepository.save(like);
100+
101+
// ★ [수정] 좋아요 시 알림 로직 추가 (본인이 본인 글을 좋아하지 않는 경우)
102+
if (!post.getWriter().getUserNum().equals(user.getUserNum())) {
103+
notificationService.notify(
104+
post.getWriter(),
105+
user.getName() + "님이 회원님의 게시글을 좋아합니다.",
106+
post.getId() // postId도 함께 전달
107+
);
108+
}
109+
97110
return true; // 등록됨
98111
}
99112
}
@@ -104,5 +117,4 @@ public long getLikeCount(Long postId) {
104117

105118
return likeRepository.countByPost(post);
106119
}
107-
108-
}
120+
}

src/main/java/com/dmu/debug_visual/config/SecurityConfig.java

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ public class SecurityConfig {
3838

3939
/**
4040
* "dev" 프로파일 (개발 환경)을 위한 보안 설정
41-
* ADMIN 권한 체크를 제외하여 USER 권한으로도 ADMIN API 테스트가 가능합니다.
4241
*/
4342
@Bean
4443
@Profile("dev")
@@ -55,15 +54,21 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce
5554
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
5655
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
5756
.requestMatchers("/api/code/**").permitAll()
57+
// ★ (유지) GET 요청은 누구나 접근 가능
5858
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
5959

60-
// 2. USER 권한이 필요한 경로
61-
.requestMatchers("/api/posts/**").hasRole("USER")
62-
.requestMatchers("/api/notifications/**").hasRole("USER")
63-
.requestMatchers("/api/report/**").hasRole("USER")
64-
.requestMatchers("/api/comments/**").hasRole("USER")
65-
.requestMatchers("/api/files/**").hasRole("USER")
66-
.requestMatchers("/api/collab").hasRole("USER")
60+
// 2. USER 또는 ADMIN 권한이 필요한 경로
61+
// ★ (수정) POST, PUT, DELETE 등 GET 외의 메서드는 USER 또는 ADMIN 권한 필요
62+
.requestMatchers(HttpMethod.POST, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
63+
.requestMatchers(HttpMethod.PUT, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
64+
.requestMatchers(HttpMethod.DELETE, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
65+
.requestMatchers(HttpMethod.PATCH, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
66+
67+
// ★ (수정) GET을 제외한 나머지 /api/notifications/** 경로는 여기서 처리됩니다.
68+
.requestMatchers("/api/notifications/**").hasAnyRole("USER", "ADMIN")
69+
.requestMatchers("/api/report/**").hasAnyRole("USER", "ADMIN")
70+
.requestMatchers("/api/files/**").hasAnyRole("USER", "ADMIN")
71+
.requestMatchers("/api/collab").hasAnyRole("USER", "ADMIN")
6772

6873
// 3. 나머지 모든 요청은 인증된 사용자만 접근 가능 (ADMIN 경로 포함)
6974
.anyRequest().authenticated()
@@ -75,7 +80,6 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce
7580

7681
/**
7782
* "prod", "default" 등 운영 환경을 위한 보안 설정
78-
* ADMIN 경로는 ADMIN 권한이 있는 사용자만 접근 가능합니다.
7983
*/
8084
@Bean
8185
@Profile("!dev")
@@ -92,18 +96,24 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws
9296
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
9397
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
9498
.requestMatchers("/api/code/**").permitAll()
99+
// ★ (유지) GET 요청은 누구나 접근 가능
95100
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
96101

97-
// 2. ADMIN 권한이 필요한 경로
102+
// 2. ADMIN 권한이 필요한 경로 (먼저 정의)
98103
.requestMatchers("/api/admin/**").hasRole("ADMIN")
99104

100-
// 3. USER 권한이 필요한 경로
101-
.requestMatchers("/api/posts/**").hasRole("USER")
102-
.requestMatchers("/api/notifications/**").hasRole("USER")
103-
.requestMatchers("/api/report/**").hasRole("USER")
104-
.requestMatchers("/api/comments/**").hasRole("USER")
105-
.requestMatchers("/api/files/**").hasRole("USER")
106-
.requestMatchers("/api/collab").hasRole("USER")
105+
// 3. USER 또는 ADMIN 권한이 필요한 경로
106+
// ★ (수정) POST, PUT, DELETE 등 GET 외의 메서드는 USER 또는 ADMIN 권한 필요
107+
.requestMatchers(HttpMethod.POST, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
108+
.requestMatchers(HttpMethod.PUT, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
109+
.requestMatchers(HttpMethod.DELETE, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN")
110+
.requestMatchers(HttpMethod.PATCH, "/api/posts/**", "/api/comments/**", "/api/notifications/**").hasAnyRole("USER", "ADMIN") // (PATCH도 명시)
111+
112+
// ★ (수정) GET을 제외한 나머지 /api/notifications/** 경로는 여기서 처리됩니다.
113+
.requestMatchers("/api/notifications/**").hasAnyRole("USER", "ADMIN")
114+
.requestMatchers("/api/report/**").hasAnyRole("USER", "ADMIN")
115+
.requestMatchers("/api/files/**").hasAnyRole("USER", "ADMIN")
116+
.requestMatchers("/api/collab").hasAnyRole("USER", "ADMIN")
107117

108118

109119
// 4. 나머지 모든 요청은 인증된 사용자만 접근 가능
@@ -132,7 +142,7 @@ public WebClient webClient() {
132142
public CorsConfigurationSource corsConfigurationSource() {
133143
CorsConfiguration config = new CorsConfiguration();
134144
config.setAllowedOriginPatterns(List.of("*"));
135-
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
145+
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
136146
config.setAllowedHeaders(List.of("*"));
137147
config.setAllowCredentials(true);
138148

0 commit comments

Comments
 (0)