diff --git a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java b/src/main/java/ita/tinybite/domain/notification/infra/scheduler/FcmTokenScheduler.java similarity index 90% rename from src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java rename to src/main/java/ita/tinybite/domain/notification/infra/scheduler/FcmTokenScheduler.java index 8185086..5a12c5f 100644 --- a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/scheduler/FcmTokenScheduler.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.notification.infra.fcm; // 별도의 scheduler 패키지 권장 +package ita.tinybite.domain.notification.infra.scheduler; import ita.tinybite.domain.notification.service.FcmTokenService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/ita/tinybite/domain/notification/infra/scheduler/NotificationScheduler.java b/src/main/java/ita/tinybite/domain/notification/infra/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..3850a3e --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/scheduler/NotificationScheduler.java @@ -0,0 +1,76 @@ +package ita.tinybite.domain.notification.infra.scheduler; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import ita.tinybite.domain.notification.service.facade.NotificationFacade; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationScheduler { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final NotificationFacade notificationFacade; + + @Scheduled(cron = "0 * * * * *") // 1분마다 + public void processPendingApprovalReminders() { + Set keys = redisTemplate.keys("pending_reminder:*"); + if (keys.isEmpty()) return; + + LocalDateTime now = LocalDateTime.now(); + + for (String key : keys) { + try { + // Redis에서 JSON 문자열 가져오기 + String jsonValue = redisTemplate.opsForValue().get(key); + if (jsonValue == null) continue; + + Map data = objectMapper.readValue(jsonValue, Map.class); + + Long hostId = Long.valueOf(data.get("hostId").toString()); + Long partyId = Long.valueOf(data.get("partyId").toString()); + Long requesterId = Long.valueOf(data.get("requesterId").toString()); + String requesterNickname = (String) data.get("requesterNickname"); + int retryCount = (int) data.get("retryCount"); + LocalDateTime lastSentAt = LocalDateTime.parse((String) data.get("lastSentAt")); + + // 10분 확인 + if (now.isAfter(lastSentAt.plusMinutes(10))) { + + if (retryCount >= 3) { + log.info("리마인드 3회 초과로 자동 삭제: {}", key); + redisTemplate.delete(key); + continue; + } + + notificationFacade.notifyPendingApprovalReminder( + hostId, + partyId, + requesterId, + requesterNickname + ); + + data.put("retryCount", retryCount + 1); + data.put("lastSentAt", now.toString()); + + redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(data)); + + log.info("리마인드 알림 발송 및 데이터 갱신 완료: {}회차", retryCount + 1); + } + } catch (Exception e) { + log.error("리마인드 스케줄러 처리 중 에러 발생 (Key: {}): {}", key, e.getMessage()); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java index 63a4847..5f2f145 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -1,10 +1,16 @@ package ita.tinybite.domain.notification.service; +import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.firebase.messaging.BatchResponse; import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; @@ -12,6 +18,11 @@ import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; import ita.tinybite.domain.notification.service.manager.PartyMessageManager; +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.CommonErrorCode; +import ita.tinybite.global.exception.errorcode.UserErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,6 +36,9 @@ public class PartyNotificationService { private final PartyMessageManager partyMessageManager; private final NotificationLogService notificationLogService; private final NotificationTransactionHelper notificationTransactionHelper; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; // JSON 변환을 위해 + private final UserRepository userRepository; // @Transactional @@ -202,4 +216,46 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } + + @Transactional + public void reservePendingApprovalReminder(Long managerId, Long requesterId, Long partyId) { + User requester = userRepository.findById(requesterId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXISTS)); + + String key = "pending_reminder:" + partyId + ":" + requesterId; + + Map reminderData = new HashMap<>(); + reminderData.put("hostId", managerId); + reminderData.put("requesterId", requesterId); + reminderData.put("requesterNickname", requester.getNickname()); + reminderData.put("partyId", partyId); + reminderData.put("lastSentAt", LocalDateTime.now().toString()); + reminderData.put("retryCount", 0); + + try { + String jsonValue = objectMapper.writeValueAsString(reminderData); + redisTemplate.opsForValue().set(key, jsonValue); + } catch (JsonProcessingException e) { + throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Transactional + public void sendPendingApprovalReminder(Long managerId, String requesterNickname, Long partyId) { + String title = "⏰ 참여 요청이 기다리고 있어요"; + String detail = String.format("%s님이 아직 응답을 기다리고 있어요", requesterNickname); + + notificationLogService.saveLog(managerId, "PENDING_APPROVAL_REMINDER", title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createPendingApprovalReminderRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java index 0399514..4bfee10 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -2,12 +2,15 @@ import java.util.List; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ita.tinybite.domain.notification.service.ChatNotificationService; import ita.tinybite.domain.notification.service.PartyNotificationService; import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.party.enums.ParticipantStatus; +import ita.tinybite.domain.party.repository.PartyParticipantRepository; import ita.tinybite.domain.party.repository.PartyRepository; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; @@ -31,6 +34,9 @@ public class NotificationFacade { private final PartyRepository partyRepository; private final UserRepository userRepository; + private final PartyParticipantRepository partyParticipantRepository; + + private final RedisTemplate redisTemplate; @Transactional public void notifyNewPartyRequest(Long managerId, Long requesterId, Long partyId) { @@ -48,7 +54,6 @@ public void notifyNewPartyRequest(Long managerId, Long requesterId, Long partyId ); } - @Transactional public void notifyApproval(Long targetUserId, Long partyId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new BusinessException(PartyErrorCode.PARTY_NOT_FOUND)); @@ -62,7 +67,6 @@ public void notifyRejection(Long targetUserId, Long partyId) { } // 인원 모집 완료 - @Transactional public void notifyPartyAutoClose(List memberIds, Long partyId, Long managerId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new BusinessException(PartyErrorCode.PARTY_NOT_FOUND)); @@ -71,7 +75,6 @@ public void notifyPartyAutoClose(List memberIds, Long partyId, Long manage } // 파티 종료 - @Transactional public void notifyPartyComplete(List memberIds, Long partyId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new BusinessException(PartyErrorCode.PARTY_NOT_FOUND)); @@ -79,31 +82,18 @@ public void notifyPartyComplete(List memberIds, Long partyId) { partyNotificationService.sendPartyCompleteNotification(memberIds, party.getTitle(), partyId); } - @Transactional public void notifyOrderComplete(List memberIds, Long partyId) { partyNotificationService.sendOrderCompleteNotification(memberIds, partyId); } - @Transactional public void notifyDeliveryReminder(List memberIds, Long partyId, Long managerId) { partyNotificationService.sendDeliveryReminderNotification(memberIds, partyId, managerId); } - @Transactional public void notifyMemberLeave(Long managerId, Long partyId, String leaverName) { partyNotificationService.sendMemberLeaveNotification(managerId, partyId, leaverName); } - /* - public void notifyNewChatMessage( - Long targetUserId, - Long chatRoomId, - String senderName, - String messageContent - ) { - chatNotificationService.sendNewChatMessage(targetUserId, chatRoomId, senderName, messageContent); - }*/ - // 1:1 채팅 public void notifyOneToOneChat(Long targetUserId, Long chatRoomId, String senderName, String content) { chatNotificationService.sendOneToOneChatMessage(targetUserId, chatRoomId, senderName, content); @@ -124,10 +114,39 @@ public void notifyGroupImage(Long targetUserId, Long chatRoomId, String partyTit chatNotificationService.sendGroupChatImage(targetUserId, chatRoomId, partyTitle, senderName); } + // 승인 대기 리마인드 생성(레디스 등록) + public void reservePartyRequestReminder(Long managerId, Long requesterId, Long partyId) { + partyNotificationService.reservePendingApprovalReminder( + managerId, + requesterId, + partyId + ); + } + + // 스케줄러가 호출 + public void notifyPendingApprovalReminder(Long hostId, Long partyId, Long requesterId, String requesterNickname) { + // PENDING 확인 + boolean isStillPending = partyParticipantRepository + .existsByParty_IdAndUser_UserIdAndStatus(partyId, requesterId, ParticipantStatus.PENDING); + + if (isStillPending) { + partyNotificationService.sendPendingApprovalReminder(hostId, requesterNickname, partyId); + } else { + // 이미 승인/거절되었다면 Redis에서 삭제 + String key = "pending_reminder:" + partyId + ":" + requesterId; + redisTemplate.delete(key); + } + } + // 스케줄러/채팅 서비스가 호출하며, 알림 도메인은 전송만 처리(보류) @Transactional public void notifyUnreadReminder(Long targetUserId, Long chatRoomId) { chatNotificationService.sendUnreadReminderNotification(targetUserId, chatRoomId); } + @Transactional + public void cancelPendingApprovalReminder(Long partyId, Long requesterId) { + String key = "pending_reminder:" + partyId + ":" + requesterId; + redisTemplate.delete(key); + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java index 3ca8e39..16c1874 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java @@ -96,4 +96,13 @@ public NotificationMulticastRequest createMemberLeaveRequest(List tokens return requestConverter.toMulticastRequest(tokens, title, detail, data); } + + public NotificationMulticastRequest createPendingApprovalReminderRequest(List tokens, Long partyId, String title, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + + data.put(KEY_EVENT_TYPE, "PENDING_APPROVAL_REMINDER"); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } } diff --git a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java index e02c6ec..6f2c1ab 100644 --- a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java +++ b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java @@ -82,4 +82,10 @@ List findActivePartiesByUserIdExcludingHost( int countByPartyIdAndStatusAndUser_UserIdNot(Long partyId, ParticipantStatus participantStatus, Long userId); List findAllByPartyAndStatus(Party party, ParticipantStatus status); + + boolean existsByParty_IdAndUser_UserIdAndStatus( + Long partyId, + Long userId, + ParticipantStatus status + ); } \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/party/service/PartyService.java b/src/main/java/ita/tinybite/domain/party/service/PartyService.java index f8a2349..19b15ee 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -243,12 +243,20 @@ public Long joinParty(Long partyId, Long userId) { PartyParticipant saved = partyParticipantRepository.save(participant); + // 즉시 알림 발송 notificationFacade.notifyNewPartyRequest( party.getHost().getUserId(), // 파티장 ID userId, // 신청자 ID partyId ); + // 리마인드 등록 + notificationFacade.reservePartyRequestReminder( + party.getHost().getUserId(), + userId, + partyId + ); + return oneToOneChatRoom.getId(); } @@ -459,6 +467,9 @@ public void approveParticipant(Long partyId, Long participantId, Long hostId) { // 단체 채팅방에 참여자 추가 groupChatRoom.addMember(participant.getUser()); + // 리마인드 예약 삭제 + notificationFacade.cancelPendingApprovalReminder(partyId, participant.getUser().getUserId()); + // 승인 알림 notificationFacade.notifyApproval( participant.getUser().getUserId(), @@ -492,6 +503,9 @@ public void rejectParticipant(Long partyId, Long participantId, Long hostId) { participant.getOneToOneChatRoom().deactivate(); } + // 리마인드 예약 삭제 + notificationFacade.cancelPendingApprovalReminder(partyId, participant.getUser().getUserId()); + notificationFacade.notifyRejection( participant.getUser().getUserId(), partyId