diff --git a/build.gradle b/build.gradle index 93a80ed2..e16fe671 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,8 @@ dependencies { //firebase implementation 'com.google.firebase:firebase-admin:9.2.0' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' + //S3 implementation group: 'io.awspring.cloud', name: 'spring-cloud-starter-aws', version: '2.4.4' implementation group: 'io.awspring.cloud', name: 'spring-cloud-starter-aws-secrets-manager-config', version: '2.4.4' diff --git a/src/main/java/com/capstone/BnagFer/domain/board/service/BoardService.java b/src/main/java/com/capstone/BnagFer/domain/board/service/BoardService.java index 4c7620eb..0a98bd6d 100644 --- a/src/main/java/com/capstone/BnagFer/domain/board/service/BoardService.java +++ b/src/main/java/com/capstone/BnagFer/domain/board/service/BoardService.java @@ -13,14 +13,14 @@ import com.capstone.BnagFer.domain.board.repository.BoardImageRepository; import com.capstone.BnagFer.domain.board.repository.BoardLikeRepository; import com.capstone.BnagFer.domain.board.repository.BoardRepository; -import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; -import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.domain.notification.event.CommentCreatedEvent; import com.capstone.BnagFer.global.common.ApiResponse; import com.capstone.BnagFer.global.common.ErrorCode; import com.capstone.BnagFer.global.util.RedisUtil; import com.capstone.BnagFer.global.util.s3.S3Provider; import com.capstone.BnagFer.global.util.s3.dto.S3UploadRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -41,7 +41,7 @@ public class BoardService { private final BoardImageRepository boardImageRepository; private final RedisUtil redisUtil; private final S3Provider s3Provider; - private final FcmNotificationService fcmNotificationService; + private final ApplicationEventPublisher eventPublisher; public CreateBoardResponseDto createBoard(BoardRequestDto request, User user, List images) { @@ -161,41 +161,11 @@ public CommentResponseDto createComment(Long boardId, CreateCommentRequestDto re redisUtil.boardSaveCommentCount(boardId, commentCount); // FCM 알림 전송 - if (parentCommentId == null) { - // 일반 댓글인 경우 - sendCommentNotification(board.getUser(), user); - } else { - // 대댓글인 경우 - sendReplyNotification(board.getUser(), parent.getUser(), user); - } + sendNotification(user, board, parentCommentId, parent); return CommentResponseDto.from(comment); } - private void sendCommentNotification(User boardOwner, User commenter) { - - FcmNotificationRequestDto alarmRequestDto = new FcmNotificationRequestDto( - "새 댓글", - commenter.getProfile().getNickname() + "님이 회원님의 게시글에 댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(alarmRequestDto, boardOwner.getId()); - - } - - private void sendReplyNotification(User boardOwner, User parentCommentOwner, User replier) { - FcmNotificationRequestDto boardOwnerAlarmDto = new FcmNotificationRequestDto( - "새 댓글", - replier.getProfile().getNickname() + "님이 회원님의 게시글에 댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(boardOwnerAlarmDto, boardOwner.getId()); - - FcmNotificationRequestDto parentCommentOwnerAlarmDto = new FcmNotificationRequestDto( - "새 대댓글", - replier.getProfile().getNickname() + "님이 회원님의 댓글에 대댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(parentCommentOwnerAlarmDto, parentCommentOwner.getId()); - } - public CommentResponseDto updateComment(Long commentId, UpdateCommentRequestDto request, User user) { Comment comment = boardCommentRepository.findById(commentId).orElseThrow(() -> new BoardExceptionHandler(ErrorCode.COMMENT_NOT_FOUND)); @@ -216,6 +186,27 @@ public void deleteComment(Long commentId, User user) { } comment.deleteComment(); } + + private void sendNotification(User user, Board board, Long parentCommentId, Comment parent) { + if (parentCommentId == null) { + // 새 댓글인 경우 + if (!user.getId().equals(board.getUser().getId())) { + // 게시글 작성자가 댓글을 단 경우가 아닐 때만 알림 발송 + eventPublisher.publishEvent(new CommentCreatedEvent(user.getId(), board.getUser().getId(), CommentCreatedEvent.NotificationType.NEW_COMMENT)); + } + } else { + // 대댓글인 경우 + if (!user.getId().equals(board.getUser().getId())) { + // 게시글 작성자가 대댓글을 단 경우가 아닐 때 게시글 작성자에게 알림 + eventPublisher.publishEvent(new CommentCreatedEvent(user.getId(), board.getUser().getId(), CommentCreatedEvent.NotificationType.NEW_COMMENT)); + } + + if (!user.getId().equals(parent.getUser().getId()) && !parent.getUser().getId().equals(board.getUser().getId())) { + // 부모 댓글 작성자가 대댓글을 단 경우가 아니고, 부모 댓글 작성자가 게시글 작성자가 아닐 때 부모 댓글 작성자에게 알림 + eventPublisher.publishEvent(new CommentCreatedEvent(user.getId(), parent.getUser().getId(), CommentCreatedEvent.NotificationType.NEW_REPLY)); + } + } + } } diff --git a/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamInviteService.java b/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamInviteService.java index 00412667..2a87aa31 100644 --- a/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamInviteService.java +++ b/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamInviteService.java @@ -4,8 +4,7 @@ import com.capstone.BnagFer.domain.accounts.entity.User; import com.capstone.BnagFer.domain.accounts.repository.ProfileJpaRepository; import com.capstone.BnagFer.domain.accounts.service.account.AccountsCommonService; -import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; -import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.domain.notification.event.TeamInviteCreatedEvent; import com.capstone.BnagFer.domain.myteam.dto.request.TeamInviteRequestDto; import com.capstone.BnagFer.domain.myteam.dto.response.TeamInviteResponseDto; import com.capstone.BnagFer.domain.myteam.dto.response.TeamMembersResponseDto; @@ -17,6 +16,7 @@ import com.capstone.BnagFer.domain.myteam.repository.TeamRepository; import com.capstone.BnagFer.global.common.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +29,7 @@ public class TeamInviteService { private final TeamRepository teamRepository; private final TeamMembersRepository teamMembersRepository; private final ProfileJpaRepository profileJpaRepository; - private final FcmNotificationService fcmNotificationService; + private final ApplicationEventPublisher eventPublisher; public TeamInviteResponseDto inviteTeamMembers(TeamInviteRequestDto request, User inviter) { Profile profile = profileJpaRepository.findByNickname(request.nickName()); @@ -66,14 +66,10 @@ public TeamInviteResponseDto inviteTeamMembers(TeamInviteRequestDto request, Use // 초대 엔티티를 생성하고 저장한다 TeamInvite teamInvite = request.toEntity(invitedUser, team, inviter); - teamInviteRepository.save(teamInvite); + TeamInvite savedInvite = teamInviteRepository.save(teamInvite); // FCM 알림 전송 - FcmNotificationRequestDto alarmRequestDto = new FcmNotificationRequestDto( - "팀 초대", - inviter.getProfile().getNickname() + "님이 " + team.getTeamName() + " 팀에 초대하였습니다." - ); - fcmNotificationService.sendAlarm(alarmRequestDto, invitedUser.getId()); + eventPublisher.publishEvent(new TeamInviteCreatedEvent(savedInvite)); // 초대 정보를 응답 DTO로 변환하여 반환한다 return TeamInviteResponseDto.from(teamInvite); diff --git a/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamMembersService.java b/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamMembersService.java index b8e691df..1eb5cc1e 100644 --- a/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamMembersService.java +++ b/src/main/java/com/capstone/BnagFer/domain/myteam/service/TeamMembersService.java @@ -1,8 +1,7 @@ package com.capstone.BnagFer.domain.myteam.service; import com.capstone.BnagFer.domain.accounts.entity.User; -import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; -import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.domain.notification.event.PositionAllocatedEvent; import com.capstone.BnagFer.domain.myteam.dto.request.TeamMemberPositionRequestDto; import com.capstone.BnagFer.domain.myteam.dto.response.TeamMemberPositionResponseDto; import com.capstone.BnagFer.domain.myteam.entity.Team; @@ -14,6 +13,7 @@ import com.capstone.BnagFer.domain.tactic.entity.Position; import com.capstone.BnagFer.global.common.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,7 +23,7 @@ public class TeamMembersService { private final TeamMembersRepository teamMembersRepository; private final TeamRepository teamRepository; - private final FcmNotificationService fcmNotificationService; + private final ApplicationEventPublisher eventPublisher; public TeamMemberPositionResponseDto allocatePosition(TeamMemberPositionRequestDto request, Long teamId, Long memberId, User user) { Team team = teamRepository.findById(teamId).orElseThrow(() -> new TeamExceptionHandler(ErrorCode.TEAM_NOT_FOUND)); @@ -50,11 +50,7 @@ public TeamMemberPositionResponseDto allocatePosition(TeamMemberPositionRequestD teamMembersRepository.save(teamMember); // FCM 알림 전송 - FcmNotificationRequestDto alarmRequestDto = new FcmNotificationRequestDto( - "포지션 할당", - team.getTeamName() + " 팀에서 " + requestedPosition.name() + " 포지션이 할당되었습니다." - ); - fcmNotificationService.sendAlarm(alarmRequestDto, teamMember.getUser().getId()); + eventPublisher.publishEvent(new PositionAllocatedEvent(teamMember, requestedPosition)); } } else throw new TeamMemberExceptionHandler(ErrorCode.CANNOT_FIND_TEAMMEMBER); diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/dto/NotificationResponseDto.java b/src/main/java/com/capstone/BnagFer/domain/notification/dto/NotificationResponseDto.java index e3c39585..54823df4 100644 --- a/src/main/java/com/capstone/BnagFer/domain/notification/dto/NotificationResponseDto.java +++ b/src/main/java/com/capstone/BnagFer/domain/notification/dto/NotificationResponseDto.java @@ -5,7 +5,6 @@ import java.time.LocalDateTime; - @Builder public record NotificationResponseDto( String title, diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/entity/FcmNotification.java b/src/main/java/com/capstone/BnagFer/domain/notification/entity/FcmNotification.java index f7a611b4..b2be6db8 100644 --- a/src/main/java/com/capstone/BnagFer/domain/notification/entity/FcmNotification.java +++ b/src/main/java/com/capstone/BnagFer/domain/notification/entity/FcmNotification.java @@ -10,7 +10,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Table(name = "board") +@Table(name = "notification") public class FcmNotification extends BaseEntity { @Id diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/entity/NotificationTemplate.java b/src/main/java/com/capstone/BnagFer/domain/notification/entity/NotificationTemplate.java new file mode 100644 index 00000000..10c5f65a --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/entity/NotificationTemplate.java @@ -0,0 +1,29 @@ +package com.capstone.BnagFer.domain.notification.entity; + +import lombok.Getter; + +import java.util.Map; + +public enum NotificationTemplate { + NEW_COMMENT("새 댓글", "{nickname}님이 회원님의 {contentType}에 댓글을 달았습니다."), + NEW_REPLY("새 대댓글", "{nickname}님이 회원님의 댓글에 대댓글을 달았습니다."), + POSITION_ALLOCATED("포지션 할당", "{teamName} 팀에서 {position} 포지션이 할당되었습니다."), + TEAM_INVITE("팀 초대", "{inviterNickname}님이 {teamName} 팀에 초대하였습니다."); + + @Getter + private final String title; + private final String bodyTemplate; + + NotificationTemplate(String title, String bodyTemplate) { + this.title = title; + this.bodyTemplate = bodyTemplate; + } + + public String getBody(Map params) { + String body = bodyTemplate; + for (Map.Entry entry : params.entrySet()) { + body = body.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return body; + } +} diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/event/CommentCreatedEvent.java b/src/main/java/com/capstone/BnagFer/domain/notification/event/CommentCreatedEvent.java new file mode 100644 index 00000000..422eb864 --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/event/CommentCreatedEvent.java @@ -0,0 +1,12 @@ +package com.capstone.BnagFer.domain.notification.event; + +public record CommentCreatedEvent( + Long authorId, + Long recipientId, + NotificationType notificationType +) { + public enum NotificationType { + NEW_COMMENT, + NEW_REPLY + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/event/PositionAllocatedEvent.java b/src/main/java/com/capstone/BnagFer/domain/notification/event/PositionAllocatedEvent.java new file mode 100644 index 00000000..fd9b744e --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/event/PositionAllocatedEvent.java @@ -0,0 +1,20 @@ +package com.capstone.BnagFer.domain.notification.event; + +import com.capstone.BnagFer.domain.myteam.entity.TeamMember; +import com.capstone.BnagFer.domain.tactic.entity.Position; + +public record PositionAllocatedEvent( + Long teamId, + Long memberId, + String teamName, + Position position +) { + public PositionAllocatedEvent(TeamMember teamMember, Position position) { + this( + teamMember.getTeam().getId(), + teamMember.getId(), + teamMember.getTeam().getTeamName(), + position + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/event/TacticCommentCreatedEvent.java b/src/main/java/com/capstone/BnagFer/domain/notification/event/TacticCommentCreatedEvent.java new file mode 100644 index 00000000..3145f6b1 --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/event/TacticCommentCreatedEvent.java @@ -0,0 +1,12 @@ +package com.capstone.BnagFer.domain.notification.event; + +public record TacticCommentCreatedEvent( + Long authorId, + Long recipientId, + NotificationType notificationType +) { + public enum NotificationType { + NEW_COMMENT, + NEW_REPLY + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/event/TeamInviteCreatedEvent.java b/src/main/java/com/capstone/BnagFer/domain/notification/event/TeamInviteCreatedEvent.java new file mode 100644 index 00000000..39a80078 --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/event/TeamInviteCreatedEvent.java @@ -0,0 +1,23 @@ +package com.capstone.BnagFer.domain.notification.event; + +import com.capstone.BnagFer.domain.myteam.entity.TeamInvite; + +public record TeamInviteCreatedEvent( + Long inviteId, + Long teamId, + Long inviterId, + Long invitedUserId, + String teamName, + String inviterNickname +) { + public TeamInviteCreatedEvent(TeamInvite teamInvite) { + this( + teamInvite.getId(), + teamInvite.getTeam().getId(), + teamInvite.getInviter().getId(), + teamInvite.getInvitedUser().getId(), + teamInvite.getTeam().getTeamName(), + teamInvite.getInviter().getProfile().getNickname() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/BaseNotificationEventHandler.java b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/BaseNotificationEventHandler.java new file mode 100644 index 00000000..2a906b2b --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/BaseNotificationEventHandler.java @@ -0,0 +1,38 @@ +package com.capstone.BnagFer.domain.notification.eventHandler; + +import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; +import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; + +/* +일관성: 모든 이벤트 핸들러가 동일한 구조를 가지게 되어 코드의 일관성이 향상됩니다. +의존성 주입: 생성자를 통한 의존성 주입으로 테스트가 용이해지고, 컴파일 시점에 의존성 문제를 발견할 수 있습니다. +유지보수성: 각 핸들러의 책임이 명확해지고, 공통 로직은 부모 클래스에서 처리되어 유지보수가 쉬워집니다. +확장성: 새로운 이벤트 유형을 추가할 때 이 패턴을 따라 쉽게 구현할 수 있습니다. + */ + + +public abstract class BaseNotificationEventHandler { + protected final FcmNotificationService fcmNotificationService; + + protected BaseNotificationEventHandler(FcmNotificationService fcmNotificationService) { + this.fcmNotificationService = fcmNotificationService; + } + + @Async + @EventListener + public void handle(Object event) { + if (getSupportedEventType().isInstance(event)) { + @SuppressWarnings("unchecked") + T typedEvent = (T) event; + FcmNotificationRequestDto requestDto = createNotificationRequest(typedEvent); + Long recipientId = getRecipientId(typedEvent); + fcmNotificationService.sendAlarm(requestDto, recipientId); + } + } + + protected abstract FcmNotificationRequestDto createNotificationRequest(T event); + protected abstract Long getRecipientId(T event); + protected abstract Class getSupportedEventType(); +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/CommentEventHandler.java b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/CommentEventHandler.java new file mode 100644 index 00000000..4492db3b --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/CommentEventHandler.java @@ -0,0 +1,53 @@ +package com.capstone.BnagFer.domain.notification.eventHandler; + +import com.capstone.BnagFer.domain.accounts.repository.UserJpaRepository; +import com.capstone.BnagFer.domain.board.exception.BoardExceptionHandler; +import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; +import com.capstone.BnagFer.domain.notification.entity.NotificationTemplate; +import com.capstone.BnagFer.domain.notification.event.CommentCreatedEvent; +import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.global.common.ErrorCode; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class CommentEventHandler extends BaseNotificationEventHandler { + private final UserJpaRepository userJpaRepository; + + public CommentEventHandler(FcmNotificationService fcmNotificationService, + UserJpaRepository userJpaRepository) { + super(fcmNotificationService); + this.userJpaRepository = userJpaRepository; + } + + @Override + protected Class getSupportedEventType() { + return CommentCreatedEvent.class; + } + + @Override + protected FcmNotificationRequestDto createNotificationRequest(CommentCreatedEvent event) { + String commenterNickname = userJpaRepository.findById(event.authorId()) + .orElseThrow(() -> new BoardExceptionHandler(ErrorCode.USER_NOT_FOUND)) + .getProfile().getNickname(); + + Map params = new HashMap<>(); + params.put("nickname", commenterNickname); + params.put("contentType", "게시글"); + + NotificationTemplate template = event.notificationType() == CommentCreatedEvent.NotificationType.NEW_COMMENT ? + NotificationTemplate.NEW_COMMENT : NotificationTemplate.NEW_REPLY; + + return new FcmNotificationRequestDto( + template.getTitle(), + template.getBody(params) + ); + } + + @Override + protected Long getRecipientId(CommentCreatedEvent event) { + return event.recipientId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/PositionAllocatedEventHandler.java b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/PositionAllocatedEventHandler.java new file mode 100644 index 00000000..82fcc699 --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/PositionAllocatedEventHandler.java @@ -0,0 +1,48 @@ +package com.capstone.BnagFer.domain.notification.eventHandler; + +import com.capstone.BnagFer.domain.myteam.exception.TeamMemberExceptionHandler; +import com.capstone.BnagFer.domain.myteam.repository.TeamMembersRepository; +import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; +import com.capstone.BnagFer.domain.notification.entity.NotificationTemplate; +import com.capstone.BnagFer.domain.notification.event.PositionAllocatedEvent; +import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.global.common.ErrorCode; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class PositionAllocatedEventHandler extends BaseNotificationEventHandler { + private final TeamMembersRepository teamMembersRepository; + + public PositionAllocatedEventHandler(FcmNotificationService fcmNotificationService, + TeamMembersRepository teamMembersRepository) { + super(fcmNotificationService); + this.teamMembersRepository = teamMembersRepository; + } + + @Override + protected Class getSupportedEventType() { + return PositionAllocatedEvent.class; + } + + @Override + protected FcmNotificationRequestDto createNotificationRequest(PositionAllocatedEvent event) { + Map params = new HashMap<>(); + params.put("teamName", event.teamName()); + params.put("position", event.position().name()); + + return new FcmNotificationRequestDto( + NotificationTemplate.POSITION_ALLOCATED.getTitle(), + NotificationTemplate.POSITION_ALLOCATED.getBody(params) + ); + } + + @Override + protected Long getRecipientId(PositionAllocatedEvent event) { + return teamMembersRepository.findById(event.memberId()) + .orElseThrow(() -> new TeamMemberExceptionHandler(ErrorCode.CANNOT_FIND_TEAMMEMBER)) + .getUser().getId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TacticCommentEventHandler.java b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TacticCommentEventHandler.java new file mode 100644 index 00000000..b7d7edcd --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TacticCommentEventHandler.java @@ -0,0 +1,53 @@ +package com.capstone.BnagFer.domain.notification.eventHandler; + +import com.capstone.BnagFer.domain.accounts.repository.UserJpaRepository; +import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; +import com.capstone.BnagFer.domain.notification.entity.NotificationTemplate; +import com.capstone.BnagFer.domain.notification.event.TacticCommentCreatedEvent; +import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.domain.tactic.exception.TacticExceptionHandler; +import com.capstone.BnagFer.global.common.ErrorCode; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class TacticCommentEventHandler extends BaseNotificationEventHandler { + private final UserJpaRepository userJpaRepository; + + public TacticCommentEventHandler(FcmNotificationService fcmNotificationService, + UserJpaRepository userJpaRepository) { + super(fcmNotificationService); + this.userJpaRepository = userJpaRepository; + } + + @Override + protected Class getSupportedEventType() { + return TacticCommentCreatedEvent.class; + } + + @Override + protected FcmNotificationRequestDto createNotificationRequest(TacticCommentCreatedEvent event) { + String commenterNickname = userJpaRepository.findById(event.authorId()) + .orElseThrow(() -> new TacticExceptionHandler(ErrorCode.USER_NOT_FOUND)) + .getProfile().getNickname(); + + Map params = new HashMap<>(); + params.put("nickname", commenterNickname); + params.put("contentType", "전술"); + + NotificationTemplate template = event.notificationType() == TacticCommentCreatedEvent.NotificationType.NEW_COMMENT ? + NotificationTemplate.NEW_COMMENT : NotificationTemplate.NEW_REPLY; + + return new FcmNotificationRequestDto( + template.getTitle(), + template.getBody(params) + ); + } + + @Override + protected Long getRecipientId(TacticCommentCreatedEvent event) { + return event.recipientId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TeamInviteEventHandler.java b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TeamInviteEventHandler.java new file mode 100644 index 00000000..03e751ac --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/domain/notification/eventHandler/TeamInviteEventHandler.java @@ -0,0 +1,40 @@ +package com.capstone.BnagFer.domain.notification.eventHandler; + +import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; +import com.capstone.BnagFer.domain.notification.entity.NotificationTemplate; +import com.capstone.BnagFer.domain.notification.event.TeamInviteCreatedEvent; +import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class TeamInviteEventHandler extends BaseNotificationEventHandler { + + public TeamInviteEventHandler(FcmNotificationService fcmNotificationService) { + super(fcmNotificationService); + } + + @Override + protected Class getSupportedEventType() { + return TeamInviteCreatedEvent.class; + } + + @Override + protected FcmNotificationRequestDto createNotificationRequest(TeamInviteCreatedEvent event) { + Map params = new HashMap<>(); + params.put("inviterNickname", event.inviterNickname()); + params.put("teamName", event.teamName()); + + return new FcmNotificationRequestDto( + NotificationTemplate.TEAM_INVITE.getTitle(), + NotificationTemplate.TEAM_INVITE.getBody(params) + ); + } + + @Override + protected Long getRecipientId(TeamInviteCreatedEvent event) { + return event.invitedUserId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/notification/service/FcmNotificationService.java b/src/main/java/com/capstone/BnagFer/domain/notification/service/FcmNotificationService.java index 0cb66e4f..1ec83e3e 100644 --- a/src/main/java/com/capstone/BnagFer/domain/notification/service/FcmNotificationService.java +++ b/src/main/java/com/capstone/BnagFer/domain/notification/service/FcmNotificationService.java @@ -13,47 +13,89 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.concurrent.TimeUnit; + @RequiredArgsConstructor @Service -@Transactional public class FcmNotificationService { private final FirebaseMessaging firebaseMessaging; private final UserJpaRepository userJpaRepository; private final RedisUtil redisUtil; private final FcmNotificationRepository fcmNotificationRepository; + private final RedissonClient redissonClient; + + private static final String LOCK_PREFIX = "fcm_notification:"; + + // 항상 새로운 트랜잭션에서 실행 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public FcmNotification saveNotification(FcmNotificationRequestDto requestDto, User user) { + String lockKey = LOCK_PREFIX + user.getId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + // 5초 동안 락 획득 시도, 획득 성공 시 최대 10초 동안 락 유지 => 데드락 방지 + + if (lock.tryLock(5, 10, TimeUnit.SECONDS)) { + try { + FcmNotification fcmNotification = requestDto.toEntity(user); + return fcmNotificationRepository.save(fcmNotification); + } finally { + // 작업 완료 후 즉시 락 해제 시도 + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } else { + throw new FcmNotificationExceptionHandler(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FcmNotificationExceptionHandler(ErrorCode.NOTIFICATION_SAVE_FAILED); + } + } + // 트랜잭션 없이 실행 + // 알림 저장과 FCM 전송을 분리하여, FCM 전송 실패 시에도 알림이 저장되도록 + @Transactional(propagation = Propagation.NOT_SUPPORTED) public String sendAlarm(FcmNotificationRequestDto requestDto, Long userId) { User user = userJpaRepository.findById(userId) .orElseThrow(() -> new FcmNotificationExceptionHandler(ErrorCode.USER_NOT_FOUND)); - FcmNotification fcmNotification = requestDto.toEntity(user); - fcmNotificationRepository.save(fcmNotification); + // 알림 저장 (별도의 트랜잭션) + saveNotification(requestDto, user); String fcmToken = redisUtil.getFCMToken(user.getEmail()); - if (fcmToken != null) { - Notification notification = Notification.builder() - .setTitle(requestDto.title()) - .setBody(requestDto.body()) - .build(); - - Message message = Message.builder() - .setToken(fcmToken) - .setNotification(notification) - .build(); - - try { - firebaseMessaging.send(message); - return "알림을 성공적으로 전송했습니다. targetUserId = " + userId; - } catch (FirebaseMessagingException e) { - e.printStackTrace(); - throw new FcmNotificationExceptionHandler(ErrorCode.FIREBASE_MESSAGING_ERROR); - } - } else { + if (fcmToken == null) { throw new FcmNotificationExceptionHandler(ErrorCode.FIREBASE_TOKEN_NOT_FOUND); } + + try { + sendFcmNotification(requestDto, fcmToken); + return "알림을 성공적으로 전송했습니다. targetUserId = " + userId; + } catch (FirebaseMessagingException e) { + e.printStackTrace(); + throw new FcmNotificationExceptionHandler(ErrorCode.FIREBASE_MESSAGING_ERROR); + } + } + + private void sendFcmNotification(FcmNotificationRequestDto requestDto, String fcmToken) throws FirebaseMessagingException { + Notification notification = Notification.builder() + .setTitle(requestDto.title()) + .setBody(requestDto.body()) + .build(); + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(notification) + .build(); + + firebaseMessaging.send(message); } } \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/domain/tactic/service/TacticService.java b/src/main/java/com/capstone/BnagFer/domain/tactic/service/TacticService.java index d0ebc566..20e173c8 100644 --- a/src/main/java/com/capstone/BnagFer/domain/tactic/service/TacticService.java +++ b/src/main/java/com/capstone/BnagFer/domain/tactic/service/TacticService.java @@ -1,7 +1,6 @@ package com.capstone.BnagFer.domain.tactic.service; import com.capstone.BnagFer.domain.accounts.entity.User; -import com.capstone.BnagFer.domain.notification.dto.FcmNotificationRequestDto; -import com.capstone.BnagFer.domain.notification.service.FcmNotificationService; +import com.capstone.BnagFer.domain.notification.event.TacticCommentCreatedEvent; import com.capstone.BnagFer.global.util.RedisUtil; import com.capstone.BnagFer.domain.accounts.service.account.AccountsCommonService; import com.capstone.BnagFer.domain.tactic.dto.*; @@ -17,6 +16,7 @@ import com.capstone.BnagFer.global.common.ApiResponse; import com.capstone.BnagFer.global.common.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +34,7 @@ public class TacticService { private final CommentRepository commentRepository; private final TacticPositionDetailRepository tacticPositionDetailRepository; private final LikeRepository likeRepository; - private final FcmNotificationService fcmNotificationService; + private final ApplicationEventPublisher eventPublisher; public TacticResponse createTactic(TacticCreateRequest request, User user){ @@ -129,41 +129,11 @@ public CommentResponse createComment(Long tacticId, CommentCreateRequest request redisUtil.saveCommentCount(tacticId, commentCount); // FCM 알림 전송 - if (parentCommentId == null) { - // 일반 댓글인 경우 - sendCommentNotification(tactic.getUser(), user); - } else { - // 대댓글인 경우 - sendReplyNotification(tactic.getUser(), parent.getUser(), user); - } + sendNotification(user, tactic, parentCommentId, parent); return CommentResponse.from(tacticComment); } - private void sendCommentNotification(User tacticOwner, User commenter) { - - FcmNotificationRequestDto alarmRequestDto = new FcmNotificationRequestDto( - "새 댓글", - commenter.getProfile().getNickname() + "님이 회원님의 전술에 댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(alarmRequestDto, tacticOwner.getId()); - - } - - private void sendReplyNotification(User tacticOwner, User parentCommentOwner, User replier) { - FcmNotificationRequestDto tacticOwnerAlarmDto = new FcmNotificationRequestDto( - "새 댓글", - replier.getProfile().getNickname() + "님이 회원님의 전술에 댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(tacticOwnerAlarmDto, tacticOwner.getId()); - - FcmNotificationRequestDto parentCommentOwnerAlarmDto = new FcmNotificationRequestDto( - "새 대댓글", - replier.getProfile().getNickname() + "님이 회원님의 댓글에 대댓글을 달았습니다." - ); - fcmNotificationService.sendAlarm(parentCommentOwnerAlarmDto, parentCommentOwner.getId()); - } - public CommentResponse updateComment(Long commentId, CommentUpdateRequest request, User user) { TacticComment tacticComment = commentRepository.findById(commentId).orElseThrow(() -> new TacticExceptionHandler(ErrorCode.COMMENT_NOT_FOUND)); @@ -207,4 +177,25 @@ public ApiResponse likeButton(Long tacticId, User user) { } } + private void sendNotification(User user, Tactic tactic, Long parentCommentId, TacticComment parent) { + if (parentCommentId == null) { + // 새 댓글인 경우 + if (!user.getId().equals(tactic.getUser().getId())) { + // 전술 작성자가 댓글을 단 경우가 아닐 때만 알림 발송 + eventPublisher.publishEvent(new TacticCommentCreatedEvent(user.getId(), tactic.getUser().getId(), TacticCommentCreatedEvent.NotificationType.NEW_COMMENT)); + } + } else { + // 대댓글인 경우 + if (!user.getId().equals(tactic.getUser().getId())) { + // 전술 작성자가 대댓글을 단 경우가 아닐 때 게시글 작성자에게 알림 + eventPublisher.publishEvent(new TacticCommentCreatedEvent(user.getId(), tactic.getUser().getId(), TacticCommentCreatedEvent.NotificationType.NEW_COMMENT)); + } + + if (!user.getId().equals(parent.getUser().getId()) && !parent.getUser().getId().equals(tactic.getUser().getId())) { + // 부모 댓글 작성자가 대댓글을 단 경우가 아니고, 부모 댓글 작성자가 전술 작성자가 아닐 때 부모 댓글 작성자에게 알림 + eventPublisher.publishEvent(new TacticCommentCreatedEvent(user.getId(), parent.getUser().getId(), TacticCommentCreatedEvent.NotificationType.NEW_REPLY)); + } + } + } + } diff --git a/src/main/java/com/capstone/BnagFer/global/common/ErrorCode.java b/src/main/java/com/capstone/BnagFer/global/common/ErrorCode.java index 7442a736..6177ec13 100644 --- a/src/main/java/com/capstone/BnagFer/global/common/ErrorCode.java +++ b/src/main/java/com/capstone/BnagFer/global/common/ErrorCode.java @@ -35,6 +35,8 @@ public enum ErrorCode implements BaseErrorCode { // Firebase 관련 에러 FIREBASE_MESSAGING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FIREBASE401", "Firebase 메시징 예외가 발생했습니다."), FIREBASE_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "FIREBASE402", "Firebase 토큰을 찾을 수 없습니다."), + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT, "FIREBASE403", "알림 저장을 위한 락 획득에 실패했습니다."), + NOTIFICATION_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FIREBASE404", "알림 저장 중 오류가 발생했습니다."), // S3 관련 에러 S3_UPLOAD_FAILED(HttpStatus.BAD_REQUEST, "S3401", "S3 파일 업로드 실패."), diff --git a/src/main/java/com/capstone/BnagFer/global/config/AsyncConfig.java b/src/main/java/com/capstone/BnagFer/global/config/AsyncConfig.java new file mode 100644 index 00000000..21881c85 --- /dev/null +++ b/src/main/java/com/capstone/BnagFer/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.capstone.BnagFer.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() + 1); + executor.setMaxPoolSize((Runtime.getRuntime().availableProcessors() + 1) * 2); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("WebAppAsync-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/BnagFer/global/config/RedisConfig.java b/src/main/java/com/capstone/BnagFer/global/config/RedisConfig.java index 216da1c5..490a2675 100644 --- a/src/main/java/com/capstone/BnagFer/global/config/RedisConfig.java +++ b/src/main/java/com/capstone/BnagFer/global/config/RedisConfig.java @@ -1,12 +1,14 @@ package com.capstone.BnagFer.global.config; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -31,4 +33,11 @@ public RedisTemplate redisTemplate() { redisTemplate.setValueSerializer(new StringRedisSerializer()); return redisTemplate; } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort); + return Redisson.create(config); + } }