Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import naughty.tuzamate.domain.comment.dto.CommentResDTO;
import naughty.tuzamate.domain.comment.entity.Comment;
import naughty.tuzamate.domain.comment.repository.CommentRepository;
import naughty.tuzamate.domain.comment.service.FCMService;
import naughty.tuzamate.domain.notification.entity.Notification;
import naughty.tuzamate.domain.notification.service.NotificationService;
import naughty.tuzamate.domain.post.code.PostErrorCode;
Expand All @@ -21,6 +20,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

@Service
@Transactional
@RequiredArgsConstructor
Expand All @@ -29,7 +32,6 @@ public class CommentCommandServiceImpl implements CommentCommandService {
private final UserRepository userRepository;
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final FCMService fcmService;
private final NotificationService notificationService;

@Override
Expand All @@ -53,42 +55,42 @@ public CommentResDTO.CreateCommentResponseDTO createComment(

commentRepository.save(comment);

// 알림 제공 서비스
User postWriter = post.getUser();
User parentWriter = parent != null ? parent.getUser() : null;

String content = comment.getContent();
String preview = (content != null && content.length() >= 15) ? content.substring(0, 15) + "..." : (content != null ? content : "");
String title = commentWriter.getNickname() + "님이 댓글을 남겼습니다.";
// 알림 대상 계산
Set<Long> receivers = new LinkedHashSet<>();

// 게시글 작성자가 아닌 사용자가 댓글을 단 경우
if (parent == null && !postWriter.getId().equals(commentWriter.getId())) {
fcmService.sendNotification(title, preview, postWriter.getFcmToken());

Notification notification = Notification.builder()
.title(title)
.content(preview)
.isRead(false)
.targetId(postId)
.receiver(postWriter)
.build();
Long postWriterId = post.getUser().getId();
Long parentWriterId = parent != null ? parent.getUser().getId() : null;
Long writerId = commentWriter.getId();

notificationService.saveNotification(notification);
if (!postWriterId.equals(writerId)) {
receivers.add(postWriterId);
}
if (parentWriterId != null && !parentWriterId.equals(writerId) && !parentWriterId.equals(postWriterId)) {
receivers.add(parentWriterId);
}

// 댓글 작성자에게 대댓글이 달린 경우(부모 댓글 작성자와 대댓글 작성자가 다른 경우)
if (parent != null && !parentWriter.getId().equals(commentWriter.getId())) {
fcmService.sendNotification(title, preview, parentWriter.getFcmToken());

// 알림 저장 + (커밋 후) FCM 발송
for (Long receiverId : receivers) {
Notification notification = Notification.builder()
.title(title)
.content(preview)
.receiver(User.builder().id(receiverId).build())
.title("새 댓글이 달렸습니다")
.content(comment.getContent())
.targetId(post.getId())
.isRead(false)
.targetId(postId)
.receiver(parentWriter)
.build();

notificationService.saveNotification(notification);
Map<String, String> data = Map.of(
"type", "COMMENT",
"postId", String.valueOf(post.getId()),
"commentId", String.valueOf(comment.getId()),
"deeplink", "myapp://post/" + post.getId() + "?commentId=" + comment.getId()
);

notificationService.saveAndDispatch(
notification, receiverId,
"새 댓글 알림", comment.getContent(),
data, true, "OPEN_POST" // 클릭 액션 키(프론트에 맞추기)
);
}

return CommentConverter.toCreateCommentResponseDTO(comment);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(indexes = {
@Index(name="idx_notification_receiver_id_id_desc", columnList = "receiver_id,id")
})
public class Notification extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotBlank
@Column(nullable = false)
private String title;

@NotBlank
@Column(nullable = false)
private String content;
Comment on lines +26 to 30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Regression risk: @notblank removed; DB-only null check allows empty strings

@column(nullable = false) prevents null but permits "". If non-empty title/content are required, reintroduce Bean Validation (and keep DB nullability).

-    @Column(nullable = false)
-    private String title;
+    @NotBlank
+    @Column(nullable = false)
+    private String title;
 
-    @Column(nullable = false)
-    private String content;
+    @NotBlank
+    @Column(nullable = false)
+    private String content;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Column(nullable = false)
private String title;
@NotBlank
@Column(nullable = false)
private String content;
@NotBlank
@Column(nullable = false)
private String title;
@NotBlank
@Column(nullable = false)
private String content;
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/domain/notification/entity/Notification.java
around lines 26 to 30, the fields title and content only have @Column(nullable =
false) which prevents nulls but allows empty strings; reintroduce Bean
Validation by annotating both fields with @NotBlank (from
javax.validation.constraints or jakarta.validation.constraints depending on
project), keep the existing nullable = false on the @Column, and ensure the
entity is validated on persist/update (e.g., via @Valid at service/controller
boundaries or enabling JPA validation) so empty strings are rejected before
hitting the DB.


private boolean isRead;
@Builder.Default
private boolean isRead = false;;

@Column(name = "target_id")
private Long targetId; // 관련 postId
Expand All @@ -35,8 +39,8 @@ public class Notification extends BaseTimeEntity {
@JoinColumn(name = "receiver_id")
private User receiver;

public void updateIsRead() {
this.isRead = !isRead;
}
public void markAsRead() { this.isRead = true; }

public void markAsUnread() { this.isRead = false; }
}

Original file line number Diff line number Diff line change
@@ -1,29 +1,102 @@
package naughty.tuzamate.domain.notification.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import naughty.tuzamate.domain.notification.code.NotificationErrorCode;
import naughty.tuzamate.domain.notification.converter.NotificationConverter;
import naughty.tuzamate.domain.notification.dto.NotificationResDTO;
import naughty.tuzamate.domain.notification.entity.Notification;
import naughty.tuzamate.domain.notification.repository.NotificationRepository;
import naughty.tuzamate.global.error.GeneralErrorCode;
import naughty.tuzamate.domain.pushToken.FcmSender;
import naughty.tuzamate.domain.pushToken.repository.PushTokenRepository;
import naughty.tuzamate.global.error.exception.CustomException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {

private final NotificationRepository notificationRepository;
private final PushTokenRepository pushTokenRepository;
private final FcmSender fcmSender;
private final PlatformTransactionManager txManager;

// 알림 저장 + 트랜잭션 커밋 후 FCM 발송
@Transactional
public void saveNotification(Notification notification) {
public void saveAndDispatch(Notification notification, Long receiverId,
String title, String body, Map<String,String> data,
boolean highPriority, String clickAction) {

// 알림 저장
notificationRepository.save(notification);

// 커밋 이후 작업 등록
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
List<String> tokens = pushTokenRepository.findActiveTokensByUserId(receiverId);
if (tokens.isEmpty()) {
log.info("No active tokens for receiverId={}", receiverId);
return;
}

try {
FcmSender.BatchResult br = fcmSender.sendToTokens(
tokens, title, body, data, highPriority, clickAction
);

// 커밋 이후, 영구 실패 토큰만 비활성화
if (!br.failedTokens().isEmpty()) {
deactivateTokensRequiresNew(br.failedTokens());
log.info("Deactivated {} tokens (receiverId={})",
br.failedTokens().size(), receiverId);
}

log.info("FCM sent: success={}, failure={}, receiverId={}",
br.success(), br.failure(), receiverId);

} catch (Exception e) {
// 커밋 이후 예외이므로 본 트랜잭션에 영향 없음
log.warn("FCM send failed afterCommit. receiverId={}", receiverId, e);
}
}
});
}

/**
* REQUIRES_NEW 트랜잭션으로 토큰 비활성화(벌크).
* (self-invocation 회피: @Transactional 사용 대신 프로그래매틱 트랜잭션)
*/
private void deactivateTokensRequiresNew(List<String> tokens) {
if (tokens == null || tokens.isEmpty()) return;

List<String> distinct = tokens.stream().distinct().collect(Collectors.toList());

DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

TransactionStatus status = txManager.getTransaction(def);
try {
pushTokenRepository.bulkDeactivateByTokens(distinct);
txManager.commit(status);
} catch (Exception ex) {
txManager.rollback(status);
log.warn("Failed to deactivate tokens in REQUIRES_NEW tx (size={})", distinct.size(), ex);
}
}

public NotificationResDTO.NotificationCursorResDTO getNotificationList(Long userId, Long cursor, int size) {
Expand Down Expand Up @@ -70,7 +143,7 @@ public NotificationResDTO.toNotificationResDTO updateIsRead(Long notificationId,
Notification notification = notificationRepository.findByIdAndReceiverId(notificationId, userId)
.orElseThrow(() -> new CustomException(NotificationErrorCode.NOTIFICATION_NOT_FOUND));

notification.updateIsRead();
notification.markAsRead();

return NotificationConverter.toNotificationDTO(notification);
}
Expand All @@ -82,5 +155,4 @@ public void deleteNotification(Long notificationId, Long userId) {

notificationRepository.delete(notification);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public CustomResponse<PostResDTO.CreatePostResponseDTO> createPost(
return CustomResponse.onSuccess(PostSuccessCode.POST_CREATED, resDTO);
}

@GetMapping("/boards/{boardType}/posts/{postId}")
@GetMapping("/boards/posts/{postId}")
@Operation(summary = "단일 게시글 조회", description = "단일 게시글을 조회합니다.")
public CustomResponse<PostResDTO.PostDTO> getPost(
@PathVariable Long postId,
Expand All @@ -59,7 +59,7 @@ public CustomResponse<PostResDTO.PostPreviewListDTO> getPostList(
return CustomResponse.onSuccess(PostSuccessCode.POST_OK, resDTO);
}

@PatchMapping("/boards/{boardType}/posts/{postId}")
@PatchMapping("/boards/posts/{postId}")
@Operation(summary = "게시글 수정", description = "게시글을 수정합니다.")
public CustomResponse<PostResDTO.UpdatePostResponseDTO> updatePost(
@PathVariable Long postId,
Expand All @@ -71,7 +71,7 @@ public CustomResponse<PostResDTO.UpdatePostResponseDTO> updatePost(
return CustomResponse.onSuccess(GeneralSuccessCode.OK, resDTO);
}

@DeleteMapping("/boards/{boardType}/posts/{postId}")
@DeleteMapping("/boards/posts/{postId}")
@Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다.")
public CustomResponse<PostResDTO.DeletePostResponseDTO> deletePost(
@PathVariable Long postId,
Expand Down
Loading
Loading