diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupCreatedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupCreatedEvent.java new file mode 100644 index 0000000..5a08ed0 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupCreatedEvent.java @@ -0,0 +1,8 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupCreatedEvent( + Long groupId, + Long hostUserId +) { + +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupDeletedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupDeletedEvent.java new file mode 100644 index 0000000..f8950cd --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupDeletedEvent.java @@ -0,0 +1,16 @@ +package team.wego.wegobackend.group.v2.application.event; + +import java.util.List; + +public record GroupDeletedEvent( + // deleteHard()에서 삭제 전에 group/host 정보를 꺼내서 이벤트에 실어 보내면, + //AFTER_COMMIT에서도 DB 재조회가 필요 없어진다. + Long groupId, + Long hostId, + String hostNickName, + String groupTitle, + List attendeeUserIds +) { + +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinApprovedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinApprovedEvent.java new file mode 100644 index 0000000..2e0bf53 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinApprovedEvent.java @@ -0,0 +1,10 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupJoinApprovedEvent( + Long groupId, + Long approverUserId, + Long targetUserId +) { + +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinKickedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinKickedEvent.java new file mode 100644 index 0000000..63acac4 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinKickedEvent.java @@ -0,0 +1,10 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupJoinKickedEvent( + Long groupId, + Long hostId, + Long targetUserId +) { + +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRejectedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRejectedEvent.java new file mode 100644 index 0000000..c329562 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRejectedEvent.java @@ -0,0 +1,9 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupJoinRejectedEvent( + Long groupId, + Long approverUserId, + Long targetUserId +) { + +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRequestedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRequestedEvent.java new file mode 100644 index 0000000..86b33ba --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinRequestedEvent.java @@ -0,0 +1,12 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupJoinRequestedEvent( + Long groupId, + Long hostUserId, + Long requesterUserId +) { + +} + + + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinedEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinedEvent.java new file mode 100644 index 0000000..6ec765c --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupJoinedEvent.java @@ -0,0 +1,10 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupJoinedEvent( + Long groupId, + Long hostId, + Long joinerUserId +) { + +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupLeftEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupLeftEvent.java new file mode 100644 index 0000000..4a7427d --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/GroupLeftEvent.java @@ -0,0 +1,10 @@ +package team.wego.wegobackend.group.v2.application.event; + +public record GroupLeftEvent( + Long groupId, + Long hostId, + Long leaverUserId +) { + +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/event/NotificationEvent.java b/src/main/java/team/wego/wegobackend/group/v2/application/event/NotificationEvent.java new file mode 100644 index 0000000..70cb04b --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/event/NotificationEvent.java @@ -0,0 +1,74 @@ +package team.wego.wegobackend.group.v2.application.event; + +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.notification.application.dto.NotificationType; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.user.domain.User; + +@Getter(AccessLevel.PUBLIC) +@Builder(access = AccessLevel.PUBLIC) +public class NotificationEvent { + + private Long id; // notificationId + private String message; + private NotificationType type; + + private LocalDateTime createdAt; + private LocalDateTime readAt; // null == unread + + private ActorUser user; // 모임 생성자 + private GroupInfo group; // 모임 + + public boolean isRead() { + return readAt != null; + } + + public static NotificationEvent of(Notification n, User actor, GroupV2 group) { + return NotificationEvent.builder() + .id(n.getId()) + .message(n.getMessage()) + .type(n.getType()) + .createdAt(n.getCreatedAt()) + .readAt(n.getReadAt()) + .user(ActorUser.from(actor)) + .group(group != null ? GroupInfo.from(group) : null) + .build(); + } + + @Getter(AccessLevel.PUBLIC) + @Builder(access = AccessLevel.PUBLIC) + public static class ActorUser { + private Long id; + private String nickName; + private String profileImage; + + public static ActorUser from(User u) { + if (u == null) return null; + return ActorUser.builder() + .id(u.getId()) + .nickName(u.getNickName()) + .profileImage(u.getProfileImage()) + .build(); + } + } + + @Getter(AccessLevel.PUBLIC) + @Builder(access = AccessLevel.PUBLIC) + public static class GroupInfo { + private Long id; + private String title; + + public static GroupInfo from(GroupV2 g) { + if (g == null) return null; + return GroupInfo.builder() + .id(g.getId()) + .title(g.getTitle()) + .build(); + } + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupCreateNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupCreateNotificationHandler.java new file mode 100644 index 0000000..ab8f42a --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupCreateNotificationHandler.java @@ -0,0 +1,77 @@ +package team.wego.wegobackend.group.v2.application.handler; + + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.application.dispatcher.NotificationDispatcher; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.FollowRepository; +import team.wego.wegobackend.user.repository.UserRepository; +import team.wego.wegobackend.user.repository.query.FollowerNotifyRow; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GroupCreateNotificationHandler { + + private final FollowRepository followRepository; + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + + private final NotificationDispatcher notificationDispatcher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(Long groupId, Long hostUserId) { + log.info("[NOTI] start handle. groupId={}, hostId={}", groupId, hostUserId); + + List test = followRepository.findFollowersForNotify(hostUserId, null, 10); + log.info("[NOTI] follower rows size={}", test.size()); + + User host = userRepository.findById(hostUserId) + .orElseThrow(); + + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow(); + + Long cursor = null; + final int size = 500; + + while (true) { + List rows = + followRepository.findFollowersForNotify( + hostUserId, cursor, size + ); + + if (rows.isEmpty()) { + break; + } + + List notifications = new ArrayList<>(rows.size()); + + for (FollowerNotifyRow row : rows) { + User receiver = userRepository.getReferenceById(row.userId()); + notifications.add( + Notification.createGroupCreateNotification( + receiver, + host, + group + ) + ); + } + + // 공통 디스패처 호출 + notificationDispatcher.dispatch(notifications, host, group); + + cursor = rows.getLast().followId(); + } + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupDeleteNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupDeleteNotificationHandler.java new file mode 100644 index 0000000..cf79395 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupDeleteNotificationHandler.java @@ -0,0 +1,60 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.event.GroupDeletedEvent; +import team.wego.wegobackend.notification.application.dispatcher.NotificationDispatcher; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GroupDeleteNotificationHandler { + + private final UserRepository userRepository; + private final NotificationDispatcher notificationDispatcher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(GroupDeletedEvent event) { + List ids = event.attendeeUserIds(); + int size = (ids == null ? 0 : ids.size()); + + log.info("[GROUP_DELETE][HANDLER] start groupId={} hostId={} attendeeCount={}", + event.groupId(), event.hostId(), size); + + if (ids == null || ids.isEmpty()) { + return; + } + + User host = userRepository.findById(event.hostId()) + .orElseThrow(() -> new GroupException(GroupErrorCode.HOST_USER_NOT_FOUND, + event.hostId())); + + List notifications = new ArrayList<>(ids.size()); + for (Long receiverId : ids) { + if (receiverId.equals(event.hostId())) { + continue; + } + User receiver = userRepository.getReferenceById(receiverId); + notifications.add( + Notification.createGroupDeleteNotification(receiver, host, event.groupId(), + event.groupTitle())); + } + + log.info("[GROUP_DELETE][HANDLER] built notifications size={}", notifications.size()); + notificationDispatcher.dispatch(notifications, host, null); + + log.info("[GROUP_DELETE][HANDLER] done groupId={}", event.groupId()); + } +} + + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinDecisionNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinDecisionNotificationHandler.java new file mode 100644 index 0000000..abfc024 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinDecisionNotificationHandler.java @@ -0,0 +1,58 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.exception.UserNotFoundException; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class GroupJoinDecisionNotificationHandler { + + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleApproved(Long groupId, Long approverUserId, Long targetUserId) { + handle(groupId, approverUserId, targetUserId, true); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleRejected(Long groupId, Long approverUserId, Long targetUserId) { + handle(groupId, approverUserId, targetUserId, false); + } + + private void handle(Long groupId, Long approverUserId, Long targetUserId, boolean approved) { + User actor = userRepository.findById(approverUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND, approverUserId)); + + User receiver = userRepository.findById(targetUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND, targetUserId)); + + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); + + Notification notification = approved + ? Notification.createGroupJoinApprovedNotification(receiver, actor, group) + : Notification.createGroupJoinRejectedNotification(receiver, actor, group); + + Notification saved = notificationRepository.save(notification); + + NotificationEvent dto = NotificationEvent.of(saved, actor, group); + sseEmitterService.sendNotification(receiver.getId(), dto); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinNotificationHandler.java new file mode 100644 index 0000000..5373d03 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinNotificationHandler.java @@ -0,0 +1,46 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class GroupJoinNotificationHandler { + + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(Long groupId, Long hostUserId, Long joinerUserId) { + User actor = userRepository.findById(joinerUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND, joinerUserId)); + + User receiver = userRepository.findById(hostUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.HOST_USER_NOT_FOUND, hostUserId)); + + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); + + + Notification saved = notificationRepository.save( + Notification.createGroupJoinNotification(receiver, actor, group) + ); + + sseEmitterService.sendNotification(receiver.getId(), NotificationEvent.of(saved, actor, group)); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinRequestNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinRequestNotificationHandler.java new file mode 100644 index 0000000..ceef06c --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupJoinRequestNotificationHandler.java @@ -0,0 +1,47 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class GroupJoinRequestNotificationHandler { + + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(Long groupId, Long hostUserId, Long requesterUserId) { + User host = userRepository.findById(hostUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.HOST_USER_NOT_FOUND, hostUserId)); + + User requester = userRepository.findById(requesterUserId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND, requesterUserId)); + + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); + + Notification saved = notificationRepository.save( + Notification.createGroupJoinRequestNotification(host, requester, group) + ); + + NotificationEvent dto = NotificationEvent.of(saved, requester, group); + sseEmitterService.sendNotification(host.getId(), dto); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupKickNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupKickNotificationHandler.java new file mode 100644 index 0000000..c07634f --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupKickNotificationHandler.java @@ -0,0 +1,38 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class GroupKickNotificationHandler { + + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(Long groupId, Long hostUserId, Long targetUserId) { + User actor = userRepository.findById(hostUserId).orElseThrow(UserNotFoundException::new); + User receiver = userRepository.findById(targetUserId).orElseThrow(UserNotFoundException::new); + GroupV2 group = groupV2Repository.findById(groupId).orElseThrow(UserNotFoundException::new); + + Notification saved = notificationRepository.save( + Notification.createGroupJoinKickedNotification(receiver, actor, group) + ); + + sseEmitterService.sendNotification(receiver.getId(), NotificationEvent.of(saved, actor, group)); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupLeaveNotificationHandler.java b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupLeaveNotificationHandler.java new file mode 100644 index 0000000..5a58773 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/handler/GroupLeaveNotificationHandler.java @@ -0,0 +1,42 @@ +package team.wego.wegobackend.group.v2.application.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GroupLeaveNotificationHandler { + + private final UserRepository userRepository; + private final GroupV2Repository groupV2Repository; + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(Long groupId, Long hostUserId, Long leaverUserId) { + log.info("[NOTI][LEAVE] groupId={}, hostUserId={}, leaverUserId={}", groupId, hostUserId, leaverUserId); + User actor = userRepository.findById(leaverUserId).orElseThrow(UserNotFoundException::new); + User receiver = userRepository.findById(hostUserId).orElseThrow(UserNotFoundException::new); + GroupV2 group = groupV2Repository.findById(groupId).orElseThrow(UserNotFoundException::new); + + Notification saved = notificationRepository.save( + Notification.createGroupLeaveNotification(receiver, actor, group) + ); + + sseEmitterService.sendNotification(receiver.getId(), NotificationEvent.of(saved, actor, group)); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupDeleteEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupDeleteEventListener.java new file mode 100644 index 0000000..a126231 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupDeleteEventListener.java @@ -0,0 +1,22 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupDeletedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupDeleteNotificationHandler; + + +@Component +@RequiredArgsConstructor +public class GroupDeleteEventListener { + + private final GroupDeleteNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDeleted(GroupDeletedEvent event) { + handler.handle(event); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinDecisionEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinDecisionEventListener.java new file mode 100644 index 0000000..1fcb64a --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinDecisionEventListener.java @@ -0,0 +1,26 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupJoinApprovedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinRejectedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupJoinDecisionNotificationHandler; + +@Component +@RequiredArgsConstructor +public class GroupJoinDecisionEventListener { + + private final GroupJoinDecisionNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onApproved(GroupJoinApprovedEvent event) { + handler.handleApproved(event.groupId(), event.approverUserId(), event.targetUserId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRejected(GroupJoinRejectedEvent event) { + handler.handleRejected(event.groupId(), event.approverUserId(), event.targetUserId()); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinEventListener.java new file mode 100644 index 0000000..aa2d410 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinEventListener.java @@ -0,0 +1,21 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupJoinedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupJoinNotificationHandler; + +@Component +@RequiredArgsConstructor +public class GroupJoinEventListener { + + private final GroupJoinNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onJoined(GroupJoinedEvent event) { + handler.handle(event.groupId(), event.hostId(), event.joinerUserId()); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinRequestEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinRequestEventListener.java new file mode 100644 index 0000000..5577ad0 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupJoinRequestEventListener.java @@ -0,0 +1,22 @@ +package team.wego.wegobackend.group.v2.application.listener; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupJoinRequestedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupJoinRequestNotificationHandler; + +@Component +@RequiredArgsConstructor +public class GroupJoinRequestEventListener { + + private final GroupJoinRequestNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onJoinRequested(GroupJoinRequestedEvent event) { + handler.handle(event.groupId(), event.hostUserId(), event.requesterUserId()); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupKickEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupKickEventListener.java new file mode 100644 index 0000000..fc0c7e5 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupKickEventListener.java @@ -0,0 +1,20 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupJoinKickedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupKickNotificationHandler; + +@Component +@RequiredArgsConstructor +public class GroupKickEventListener { + + private final GroupKickNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onKicked(GroupJoinKickedEvent event) { + handler.handle(event.groupId(), event.hostId(), event.targetUserId()); + } +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupLeaveEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupLeaveEventListener.java new file mode 100644 index 0000000..2178de2 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupLeaveEventListener.java @@ -0,0 +1,24 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import team.wego.wegobackend.group.v2.application.event.GroupLeftEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupLeaveNotificationHandler; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupLeaveEventListener { + + private final GroupLeaveNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onLeft(GroupLeftEvent event) { + log.info("[EVENT][RECEIVE] {}", event.getClass().getName()); + handler.handle(event.groupId(), event.hostId(), event.leaverUserId()); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupNotificationEventListener.java b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupNotificationEventListener.java new file mode 100644 index 0000000..ac0e851 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/listener/GroupNotificationEventListener.java @@ -0,0 +1,24 @@ +package team.wego.wegobackend.group.v2.application.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; +import team.wego.wegobackend.group.v2.application.event.GroupCreatedEvent; +import team.wego.wegobackend.group.v2.application.handler.GroupCreateNotificationHandler; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupNotificationEventListener { + + private final GroupCreateNotificationHandler handler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onGroupCreated(GroupCreatedEvent event) { + log.info("[NOTI] onGroupCreated event received. groupId={}, hostId={}", event.groupId(), event.hostUserId()); + handler.handle(event.groupId(), event.hostUserId()); + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java index 80d40b0..1bcee59 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java @@ -2,6 +2,8 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import team.wego.wegobackend.group.domain.exception.GroupErrorCode; @@ -13,6 +15,12 @@ import team.wego.wegobackend.group.v2.application.dto.response.GetBannedTargetsResponse; import team.wego.wegobackend.group.v2.application.dto.response.GetKickTargetsResponse; import team.wego.wegobackend.group.v2.application.dto.response.GroupUserV2StatusResponse; +import team.wego.wegobackend.group.v2.application.event.GroupJoinApprovedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinKickedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinRejectedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinRequestedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupJoinedEvent; +import team.wego.wegobackend.group.v2.application.event.GroupLeftEvent; import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2; import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Role; import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status; @@ -24,6 +32,7 @@ import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; import team.wego.wegobackend.user.repository.UserRepository; +@Slf4j @RequiredArgsConstructor @Service public class GroupV2AttendanceService { @@ -35,6 +44,8 @@ public class GroupV2AttendanceService { // 회원 호출 private final UserRepository userRepository; + // SSE 이벤트 호출 + private final ApplicationEventPublisher eventPublisher; // TODO: 참석, 취소 동시성 해결 필요. @Transactional @@ -114,6 +125,11 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId) { } MyMembership membership = MyMembership.from(groupUserV2); + + log.info("[EVENT][PUBLISH] {}", GroupJoinedEvent.class.getName()); + eventPublisher.publishEvent( + new GroupJoinedEvent(groupId, group.getHost().getId(), userId)); + return AttendanceGroupV2Response.of(group, attendCount, membership); } @@ -149,6 +165,9 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId) { // 내 멤버십 + 최신 카운트 + 모임 상태 응답 MyMembership membership = MyMembership.from(groupUserV2); + eventPublisher.publishEvent( + new GroupJoinRequestedEvent(groupId, group.getHost().getId(), userId)); + return AttendanceGroupV2Response.of(group, attendCount, membership); } throw new GroupException(GroupErrorCode.INVALID_JOIN_POLICY, String.valueOf(joinPolicy)); @@ -185,6 +204,15 @@ public AttendanceGroupV2Response left(Long userId, Long groupId) { // 멤버십 상태 기반 정책, 행동은 도메인으로 위임 groupUserV2.leaveOrCancel(); + GroupUserV2Status after = groupUserV2.getStatus(); + + // LEFT일 때만 "탈퇴" 알림: CANCELLED 등은 별도 명세 없으면 알림 X + if (after == GroupUserV2Status.LEFT) { + GroupLeftEvent ev = new GroupLeftEvent(groupId, group.getHost().getId(), userId); + log.info("[EVENT][PUBLISH] {}", ev.getClass().getName()); + eventPublisher.publishEvent(ev); + } + // 참석 인원은 ATTEND만 카운트 long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, GroupUserV2Status.ATTEND); @@ -259,6 +287,9 @@ public GroupUserV2StatusResponse approve(Long approverUserId, Long groupId, // PENDING만 승인 가능 (도메인에서 검증) target.approveJoin(); + eventPublisher.publishEvent( + new GroupJoinApprovedEvent(groupId, approverUserId, targetUserId)); + long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, GroupUserV2Status.ATTEND); @@ -328,6 +359,9 @@ public GroupUserV2StatusResponse reject(Long approverUserId, Long groupId, target.rejectJoin(); + eventPublisher.publishEvent( + new GroupJoinRejectedEvent(groupId, approverUserId, targetUserId)); + long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, GroupUserV2Status.ATTEND); @@ -350,7 +384,7 @@ public GroupUserV2StatusResponse kick(Long kickerUserId, Long groupId, Long targ .orElseThrow( () -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); - // ✅ HOST only + // HOST only if (!group.getHost().getId().equals(kickerUserId)) { throw new GroupException(GroupErrorCode.NO_PERMISSION_TO_KICK, groupId, kickerUserId); } @@ -378,6 +412,9 @@ public GroupUserV2StatusResponse kick(Long kickerUserId, Long groupId, Long targ target.kick(); + eventPublisher.publishEvent( + new GroupJoinKickedEvent(groupId, group.getHost().getId(), targetUserId)); + long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, GroupUserV2Status.ATTEND); diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2DeleteService.java b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2DeleteService.java index 394f42b..53ce272 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2DeleteService.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2DeleteService.java @@ -2,12 +2,16 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import team.wego.wegobackend.group.domain.exception.GroupErrorCode; import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.event.GroupDeletedEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status; import team.wego.wegobackend.group.v2.domain.entity.GroupV2; import team.wego.wegobackend.group.v2.domain.repository.GroupImageV2Repository; import team.wego.wegobackend.group.v2.domain.repository.GroupTagV2Repository; @@ -15,6 +19,7 @@ import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; import team.wego.wegobackend.image.application.service.ImageUploadService; +@Slf4j @RequiredArgsConstructor @Service public class GroupV2DeleteService { @@ -25,6 +30,7 @@ public class GroupV2DeleteService { private final GroupImageV2Repository groupImageV2Repository; private final ImageUploadService imageUploadService; + private final ApplicationEventPublisher eventPublisher; @Transactional public void deleteHard(Long userId, Long groupId) { @@ -36,39 +42,50 @@ public void deleteHard(Long userId, Long groupId) { .orElseThrow( () -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); - // 호스트만 삭제 가능 if (!group.getHost().getId().equals(userId)) { throw new GroupException(GroupErrorCode.GROUP_ONLY_HOST_CAN_UPDATE, groupId, userId); - // (에러코드가 update용이라면 delete 전용 에러코드 추가를 추천) } - // 1) S3 삭제 대상 URL 확보 (variants의 url 2개씩) + // 삭제 알림에 필요한 정보는 삭제 전에 확보 + final String hostNickName = group.getHost().getNickName(); + final String groupTitle = group.getTitle(); + + List attendeeIds = groupUserV2Repository.findUserIdsByGroupIdAndStatus( + groupId, GroupUserV2Status.ATTEND + ).stream().filter(id -> !id.equals(userId)).toList(); + + log.info("[GROUP_DELETE] start groupId={} hostId={} title='{}' attendeeCount={}", + groupId, userId, groupTitle, attendeeIds.size()); + + // S3 삭제 대상 URL도 삭제 전에 확보 List variantUrls = groupImageV2Repository.findAllVariantUrlsByGroupId(groupId); - // 2) DB 삭제 (연관관계가 복잡하므로 "명시적 순서"로 지움) - // - users 먼저 + // DB 삭제 groupUserV2Repository.deleteByGroupId(groupId); - - // - group_tags (Tag 자체는 삭제하면 안됨) groupTagV2Repository.deleteByGroupId(groupId); - - // - image variants -> images groupImageV2Repository.deleteVariantsByGroupId(groupId); groupImageV2Repository.deleteImagesByGroupId(groupId); - - // - 마지막으로 group groupV2Repository.delete(group); - // 3) 커밋 이후 S3 삭제 (DB가 실제로 삭제 확정된 다음 파일 삭제) - registerAfterCommitS3Deletion(variantUrls); + // AFTER_COMMIT 작업 등록 + registerAfterCommitS3Deletion(groupId, variantUrls); + registerAfterCommitGroupDeletedEvent(groupId, userId, hostNickName, groupTitle, + attendeeIds); + + log.info( + "[GROUP_DELETE] registered afterCommit hooks groupId={} s3Urls={} attendeeCount={}", + groupId, (variantUrls == null ? 0 : variantUrls.size()), attendeeIds.size()); } - private void registerAfterCommitS3Deletion(List variantUrls) { + private void registerAfterCommitS3Deletion(Long groupId, List variantUrls) { if (variantUrls == null || variantUrls.isEmpty()) { + log.info("[GROUP_DELETE][S3] no variant urls. groupId={}", groupId); return; } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { - // 트랜잭션 밖에서 호출되는 이상 케이스 방어: 즉시 삭제 + log.warn("[GROUP_DELETE][S3] no tx sync. delete immediately. groupId={} urls={}", + groupId, variantUrls.size()); imageUploadService.deleteAllByUrls(variantUrls); return; } @@ -76,10 +93,58 @@ private void registerAfterCommitS3Deletion(List variantUrls) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - // 여기서 S3 삭제 실패가 나면 DB는 이미 지워짐. - // 추후 "삭제 재시도(outbox)" 확장 포인트가 필요하면 여기서 기록/로그 남기기. - imageUploadService.deleteAllByUrls(variantUrls); + try { + log.info( + "[GROUP_DELETE][S3][AFTER_COMMIT] deleting s3 objects groupId={} urls={}", + groupId, variantUrls.size()); + imageUploadService.deleteAllByUrls(variantUrls); + log.info("[GROUP_DELETE][S3][AFTER_COMMIT] deleted s3 objects groupId={}", + groupId); + } catch (Exception e) { + // DB는 이미 커밋됨 → 실패 로그는 반드시 남겨서 추후 재처리(outbox) 근거로 + log.error("[GROUP_DELETE][S3][AFTER_COMMIT] delete failed groupId={} reason={}", + groupId, e.toString(), e); + } + } + }); + } + + private void registerAfterCommitGroupDeletedEvent( + Long groupId, + Long hostId, + String hostNickName, + String groupTitle, + List attendeeIds + ) { + if (attendeeIds == null || attendeeIds.isEmpty()) { + log.info("[GROUP_DELETE][EVENT] no attendees. skip publish. groupId={}", groupId); + return; + } + + GroupDeletedEvent event = new GroupDeletedEvent( + groupId, + hostId, + hostNickName, + groupTitle, + attendeeIds + ); + + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + log.warn( + "[GROUP_DELETE][EVENT] no tx sync. publish immediately. groupId={} attendeeCount={}", + groupId, attendeeIds.size()); + eventPublisher.publishEvent(event); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + log.info( + "[GROUP_DELETE][EVENT][AFTER_COMMIT] publish groupDeleted groupId={} hostId={} attendeeCount={}", + groupId, hostId, attendeeIds.size()); + eventPublisher.publishEvent(event); } }); } -} \ No newline at end of file +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java index 362f3c5..bb3986e 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import team.wego.wegobackend.group.domain.exception.GroupErrorCode; @@ -16,6 +18,7 @@ import team.wego.wegobackend.group.v2.application.dto.response.GetGroupListV2Response.GroupListItemV2Response; import team.wego.wegobackend.group.v2.application.dto.response.GetGroupListV2Response.GroupListItemV2Response.CreatedByV2Response; import team.wego.wegobackend.group.v2.application.dto.response.GetGroupV2Response; +import team.wego.wegobackend.group.v2.application.event.GroupCreatedEvent; import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2; import team.wego.wegobackend.group.v2.domain.entity.GroupTagV2; import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2; @@ -34,6 +37,7 @@ import team.wego.wegobackend.user.domain.User; import team.wego.wegobackend.user.repository.UserRepository; +@Slf4j @RequiredArgsConstructor @Service public class GroupV2Service { @@ -52,6 +56,9 @@ public class GroupV2Service { // 회원 호출 private final UserRepository userRepository; + // SSE 이벤트 호출 + private final ApplicationEventPublisher eventPublisher; + private static final int MAX_PAGE_SIZE = 50; private static final int GROUP_LIST_IMAGE_LIMIT = 3; @@ -240,6 +247,12 @@ public CreateGroupV2Response create(Long userId, CreateGroupV2Request request) { // 모임 저장 GroupV2 saved = groupV2Repository.save(group); + // 이벤트 발행 + log.info("[GROUP] created. groupId={}, hostId={}", saved.getId(), host.getId()); + eventPublisher.publishEvent(new GroupCreatedEvent(saved.getId(), host.getId())); + log.info("[GROUP] published GroupCreatedEvent. groupId={}, hostId={}", saved.getId(), host.getId()); + + return CreateGroupV2Response.from(saved, host); } diff --git a/src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupUserV2Repository.java b/src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupUserV2Repository.java index f21a9da..f823dce 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupUserV2Repository.java +++ b/src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupUserV2Repository.java @@ -26,4 +26,14 @@ public interface GroupUserV2Repository extends JpaRepository @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("delete from GroupUserV2 gu where gu.group.id = :groupId") int deleteByGroupId(@Param("groupId") Long groupId); + + + @Query(""" + select gu.user.id + from GroupUserV2 gu + where gu.group.id = :groupId + and gu.status = :status + """) + List findUserIdsByGroupIdAndStatus(@Param("groupId") Long groupId, + @Param("status") GroupUserV2Status status); } diff --git a/src/main/java/team/wego/wegobackend/notification/application/SseEmitterService.java b/src/main/java/team/wego/wegobackend/notification/application/SseEmitterService.java index b27cdd0..ea405c8 100644 --- a/src/main/java/team/wego/wegobackend/notification/application/SseEmitterService.java +++ b/src/main/java/team/wego/wegobackend/notification/application/SseEmitterService.java @@ -1,14 +1,13 @@ package team.wego.wegobackend.notification.application; import java.io.IOException; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; import team.wego.wegobackend.notification.application.dto.response.NotificationResponse; -import team.wego.wegobackend.user.domain.User; @Slf4j @Service @@ -29,8 +28,8 @@ public SseEmitter createEmitter(Long userId) { try { emitter.send(SseEmitter.event() - .name("connect") - .data("Connected")); + .name("connect") + .data("Connected")); } catch (IOException e) { emitter.completeWithError(e); } @@ -45,32 +44,34 @@ public void sendNotification(Long userId, NotificationResponse notification) { if (emitter != null) { try { emitter.send(SseEmitter.event() - .name("notification") - .data(notification)); + .name("notification") + .data(notification)); } catch (IOException e) { emitters.remove(userId); } } } - /** - * 다수 유저에게 알림 전송 - * */ - public void sendNotificationList(List users, NotificationResponse notification) { + public void sendNotification(Long userId, NotificationEvent notification) { + sendNotificationIfConnected(userId, notification); + } - for(User user : users) { - SseEmitter emitter = emitters.get(user.getId()); - log.debug("Connected emitter Info -> {} ", emitter); - if (emitter != null) { - try { - emitter.send(SseEmitter.event() - .name("notification") - .data(notification)); - } catch (IOException e) { - emitters.remove(user.getId()); - } - } + public boolean sendNotificationIfConnected(Long userId, NotificationEvent notification) { + SseEmitter emitter = emitters.get(userId); + + if (emitter == null) { + log.debug("[SSE] no emitter. userId={}", userId); + return false; } + try { + emitter.send(SseEmitter.event().name("notification").data(notification)); + log.debug("[SSE] sent. userId={} notificationId={}", userId, notification.getId()); + return true; + } catch (IOException e) { + log.warn("[SSE] send failed. userId={} reason={}", userId, e.toString()); + emitters.remove(userId); + return false; + } } } diff --git a/src/main/java/team/wego/wegobackend/notification/application/dispatcher/NotificationDispatcher.java b/src/main/java/team/wego/wegobackend/notification/application/dispatcher/NotificationDispatcher.java new file mode 100644 index 0000000..46e94f7 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/notification/application/dispatcher/NotificationDispatcher.java @@ -0,0 +1,59 @@ +package team.wego.wegobackend.notification.application.dispatcher; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.group.v2.application.event.NotificationEvent; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.notification.application.SseEmitterService; +import team.wego.wegobackend.notification.domain.Notification; +import team.wego.wegobackend.notification.repository.NotificationRepository; +import team.wego.wegobackend.user.domain.User; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationDispatcher { + + private final NotificationRepository notificationRepository; + private final SseEmitterService sseEmitterService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void dispatch( + List notifications, + User actor, + GroupV2 group + ) { + + if (notifications == null || notifications.isEmpty()) { + return; + } + + + + // 저장 결과를 받아서 "ID 확정된 엔티티"로 SSE 전송 + List saved = notificationRepository.saveAll(notifications); + notificationRepository.flush(); + log.info("[NOTI][DISPATCH] saved={} actorId={} groupId={}", + saved.size(), (actor == null ? null : actor.getId()), + (group == null ? null : group.getId())); + + int sent = 0; + int noEmitter = 0; + + + for (Notification n : saved) { + Long receiverId = n.getReceiver().getId(); + boolean ok = sseEmitterService.sendNotificationIfConnected( + receiverId, NotificationEvent.of(n, actor, group) + ); + if (ok) sent++; else noEmitter++; + } + log.info("[NOTI][DISPATCH] sseSent={} noEmitter={}", sent, noEmitter); + } +} + diff --git a/src/main/java/team/wego/wegobackend/notification/application/dto/NotificationType.java b/src/main/java/team/wego/wegobackend/notification/application/dto/NotificationType.java index 318282e..062ed06 100644 --- a/src/main/java/team/wego/wegobackend/notification/application/dto/NotificationType.java +++ b/src/main/java/team/wego/wegobackend/notification/application/dto/NotificationType.java @@ -3,15 +3,30 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -@Getter -@RequiredArgsConstructor public enum NotificationType { - FOLLOW("팔로우"), - ENTER("모임 참여"), - EXIT("모임 퇴장"), - CREATE("모임 생성"), - CANCEL("모임 취소"), - TEST("테스트 알림"); + TEST("test", "테스트"), + // user + FOLLOW("user", "A가 B를 팔로우"), + + // group + GROUP_JOIN("group", "모임 참여"), + GROUP_LEAVE("group", "모임 탈퇴"), + GROUP_CREATE("group", "모임 생성"), + GROUP_DELETE("group", "모임 삭제"), + GROUP_JOIN_REQUEST("group", "모임 참여 신청"), + GROUP_JOIN_APPROVED("group", "모임 참여 승인"), + GROUP_JOIN_REJECTED("group", "모임 참여 거절"), + GROUP_JOIN_KICKED("group", "모임 강퇴"); + + private final String domain; private final String description; + + NotificationType(String domain, String description) { + this.domain = domain; + this.description = description; + } + + public String domain() { return domain; } + public String description() { return description; } } diff --git a/src/main/java/team/wego/wegobackend/notification/domain/Notification.java b/src/main/java/team/wego/wegobackend/notification/domain/Notification.java index 5105253..1b5ab7a 100644 --- a/src/main/java/team/wego/wegobackend/notification/domain/Notification.java +++ b/src/main/java/team/wego/wegobackend/notification/domain/Notification.java @@ -17,6 +17,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import team.wego.wegobackend.common.entity.BaseTimeEntity; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; import team.wego.wegobackend.notification.application.dto.NotificationType; import team.wego.wegobackend.user.domain.User; @@ -64,8 +65,8 @@ public class Notification extends BaseTimeEntity { @Builder public Notification(User receiver, User actor, NotificationType type, - String message, Long relatedId, String relatedType, - String redirectUrl) { + String message, Long relatedId, String relatedType, + String redirectUrl) { this.receiver = receiver; this.actor = actor; this.type = type; @@ -83,67 +84,117 @@ public void markAsRead() { // 알림 생성 정적 팩토리 public static Notification createFollowNotification(User receiver, User follower) { return Notification.builder() - .receiver(receiver) - .actor(follower) - .type(NotificationType.FOLLOW) - .message(follower.getNickName() + "님이 회원님을 팔로우하기 시작했습니다.") - .relatedType("FOLLOW") - .redirectUrl("/profile/" + follower.getId()) - .build(); + .receiver(receiver) + .actor(follower) + .type(NotificationType.FOLLOW) + .message(follower.getNickName() + "님이 회원님을 팔로우하기 시작했습니다.") + .relatedType("FOLLOW") + .redirectUrl("/profile/" + follower.getId()) + .build(); } - //TODO : receiver -> 모임장, related 데이터 작성 필요 - public static Notification createEnterNotification(User receiver, User participant, Long postId) { + public static Notification createGroupCreateNotification(User receiver, User creator, + GroupV2 group) { return Notification.builder() - .receiver(receiver) - .actor(participant) - .type(NotificationType.ENTER) - .message(participant.getNickName() + "님이 모임에 참여하셨습니다.") - .relatedId(postId) - .relatedType("POST") - .redirectUrl("/post/" + postId) - .build(); + .receiver(receiver) + .actor(creator) + .type(NotificationType.GROUP_CREATE) + .message(creator.getNickName() + "님이 새 모임을 생성하셨습니다.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId()) + .build(); } - //TODO : receiver -> 모임장, related 데이터 작성 필요 - public static Notification createExitNotification(User receiver, User leaver, - Long postId, Long commentId) { + public static Notification createGroupJoinRequestNotification( + User receiver, + User actor, + GroupV2 group + ) { return Notification.builder() - .receiver(receiver) - .actor(leaver) - .type(NotificationType.EXIT) - .message(leaver.getNickName() + "님이 모임에서 퇴장하셨습니다.") - .relatedId(commentId) - .relatedType("POST") - .redirectUrl("/post/" + postId) - .build(); + .receiver(receiver) // host + .actor(actor) // follower + .type(NotificationType.GROUP_JOIN_REQUEST) + .message(actor.getNickName() + "님이 \"" + group.getTitle() + "\" 모임에 참여를 신청했어요.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId() + "/attend") + .build(); } + public static Notification createGroupJoinApprovedNotification(User receiver, User actor, + GroupV2 group) { + return Notification.builder() + .receiver(receiver) // host + .actor(actor) // joiner approve + .type(NotificationType.GROUP_JOIN_APPROVED) + .message(actor.getNickName() + "님이 모임 참여 신청을 수락하셨습니다.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId()) + .build(); + } + + public static Notification createGroupJoinRejectedNotification(User receiver, User actor, + GroupV2 group) { + return Notification.builder() + .receiver(receiver) // host + .actor(actor) // joiner reject + .type(NotificationType.GROUP_JOIN_REJECTED) + .message(actor.getNickName() + "님이 모임 참여 신청을 거절하셨습니다.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId()) + .build(); + } - public static Notification createGroupNotification(User receiver, User creator, - Long postId) { + public static Notification createGroupJoinNotification(User receiver, User actor, GroupV2 group) { return Notification.builder() - .receiver(receiver) - .actor(creator) - .type(NotificationType.CREATE) - .message(creator.getNickName() + "님이 모임을 생성하셨습니다.") - .relatedId(postId) - .relatedType("POST") - .redirectUrl("/post/" + postId) - .build(); + .receiver(receiver) // host + .actor(actor) // joiner + .type(NotificationType.GROUP_JOIN) + .message(actor.getNickName() + "님이 \"" + group.getTitle() + "\" 모임에 참여했어요.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId()) + .build(); } - public static Notification createGroupCancleNotification(User receiver, User canceler, - Long postId) { + public static Notification createGroupLeaveNotification(User receiver, User actor, GroupV2 group) { return Notification.builder() - .receiver(receiver) - .actor(canceler) - .type(NotificationType.CANCEL) - .message(canceler.getNickName() + "님이 모임을 취소하셨습니다.") - .relatedId(postId) - .relatedType("POST") - .redirectUrl("/post/" + postId) - .build(); + .receiver(receiver) // host + .actor(actor) // leaver + .type(NotificationType.GROUP_LEAVE) + .message(actor.getNickName() + "님이 \"" + group.getTitle() + "\" 모임을 탈퇴했어요.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups/" + group.getId()) + .build(); } + public static Notification createGroupDeleteNotification( + User receiver, User actor, Long groupId, String groupTitle + ) { + return Notification.builder() + .receiver(receiver) + .actor(actor) + .type(NotificationType.GROUP_DELETE) + .message(actor.getNickName() + "님이 \"" + groupTitle + "\" 모임을 삭제했어요.") + .relatedId(groupId) + .relatedType("GROUP") + .redirectUrl("/groups") + .build(); + } + + public static Notification createGroupJoinKickedNotification(User receiver, User actor, GroupV2 group) { + return Notification.builder() + .receiver(receiver) // kicked user + .actor(actor) // host + .type(NotificationType.GROUP_JOIN_KICKED) + .message(actor.getNickName() + "님이 \"" + group.getTitle() + "\" 모임에서 회원님을 강퇴했어요.") + .relatedId(group.getId()) + .relatedType("GROUP") + .redirectUrl("/groups") // 더 이상 접근 불가면 리스트가 안전 + .build(); + } } diff --git a/src/main/java/team/wego/wegobackend/notification/presentation/NotificationController.java b/src/main/java/team/wego/wegobackend/notification/presentation/NotificationController.java index 2825310..ef0173f 100644 --- a/src/main/java/team/wego/wegobackend/notification/presentation/NotificationController.java +++ b/src/main/java/team/wego/wegobackend/notification/presentation/NotificationController.java @@ -1,7 +1,6 @@ package team.wego.wegobackend.notification.presentation; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -17,14 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import team.wego.wegobackend.auth.exception.UserNotFoundException; import team.wego.wegobackend.common.response.ApiResponse; import team.wego.wegobackend.common.security.CustomUserDetails; import team.wego.wegobackend.notification.application.NotificationService; import team.wego.wegobackend.notification.application.SseEmitterService; import team.wego.wegobackend.notification.application.dto.response.NotificationListResponse; -import team.wego.wegobackend.notification.application.dto.response.NotificationResponse; -import team.wego.wegobackend.user.domain.User; import team.wego.wegobackend.user.repository.UserRepository; @Slf4j diff --git a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java index dd49b42..d9b7087 100644 --- a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java +++ b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java @@ -2,12 +2,17 @@ import java.util.List; import team.wego.wegobackend.user.application.dto.response.FollowResponse; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.query.FollowerNotifyRow; public interface FollowRepositoryCustom { List findFollowingList( - Long followerId, - Long cursorFollowId, - int size - ); + Long followerId, + Long cursorFollowId, + int size + ); + + List findFollowersForNotify(Long followeeId, Long cursorFollowId, int size); + } diff --git a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java index 64f3a67..438eed6 100644 --- a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java +++ b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java @@ -1,5 +1,6 @@ package team.wego.wegobackend.user.repository; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -8,44 +9,76 @@ import team.wego.wegobackend.user.application.dto.response.QFollowResponse; import team.wego.wegobackend.user.domain.QFollow; import team.wego.wegobackend.user.domain.QUser; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.query.FollowerNotifyRow; @RequiredArgsConstructor -public class FollowRepositoryImpl implements FollowRepositoryCustom{ +public class FollowRepositoryImpl implements FollowRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; @Override public List findFollowingList( - Long followerId, - Long cursorFollowId, - int size + Long followerId, + Long cursorFollowId, + int size ) { QFollow follow = QFollow.follow; QUser user = QUser.user; return jpaQueryFactory - .select(new QFollowResponse( - follow.id, - user.id, - user.profileImage, - user.nickName, - user.profileMessage - )) - .from(follow) - .join(follow.followee, user) - .where( - follow.follower.id.eq(followerId), - ItCursor(cursorFollowId) - ) - .orderBy(follow.id.desc()) //최신 팔로우 순 - .limit(size) - .fetch(); + .select(new QFollowResponse( + follow.id, + user.id, + user.profileImage, + user.nickName, + user.profileMessage + )) + .from(follow) + .join(follow.followee, user) + .where( + follow.follower.id.eq(followerId), + itCursor(cursorFollowId) + ) + .orderBy(follow.id.desc()) //최신 팔로우 순 + .limit(size) + .fetch(); } - private BooleanExpression ItCursor(Long cursorFollowId) { + private BooleanExpression itCursor(Long cursorFollowId) { return cursorFollowId == null - ? null - : QFollow.follow.id.lt(cursorFollowId); + ? null + : QFollow.follow.id.lt(cursorFollowId); + } + + @Override + public List findFollowersForNotify( + Long followeeId, + Long cursorFollowId, + int size + ) { + QFollow follow = QFollow.follow; + QUser user = QUser.user; + + return jpaQueryFactory + .select(Projections.constructor( + FollowerNotifyRow.class, + follow.id, + user.id, + user.nickName, + user.profileImage + )) + .from(follow) + .join(follow.follower, user) + .where( + follow.followee.id.eq(followeeId), + cursorFollowId == null ? null : follow.id.lt(cursorFollowId), + user.notificationEnabled.isTrue(), + user.deleted.isFalse() + ) + .orderBy(follow.id.desc()) + .limit(size) + .fetch(); } } diff --git a/src/main/java/team/wego/wegobackend/user/repository/query/FollowerNotifyRow.java b/src/main/java/team/wego/wegobackend/user/repository/query/FollowerNotifyRow.java new file mode 100644 index 0000000..d85a649 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/user/repository/query/FollowerNotifyRow.java @@ -0,0 +1,11 @@ +package team.wego.wegobackend.user.repository.query; + +public record FollowerNotifyRow( + Long followId, + Long userId, + String nickName, + String profileImage +) { + +} + diff --git a/src/test/http/group/group-attend.http b/src/test/http/group/group-attend.http index 0f2d48b..4a46eb2 100644 --- a/src/test/http/group/group-attend.http +++ b/src/test/http/group/group-attend.http @@ -46,7 +46,7 @@ Content-Type: image/jpeg > {% const images = response.body.data.images; - // ✅ 업로드 결과 URL을 전역 변수로 저장 + // 업로드 결과 URL을 전역 변수로 저장 client.global.set("img0_main", images[0].imageUrl440x240); client.global.set("img0_thumb", images[0].imageUrl100x100); diff --git a/src/test/http/group/v2/V2-group-left.http b/src/test/http/group/v2/V2-group-left.http deleted file mode 100644 index 8245235..0000000 --- a/src/test/http/group/v2/V2-group-left.http +++ /dev/null @@ -1,185 +0,0 @@ -### 0. 회원가입(HOST2) -POST http://localhost:8080/api/v1/auth/signup -Content-Type: application/json - -{ - "email": "host2@example.com", - "password": "Test1234!@#", - "nickName": "HostTwo", - "phoneNumber": "010-0000-0001" -} - -> {% - client.global.set("host2UserId", response.body.data.userId); -%} - -### 0-1. 로그인(HOST2) - accessToken 발급 -POST http://localhost:8080/api/v1/auth/login -Content-Type: application/json - -{ - "email": "host2@example.com", - "password": "Test1234!@#" -} - -> {% - client.global.set("host2AccessToken", response.body.data.accessToken); -%} - -### 1-1. 모임 V2 이미지 선 업로드 (png / jpg 2장) - imageKey 저장 -POST http://localhost:8080/api/v2/groups/images/upload -Content-Type: multipart/form-data; boundary=boundary -Authorization: Bearer {{host2AccessToken}} - ---boundary -Content-Disposition: form-data; name="images"; filename="img1.png" -Content-Type: image/png - -< ../../image/resources/img1.png ---boundary -Content-Disposition: form-data; name="images"; filename="img2.jpg" -Content-Type: image/jpeg - -< ../../image/resources/img2.jpg ---boundary-- - -> {% - const images = response.body.data.images; - - client.global.set("c_img0_key", images[0].imageKey); - client.global.set("c_img1_key", images[1].imageKey); -%} - -### 1-2. 모임 V2 생성 (left 테스트용 모임) -POST http://localhost:8080/api/v2/groups/create -Content-Type: application/json -Authorization: Bearer {{host2AccessToken}} - -{ - "title": "V2 left() 테스트용 모임", - "location": "서울 서초구", - "joinPolicy": "FREE", - "locationDetail": "교대역 1번 출구 근처 카페", - "startTime": "2026-12-20T19:00:00", - "endTime": "2026-12-20T21:00:00", - "tags": [ - "v2", - "left", - "attendance" - ], - "description": "left() 성공/중복 left/HOST left 불가를 테스트합니다.", - "maxParticipants": 5, - "images": [ - { - "sortOrder": 0, - "imageKey": "{{c_img0_key}}" - }, - { - "sortOrder": 1, - "imageKey": "{{c_img1_key}}" - } - ] -} - -> {% - client.global.set("groupId_leftTest", response.body.data.id); -%} - -### 2. 회원가입(MEMBER 1) -POST http://localhost:8080/api/v1/auth/signup -Content-Type: application/json - -{ - "email": "left_member1@example.com", - "password": "Test1234!@#", - "nickName": "LeftMember1", - "phoneNumber": "010-1111-2222" -} - -> {% - client.global.set("left_memberId1", response.body.data.userId); -%} - -### 2-1. 로그인(MEMBER 1) -POST http://localhost:8080/api/v1/auth/login -Content-Type: application/json - -{ - "email": "left_member1@example.com", - "password": "Test1234!@#" -} - -> {% - client.global.set("left_member1AccessToken", response.body.data.accessToken); -%} - -### 3. 회원가입(MEMBER 2) -POST http://localhost:8080/api/v1/auth/signup -Content-Type: application/json - -{ - "email": "left_member2@example.com", - "password": "Test1234!@#", - "nickName": "LeftMember2", - "phoneNumber": "010-2222-3333" -} - -> {% - client.global.set("left_memberId2", response.body.data.userId); -%} - -### 3-1. 로그인(MEMBER 2) -POST http://localhost:8080/api/v1/auth/login -Content-Type: application/json - -{ - "email": "left_member2@example.com", - "password": "Test1234!@#" -} - -> {% - client.global.set("left_member2AccessToken", response.body.data.accessToken); -%} - -### 4-1. V2 MEMBER 1 참여 (ATTEND) -POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/attend -Content-Type: application/json -Authorization: Bearer {{left_member1AccessToken}} - -{} - -### 4-2. V2 MEMBER 2 참여 (ATTEND) -POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/attend -Content-Type: application/json -Authorization: Bearer {{left_member2AccessToken}} - -{} - -### 5-1. V2 MEMBER 1 left() (성공: ATTEND -> LEFT) -POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/left -Content-Type: application/json -Authorization: Bearer {{left_member1AccessToken}} - -{} - -### 5-2. 예외: MEMBER 1 다시 left() 시도 -POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/left -Content-Type: application/json -Authorization: Bearer {{left_member1AccessToken}} - -{} - -### 5-3. 예외: HOST가 자신의 모임 left() 시도 (HOST는 left 불가) -POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/left -Content-Type: application/json -Authorization: Bearer {{host2AccessToken}} - -{} - -### 5-4. (선택) 모임 상세 조회 - MEMBER 2 토큰 (참여 인원/내 상태 확인) -GET http://localhost:8080/api/v2/groups/{{groupId_leftTest}} -Authorization: Bearer {{left_member2AccessToken}} - -### 5-5. (선택) 모임 상세 조회 - HOST 토큰 (참여 인원/상태 확인) -GET http://localhost:8080/api/v2/groups/{{groupId_leftTest}} -Authorization: Bearer {{host2AccessToken}} diff --git a/src/test/http/group/v2/v2-group-create.http b/src/test/http/group/v2/v2-group-create.http index c43ee38..7f5e46a 100644 --- a/src/test/http/group/v2/v2-group-create.http +++ b/src/test/http/group/v2/v2-group-create.http @@ -12,6 +12,7 @@ Content-Type: application/json client.global.set("userId", response.body.data.userId); %} + ### 0-1. 로그인(HOST) - accessToken 발급 POST http://localhost:8080/api/v1/auth/login Content-Type: application/json @@ -25,6 +26,40 @@ Content-Type: application/json client.global.set("accessToken", response.body.data.accessToken); %} +### Follower 회원가입 +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "follower@example.com", + "password": "Test1234!@#", + "nickName": "Jake" +} + +### Follower 로그인 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "follower@example.com", + "password": "Test1234!@#" +} +> {% + client.global.set("followerAccessToken", response.body.data.accessToken); +%} + +### Follower 알림 허용 +PATCH http://localhost:8080/api/v1/users/notification?isNotificationEnabled=true +Authorization: Bearer {{followerAccessToken}} + +### Follower → HOST 팔로우 +POST http://localhost:8080/api/v1/users/follow?followNickname=Beemo +Authorization: Bearer {{followerAccessToken}} + +### 모임 생성 전 FOLLOWER 알림 카운트 조회 +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{followerAccessToken}} + ### 1-1. 모임 V2 이미지 선 업로드 (png / jpg 2장) POST http://localhost:8080/api/v2/groups/images/upload @@ -86,6 +121,13 @@ Authorization: Bearer {{accessToken}} client.global.set("groupId_png_jpg", response.body.data.id); %} +### 모임 생성 후 FOLLOWER 알림 카운트 조회 +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{followerAccessToken}} + +### FOLLOWER 알림 목록 조회 +GET http://localhost:8080/api/v1/notifications +Authorization: Bearer {{followerAccessToken}} ### 2-1. 모임 V2 이미지 선 업로드 (webp 2장 + png 1장) POST http://localhost:8080/api/v2/groups/images/upload diff --git a/src/test/http/group/v2/v2-group-delete.http b/src/test/http/group/v2/v2-group-delete.http index a21dfac..531423a 100644 --- a/src/test/http/group/v2/v2-group-delete.http +++ b/src/test/http/group/v2/v2-group-delete.http @@ -25,6 +25,41 @@ Content-Type: application/json client.global.set("accessToken", response.body.data.accessToken); %} +### 0-2. (추가) 삭제 알림 받을 MEMBER 회원가입 +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "delmember@example.com", + "password": "Test1234!@#", + "nickName": "DelMember" +} + +> {% + client.global.set("delMemberUserId", response.body.data.userId); +%} + +### 0-3. (추가) 삭제 알림 받을 MEMBER 로그인 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "delmember@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("delMemberAccessToken", response.body.data.accessToken); +%} + +### (추가) MEMBER SSE 구독 (삭제 이벤트 실시간 수신용) +GET http://localhost:8080/api/v1/notifications/subscribe +Authorization: Bearer {{delMemberAccessToken}} +Accept: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + + ### 1-1. 모임 V2 이미지 선 업로드 (png / jpg 2장) POST http://localhost:8080/api/v2/groups/images/upload Content-Type: multipart/form-data; boundary=boundary @@ -84,14 +119,59 @@ Authorization: Bearer {{accessToken}} client.global.set("groupId_delete", response.body.data.id); %} +### (추가) MEMBER가 모임 참가 -> GroupUser 생성됨 (삭제 알림 대상 확보) +POST http://localhost:8080/api/v2/groups/{{groupId_delete}}/attend +Authorization: Bearer {{delMemberAccessToken}} +Content-Type: application/json + +{} + ### 1-3. 모임 V2 단건 조회 (삭제 전 상태 확인) GET http://localhost:8080/api/v2/groups/{{groupId_delete}} Authorization: Bearer {{accessToken}} +### 2-0-1. (삭제 직전) MEMBER unread-count +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{delMemberAccessToken}} + +> {% + client.global.set("beforeUnread", response.body.data); +%} + +### 2-0-2. (삭제 직전) MEMBER 알림 목록 조회 (baseline) +GET http://localhost:8080/api/v1/notifications?size=20 +Authorization: Bearer {{delMemberAccessToken}} + ### 2-1. 모임 V2 삭제 (Hard Delete) - 성공 기대 (204 No Content) DELETE http://localhost:8080/api/v2/groups/{{groupId_delete}} Authorization: Bearer {{accessToken}} +### 2-2-1. (삭제 직후) MEMBER unread-count (증가 기대) +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{delMemberAccessToken}} + +> {% + const afterUnread = response.body.data; + const beforeUnread = Number(client.global.get("beforeUnread") || 0); + + client.test("unread-count should increase by 1", function() { + client.assert(afterUnread === beforeUnread + 1, "Expected unread to increase by 1. before=" + beforeUnread + " after=" + afterUnread); + }); +%} + +### 2-2-2. (삭제 직후) MEMBER 알림 목록 조회 (GROUP_DELETE 존재 기대) +GET http://localhost:8080/api/v1/notifications?size=20 +Authorization: Bearer {{delMemberAccessToken}} + +> {% + const list = response.body.data.result || response.body.data; // 응답 구조에 따라 조정 + const found = list.some(n => n.type === "GROUP_DELETE" || (n.type && n.type.includes("GROUP_DELETE"))); + + client.test("notification list contains GROUP_DELETE", function() { + client.assert(found, "Expected GROUP_DELETE notification in list"); + }); +%} + ### 2-2. 모임 V2 단건 조회 (삭제 후 조회 → 예외 기대: GROUP_NOT_FOUND_BY_ID) GET http://localhost:8080/api/v2/groups/{{groupId_delete}} Authorization: Bearer {{accessToken}} diff --git a/src/test/http/group/v2/v2-group-left.http b/src/test/http/group/v2/v2-group-left.http new file mode 100644 index 0000000..7417c2f --- /dev/null +++ b/src/test/http/group/v2/v2-group-left.http @@ -0,0 +1,184 @@ +### 0. HOST 회원가입 +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#", + "nickName": "Beemo" +} + +> {% + client.global.set("hostUserId", response.body.data.userId); +%} + + +### 0-1. HOST 로그인 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("hostAccessToken", response.body.data.accessToken); +%} + + +### 0-2. FOLLOWER 회원가입 +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "follower@example.com", + "password": "Test1234!@#", + "nickName": "Jake" +} + +> {% + client.global.set("followerUserId", response.body.data.userId); +%} + + +### 0-3. FOLLOWER 로그인 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "follower@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("followerAccessToken", response.body.data.accessToken); +%} + + +### 0-4. FOLLOWER 알림 허용 (켜기) +PATCH http://localhost:8080/api/v1/users/notification?isNotificationEnabled=true +Authorization: Bearer {{followerAccessToken}} + + +### 0-5. FOLLOWER -> HOST 팔로우 +POST http://localhost:8080/api/v1/users/follow?followNickname=Beemo +Authorization: Bearer {{followerAccessToken}} + + +### 0-6. (교차검증) 모임 생성 전 FOLLOWER unread-count +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{followerAccessToken}} + +> {% + client.global.set("SSE_URL_HEADER", "http://localhost:8080/api/v1/notifications/subscribe"); + client.global.set("SSE_URL_QUERY", "http://localhost:8080/api/v1/notifications/subscribe?accessToken=" + client.global.get("followerAccessToken")); +%} + + +### 1-1. FOLLOWER SSE 구독 (헤더 방식) +GET {{SSE_URL_HEADER}} +Authorization: Bearer {{followerAccessToken}} +Accept: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + + + +### 1-2. FOLLOWER SSE 구독 (QueryParam 방식 - 서버가 이 방식을 지원할 때만) +### - 위 1-1이 안 될 때만 이걸로 테스트하세요. +GET {{SSE_URL_QUERY}} +Accept: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + +### HOST SSE 구독 추가 +GET http://localhost:8080/api/v1/notifications/subscribe +Authorization: Bearer {{hostAccessToken}} +Accept: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + + +### 2-1. 모임 V2 이미지 선 업로드 (png/jpg 2장) - HOST +POST http://localhost:8080/api/v2/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{hostAccessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="img1.png" +Content-Type: image/png + +< ../../image/resources/img1.png +--boundary +Content-Disposition: form-data; name="images"; filename="img4.jpg" +Content-Type: image/jpeg + +< ../../image/resources/img4.jpg +--boundary-- + +> {% + const images = response.body.data.images; + client.global.set("img0_key", images[0].imageKey); + client.global.set("img1_key", images[1].imageKey); +%} + + + +### 2-2. HOST 모임 생성 (이 순간 SSE 이벤트가 FOLLOWER에 와야 함) +POST http://localhost:8080/api/v2/groups/create +Content-Type: application/json +Authorization: Bearer {{hostAccessToken}} + +{ + "title": "SSE 알림 실시간 테스트", + "location": "서울 강남구", + "joinPolicy": "FREE", + "locationDetail": "강남역 2번 출구 근처 카페", + "startTime": "2026-12-10T19:00:00", + "endTime": "2026-12-10T21:00:00", + "tags": ["자바","백엔드","스터디"], + "description": "SSE 구독 후 모임 생성 시 follower에게 이벤트가 실시간으로 와야 합니다.", + "maxParticipants": 12, + "images": [ + { "sortOrder": 0, "imageKey": "{{img0_key}}" }, + { "sortOrder": 1, "imageKey": "{{img1_key}}" } + ] +} + +> {% + client.global.set("createdGroupId", response.body.data.id); +%} + + + +### 3-1. 모임 생성 후 FOLLOWER unread-count (DB 기준으로도 증가했는지) +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{followerAccessToken}} + + + +### 3-2. FOLLOWER 알림 목록 조회 (목록에도 쌓였는지) +GET http://localhost:8080/api/v1/notifications +Authorization: Bearer {{followerAccessToken}} + +### 4. FOLLOWER 모임 참여(Join) 또는 승인까지 완료 +POST http://localhost:8080/api/v2/groups/{{createdGroupId}}/attend +Authorization: Bearer {{followerAccessToken}} +Content-Type: application/json + +{} + +### 5. FOLLOWER 모임 탈퇴 +POST http://localhost:8080/api/v2/groups/{{createdGroupId}}/left +Authorization: Bearer {{followerAccessToken}} + + +### 6. 탈퇴 후 HOST unread-count (DB 기준 검증) +GET http://localhost:8080/api/v1/notifications/unread-count +Authorization: Bearer {{hostAccessToken}} + + +### 7. 탈퇴 후 HOST 알림 목록 조회 +GET http://localhost:8080/api/v1/notifications +Authorization: Bearer {{hostAccessToken}}