diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java index b472d2a794..7dafab30f5 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java @@ -1,11 +1,16 @@ package in.koreatech.koin.common.event; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; public record ArticleKeywordEvent( Integer articleId, Integer authorId, - ArticleKeyword keyword + Map matchedKeywordByUserId ) { + public ArticleKeywordEvent { + matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index da0d9ac0ba..daabf61dc3 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -28,6 +28,8 @@ public interface ArticleRepository extends Repository { Optional
findById(Integer articleId); + List
findAllByIdIn(List articleIds); + Page
findAll(Pageable pageable); Page
findAllByBoardIdNot(Integer boardId, PageRequest pageRequest); diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java index 611cdfab34..262f71fc79 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java @@ -34,4 +34,8 @@ public UserNotificationStatus(Integer userId, Integer notifiedArticleId) { this.userId = userId; this.notifiedArticleId = notifiedArticleId; } + + public void updateNotifiedArticleId(Integer notifiedArticleId) { + this.notifiedArticleId = notifiedArticleId; + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java index 48cc034a50..c1c00bc818 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java @@ -45,4 +45,6 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( @Param("articleKeywordId") Integer articleKeywordId, @Param("userId") Integer userId ); + + List findAllByArticleKeywordIdIn(List articleKeywordIds); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index a405128ef2..9ab80b0e63 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -1,9 +1,13 @@ package in.koreatech.koin.domain.community.keyword.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; @@ -14,4 +18,26 @@ public interface UserNotificationStatusRepository extends Repository findByUserId(Integer userId); boolean existsByNotifiedArticleIdAndUserId(Integer notifiedArticleId, Integer userId); + + @Query(""" + SELECT status.userId + FROM UserNotificationStatus status + WHERE status.notifiedArticleId = :notifiedArticleId + AND status.userId IN :userIds + """) + List findUserIdsByNotifiedArticleIdAndUserIdIn( + @Param("notifiedArticleId") Integer notifiedArticleId, + @Param("userIds") Collection userIds + ); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_notification_status (user_id, last_notified_article_id) + VALUES (:userId, :notifiedArticleId) + ON DUPLICATE KEY UPDATE last_notified_article_id = :notifiedArticleId + """, nativeQuery = true) + void upsertLastNotifiedArticleId( + @Param("userId") Integer userId, + @Param("notifiedArticleId") Integer notifiedArticleId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 0c083a2067..b8e79e4759 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -1,7 +1,6 @@ package in.koreatech.koin.domain.community.keyword.service; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -12,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.community.article.exception.ArticleNotFoundException; import in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -26,7 +26,6 @@ import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordSuggestCache; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; -import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; @@ -148,22 +147,30 @@ public ArticleKeywordsSuggestionResponse suggestKeywords() { } public void sendKeywordNotification(KeywordNotificationRequest request) { - List updateNotificationIds = request.updateNotification(); - - if (!updateNotificationIds.isEmpty()) { - List
articles = new ArrayList<>(); - - for (Integer id : updateNotificationIds) { - articles.add(articleRepository.getById(id)); - } + List updateNotificationIds = request.updateNotification().stream() + .distinct() + .toList(); - List keywordEvents = keywordExtractor.matchKeyword(articles, null); + if (updateNotificationIds.isEmpty()) { + return; + } - if (!keywordEvents.isEmpty()) { - for (ArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + List
fetchedArticles = articleRepository.findAllByIdIn(updateNotificationIds); + var articleById = fetchedArticles.stream() + .collect(Collectors.toMap(Article::getId, article -> article)); + List
articles = updateNotificationIds.stream() + .map(articleId -> { + Article article = articleById.get(articleId); + if (article == null) { + throw ArticleNotFoundException.withDetail("articleId: " + articleId); } - } + return article; + }) + .toList(); + + List keywordEvents = keywordExtractor.matchKeyword(articles, null); + for (ArticleKeywordEvent event : keywordEvents) { + eventPublisher.publishEvent(event); } } @@ -201,6 +208,6 @@ public void fetchTopKeywordsFromLastWeek() { @Transactional public void createNotifiedArticleStatus(Integer userId, Integer articleId) { - userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId)); + userNotificationStatusRepository.upsertLastNotifiedArticleId(userId, articleId); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java index b90e80141f..2fce59b974 100644 --- a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java +++ b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java @@ -1,7 +1,10 @@ package in.koreatech.koin.domain.community.util; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -10,8 +13,10 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import lombok.RequiredArgsConstructor; @Service @@ -22,9 +27,10 @@ public class KeywordExtractor { private static final int KEYWORD_BATCH_SIZE = 100; private final ArticleKeywordRepository articleKeywordRepository; + private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; public List matchKeyword(List
articles, Integer authorId) { - List keywordEvents = new ArrayList<>(); + Map> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>(); int offset = 0; while (true) { @@ -34,18 +40,57 @@ public List matchKeyword(List
articles, Integer au if (keywords.isEmpty()) { break; } + List keywordIds = keywords.stream() + .map(ArticleKeyword::getId) + .toList(); + Map> userMapsByKeywordId = articleKeywordUserMapRepository + .findAllByArticleKeywordIdIn(keywordIds) + .stream() + .filter(keywordUserMap -> !keywordUserMap.getIsDeleted()) + .collect(Collectors.groupingBy( + keywordUserMap -> keywordUserMap.getArticleKeyword().getId(), + LinkedHashMap::new, + Collectors.toList() + )); for (Article article : articles) { String title = article.getTitle(); for (ArticleKeyword keyword : keywords) { - if (title.contains(keyword.getKeyword())) { - keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, keyword)); + if (!title.contains(keyword.getKeyword())) { + continue; + } + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId + .computeIfAbsent(article.getId(), ignored -> new LinkedHashMap<>()); + + for (ArticleKeywordUserMap keywordUserMap : + userMapsByKeywordId.getOrDefault(keyword.getId(), List.of())) { + Integer userId = keywordUserMap.getUser().getId(); + matchedKeywordByUserId.merge( + userId, + keyword.getKeyword(), + this::pickHigherPriorityKeyword + ); } } } offset += KEYWORD_BATCH_SIZE; } + List keywordEvents = new ArrayList<>(); + for (Article article : articles) { + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId.get(article.getId()); + if (matchedKeywordByUserId != null && !matchedKeywordByUserId.isEmpty()) { + keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, matchedKeywordByUserId)); + } + } + return keywordEvents; } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; + } + return previousKeyword; + } } diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 30be4c43e2..53f985fe77 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -3,18 +3,24 @@ import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.transaction.event.TransactionalEventListener; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.domain.notification.model.Notification; @@ -38,35 +44,67 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 @TransactionalEventListener public void onKeywordRequest(ArticleKeywordEvent event) { + Map matchedKeywordByUserId = event.matchedKeywordByUserId(); + + if (matchedKeywordByUserId.isEmpty()) { + return; + } + Article article = articleRepository.getById(event.articleId()); Board board = article.getBoard(); - List notifications = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD) + Map keywordSubscribersByUserId = notificationSubscribeRepository + .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) .stream() .filter(this::hasDeviceToken) - .filter(subscribe -> isKeywordRegistered(event, subscribe)) - .filter(subscribe -> isNewNotifiedArticleId(event.articleId(), subscribe)) + .collect(Collectors.toMap( + subscribe -> subscribe.getUser().getId(), + Function.identity(), + (existing, ignored) -> existing, + LinkedHashMap::new + )); + + Set matchedUserIds = keywordSubscribersByUserId.keySet().stream() + .filter(matchedKeywordByUserId::containsKey) + .collect(Collectors.toSet()); + + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds( + event.articleId(), + matchedUserIds + ); + + List notifications = keywordSubscribersByUserId.values().stream() + .filter(subscribe -> matchedUserIds.contains(subscribe.getUser().getId())) + .filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !isMyArticle(event, subscribe)) - .map(subscribe -> createAndRecordNotification(article, board, event.keyword(), subscribe)) + .map(subscribe -> createNotification( + article, + board, + matchedKeywordByUserId.get(subscribe.getUser().getId()), + subscribe + )) .toList(); - notificationService.pushNotifications(notifications); + List deliveryResults = + notificationService.pushNotificationsWithResult(notifications); + for (NotificationService.NotificationDeliveryResult deliveryResult : deliveryResults) { + if (deliveryResult.delivered()) { + keywordService.createNotifiedArticleStatus(deliveryResult.notification().getUser().getId(), article.getId()); + } + } } private boolean hasDeviceToken(NotificationSubscribe subscribe) { - return subscribe.getUser().getDeviceToken() != null; + return StringUtils.hasText(subscribe.getUser().getDeviceToken()); } - private boolean isKeywordRegistered(ArticleKeywordEvent event, NotificationSubscribe subscribe) { - return event.keyword().getArticleKeywordUserMaps().stream() - .filter(map -> !map.getIsDeleted()) - .anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId())); - } - - private boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) { - Integer userId = subscribe.getUser().getId(); - return !userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId); + private Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { + if (subscriberUserIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) + ); } private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) { @@ -75,26 +113,23 @@ private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe sub return Objects.equals(authorId, subscriberId); } - private Notification createAndRecordNotification( + private Notification createNotification( Article article, Board board, - ArticleKeyword keyword, + String keyword, NotificationSubscribe subscribe ) { - Integer userId = subscribe.getUser().getId(); - String description = generateDescription(keyword.getKeyword()); + String description = generateDescription(keyword); Notification notification = notificationFactory.generateKeywordNotification( KEYWORD, article.getId(), - keyword.getKeyword(), + keyword, article.getTitle(), board.getId(), description, subscribe.getUser() ); - - keywordService.createNotifiedArticleStatus(userId, article.getId()); return notification; } diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java index 575e51ee1f..9e066cb298 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java @@ -7,4 +7,6 @@ public interface NotificationRepository extends Repository { Notification save(Notification notification); + + void saveAll(Iterable notifications); } diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java index c868f1ec87..f9d00d2541 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java @@ -17,6 +17,17 @@ public interface NotificationSubscribeRepository extends Repository findAllBySubscribeTypeAndDetailTypeIsNull(NotificationSubscribeType type); + @Query(""" + SELECT ns + FROM NotificationSubscribe ns + JOIN FETCH ns.user + WHERE ns.subscribeType = :subscribeType + AND ns.detailType IS NULL + """) + List findAllBySubscribeTypeAndDetailTypeIsNullWithUser( + @Param("subscribeType") NotificationSubscribeType subscribeType + ); + boolean existsByUserIdAndSubscribeTypeAndDetailTypeIsNull(Integer userId, NotificationSubscribeType type); boolean existsByUserIdAndSubscribeTypeAndDetailType( diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java new file mode 100644 index 0000000000..63deb3cb5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.notification.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationPersistenceService { + + private final NotificationRepository notificationRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAfterSend(Notification notification) { + notificationRepository.save(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index f449dc8a96..7a7c686cde 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -4,10 +4,13 @@ import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.getParentType; +import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import in.koreatech.koin.domain.dining.model.DiningType; import in.koreatech.koin.domain.notification.dto.NotificationStatusResponse; @@ -24,42 +27,68 @@ import in.koreatech.koin.infrastructure.fcm.FcmClient; import io.micrometer.common.util.StringUtils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class NotificationService { + public record NotificationDeliveryResult(Notification notification, boolean delivered) {} + private final UserRepository userRepository; private final NotificationRepository notificationRepository; + private final NotificationPersistenceService notificationPersistenceService; private final FcmClient fcmClient; private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; + @Transactional public void pushNotifications(List notifications) { - for (Notification notification : notifications) { - pushNotification(notification); + if (notifications.isEmpty()) { + return; + } + notificationRepository.saveAll(notifications); + runAfterCommit(() -> notifications.forEach(this::sendNotificationSafely)); + } + + @Transactional + public List pushNotificationsWithResult(List notifications) { + if (notifications.isEmpty()) { + return List.of(); } + + List deliveryResults = new ArrayList<>(notifications.size()); + // afterCommit 콜백은 트랜잭션 프록시가 반환되기 전에 실행되므로 호출자는 채워진 결과를 받는다. + runAfterCommit(() -> notifications.forEach(notification -> + deliveryResults.add(pushNotificationWithResult(notification)) + )); + return deliveryResults; } @Transactional public void pushNotification(Notification notification) { - notificationRepository.save(notification); - String deviceToken = notification.getUser().getDeviceToken(); - fcmClient.sendMessage( - deviceToken, - notification.getTitle(), - notification.getMessage(), - notification.getImageUrl(), - notification.getMobileAppPath(), - notification.getSchemeUri(), - notification.getType().toLowerCase() - ); + pushNotifications(List.of(notification)); + } + + private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { + try { + boolean delivered = sendNotificationWithResult(notification); + if (!delivered) { + return new NotificationDeliveryResult(notification, false); + } + saveNotificationAfterSend(notification); + return new NotificationDeliveryResult(notification, true); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + return new NotificationDeliveryResult(notification, false); + } } public NotificationStatusResponse getNotificationInfo(Integer userId) { User user = userRepository.getById(userId); - boolean isPermit = user.getDeviceToken() != null; + boolean isPermit = StringUtils.isNotBlank(user.getDeviceToken()); List subscribeList = notificationSubscribeRepository.findAllByUserId(userId); return NotificationStatusResponse.of(isPermit, subscribeList); } @@ -122,6 +151,7 @@ public void rejectNotificationByDetailType(Integer userId, NotificationDetailSub notificationSubscribeRepository.deleteByUserIdAndDetailType(userId, detailType); } + @Transactional public void sendDiningSoldOutNotifications(Integer dinningId, String place, DiningType diningType) { NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(diningType); var notifications = notificationSubscribeRepository.findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, detailType) @@ -136,6 +166,65 @@ public void sendDiningSoldOutNotifications(Integer dinningId, String place, Dini pushNotifications(notifications); } + private void sendNotificationSafely(Notification notification) { + try { + sendNotification(notification); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + } + } + + private void sendNotification(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + fcmClient.sendMessage( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private boolean sendNotificationWithResult(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + return fcmClient.sendMessageWithResult( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private void saveNotificationAfterSend(Notification notification) { + try { + notificationPersistenceService.saveAfterSend(notification); + } catch (Exception e) { + log.warn("발송된 알림 저장 중 예외가 발생했습니다.", e); + } + } + + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + + // Rollback된 데이터에 대한 푸시 전송을 막기 위해 커밋 이후에만 FCM을 호출한다. + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void ensureUserDeviceToken(String deviceToken) { if (StringUtils.isBlank(deviceToken)) { throw NotificationNotPermitException.withDetail("user.deviceToken: null"); diff --git a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java index f14b013319..776e98d5fc 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java +++ b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.ApnsConfig; @@ -33,23 +34,39 @@ public void sendMessage( String schemeUri, String type ) { - if (targetDeviceToken == null) { - return; + sendMessageWithResult(targetDeviceToken, title, content, imageUrl, path, schemeUri, type); + } + + public boolean sendMessageWithResult( + String targetDeviceToken, + String title, + String content, + String imageUrl, + MobileAppPath path, + String schemeUri, + String type + ) { + if (!StringUtils.hasText(targetDeviceToken)) { + return false; } - log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); + try { + log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); - ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); - AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); + AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + + Message message = Message.builder() + .setToken(targetDeviceToken) + .setApnsConfig(apnsConfig) + .setAndroidConfig(androidConfig) + .build(); - Message message = Message.builder() - .setToken(targetDeviceToken) - .setApnsConfig(apnsConfig) - .setAndroidConfig(androidConfig).build(); - try { String result = FirebaseMessaging.getInstance().send(message); log.info("FCM 알림 전송 성공: {}", result); + return true; } catch (Exception e) { log.warn("FCM 알림 전송 실패", e); + return false; } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java new file mode 100644 index 0000000000..6ff874eb0c --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.unit.domain.community.keyword.service; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class KeywordServiceTest { + + @InjectMocks + private KeywordService keywordService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordSuggestRepository articleKeywordSuggestRepository; + + @Mock + private ArticleRepository articleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordExtractor keywordExtractor; + + @Test + @DisplayName("중복 게시글 ID 요청은 제거 후 키워드 알림 이벤트를 발행한다.") + void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArticle() { + KeywordNotificationRequest request = new KeywordNotificationRequest(List.of(10, 10, 11, 11)); + Article article10 = mock(Article.class); + Article article11 = mock(Article.class); + when(article10.getId()).thenReturn(10); + when(article11.getId()).thenReturn(11); + when(articleRepository.findAllByIdIn(List.of(10, 11))).thenReturn(List.of(article11, article10)); + + ArticleKeywordEvent event10 = new ArticleKeywordEvent( + 10, + null, + Map.of(1, "A") + ); + ArticleKeywordEvent event11 = new ArticleKeywordEvent( + 11, + null, + Map.of(2, "B") + ); + when(keywordExtractor.matchKeyword(List.of(article10, article11), null)).thenReturn(List.of(event10, event11)); + + keywordService.sendKeywordNotification(request); + + verify(articleRepository).findAllByIdIn(List.of(10, 11)); + verify(keywordExtractor).matchKeyword(List.of(article10, article11), null); + verify(eventPublisher).publishEvent(event10); + verify(eventPublisher).publishEvent(event11); + verifyNoMoreInteractions(eventPublisher); + } + + @Test + @DisplayName("업데이트 알림 대상 게시글이 없으면 아무 작업도 수행하지 않는다.") + void sendKeywordNotification_withEmptyArticleIds_doesNothing() { + keywordService.sendKeywordNotification(new KeywordNotificationRequest(List.of())); + + verifyNoInteractions(articleRepository); + verifyNoInteractions(keywordExtractor); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("발송 이력 저장은 DB upsert를 사용한다.") + void createNotifiedArticleStatus_usesAtomicUpsert() { + keywordService.createNotifiedArticleStatus(1, 100); + + verify(userNotificationStatusRepository).upsertLastNotifiedArticleId(1, 100); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java new file mode 100644 index 0000000000..0c9c4f6076 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java @@ -0,0 +1,154 @@ +package in.koreatech.koin.unit.domain.community.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class KeywordExtractorTest { + + @InjectMocks + private KeywordExtractor keywordExtractor; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Test + @DisplayName("한 게시글에 여러 키워드가 매칭되면 사용자별 키워드를 병합한 이벤트 한 건만 생성된다.") + void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("근로장학생 모집"); + + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keywordA = createKeyword(1, "근로", subscriber); + ArticleKeyword keywordB = createKeyword(2, "근로장학", subscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keywordA, keywordB)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + keywordA.getArticleKeywordUserMaps().get(0), + keywordB.getArticleKeywordUserMaps().get(0) + )); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).hasSize(1); + ArticleKeywordEvent event = result.get(0); + assertThat(event.articleId()).isEqualTo(1); + assertThat(event.authorId()).isNull(); + assertThat(event.matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로장학")); + } + + @Test + @DisplayName("매칭되는 키워드가 없으면 이벤트를 생성하지 않는다.") + void matchKeyword_whenNoKeywordsMatch_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("근로장학생 모집"); + + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keyword = createKeyword(1, "장학금", subscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of(keyword.getArticleKeywordUserMaps().get(0))); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("여러 게시글이 각각 다른 키워드에 매칭되면 게시글별 이벤트를 생성한다.") + void matchKeyword_withMultipleArticles_createsEventPerArticle() { + Article firstArticle = mock(Article.class); + when(firstArticle.getId()).thenReturn(1); + when(firstArticle.getTitle()).thenReturn("근로장학생 모집"); + + Article secondArticle = mock(Article.class); + when(secondArticle.getId()).thenReturn(2); + when(secondArticle.getTitle()).thenReturn("국가장학금 신청 안내"); + + User firstSubscriber = UserFixture.id_설정_코인_유저(1); + User secondSubscriber = UserFixture.id_설정_코인_유저(2); + ArticleKeyword firstKeyword = createKeyword(1, "근로", firstSubscriber); + ArticleKeyword secondKeyword = createKeyword(2, "장학금", secondSubscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(firstKeyword, secondKeyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + firstKeyword.getArticleKeywordUserMaps().get(0), + secondKeyword.getArticleKeywordUserMaps().get(0) + )); + + List result = keywordExtractor.matchKeyword(List.of(firstArticle, secondArticle), null); + + assertThat(result).hasSize(2); + assertThat(result.get(0).articleId()).isEqualTo(1); + assertThat(result.get(0).matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로")); + assertThat(result.get(1).articleId()).isEqualTo(2); + assertThat(result.get(1).matchedKeywordByUserId()).isEqualTo(Map.of(2, "장학금")); + } + + @Test + @DisplayName("등록된 키워드가 없으면 빈 결과를 반환한다.") + void matchKeyword_whenNoKeywordsExist_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(articleKeywordRepository.findAll(any(Pageable.class))).thenReturn(List.of()); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + verifyNoInteractions(articleKeywordUserMapRepository); + } + + private ArticleKeyword createKeyword(Integer keywordId, String keyword, User... users) { + ArticleKeyword articleKeyword = ArticleKeyword.builder() + .keyword(keyword) + .build(); + ReflectionTestUtils.setField(articleKeyword, "id", keywordId); + for (User user : users) { + ArticleKeywordUserMap userMap = ArticleKeywordUserMap.builder() + .articleKeyword(articleKeyword) + .user(user) + .build(); + articleKeyword.addUserMap(userMap); + } + return articleKeyword; + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java new file mode 100644 index 0000000000..2b8b4271e3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -0,0 +1,281 @@ +package in.koreatech.koin.unit.domain.notification.eventlistener; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.Board; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.notification.eventlistener.ArticleKeywordEventListener; +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class ArticleKeywordEventListenerTest { + + @InjectMocks + private ArticleKeywordEventListener articleKeywordEventListener; + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationFactory notificationFactory; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordService keywordService; + + @Mock + private ArticleRepository articleRepository; + + @Test + @DisplayName("중복 구독이 있어도 사용자당 알림은 한 번만 발송된다.") + void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { + Integer articleId = 100; + Integer boardId = 12; + Integer userId = 1; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribeA = createKeywordSubscribe(subscriber); + NotificationSubscribe subscribeB = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribeA, subscribeB)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(subscriber); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, true))); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(keywordService, times(1)).createNotifiedArticleStatus(userId, articleId); + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + } + + @Test + @DisplayName("게시글 작성자 본인에게는 키워드 알림을 보내지 않는다.") + void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { + Integer articleId = 200; + Integer userId = 2; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, userId, Map.of(userId, "A")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); + } + + @Test + @DisplayName("이미 해당 게시글 알림을 받은 사용자는 다시 발송하지 않는다.") + void onKeywordRequest_whenAlreadyNotified_skipsNotification() { + Integer articleId = 300; + Integer userId = 3; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "C")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of(userId)); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); + } + + @Test + @DisplayName("알림 전송 실패 시 발송 이력을 저장하지 않는다.") + void onKeywordRequest_whenDeliveryFails_doesNotRecordNotifiedStatus() { + Integer articleId = 400; + Integer boardId = 15; + Integer userId = 4; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, false))); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + } + + @Test + @DisplayName("기발송 사용자 조회는 매칭된 사용자 ID만 대상으로 수행한다.") + void onKeywordRequest_queriesNotifiedStatusOnlyForMatchedUsers() { + Integer articleId = 500; + Integer boardId = 16; + Integer matchedUserId = 5; + Integer unmatchedUserId = 6; + + User matchedUser = UserFixture.id_설정_코인_유저(matchedUserId); + matchedUser.permitNotification("matched-device-token"); + User unmatchedUser = UserFixture.id_설정_코인_유저(unmatchedUserId); + unmatchedUser.permitNotification("unmatched-device-token"); + + NotificationSubscribe matchedSubscribe = createKeywordSubscribe(matchedUser); + NotificationSubscribe unmatchedSubscribe = createKeywordSubscribe(unmatchedUser); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(matchedUserId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(matchedSubscribe, unmatchedSubscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId))) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(userNotificationStatusRepository) + .findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId)); + } + + private NotificationSubscribe createKeywordSubscribe(User user) { + return NotificationSubscribe.builder() + .subscribeType(ARTICLE_KEYWORD) + .user(user) + .build(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000000..7e51728538 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,162 @@ +package in.koreatech.koin.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationPersistenceService; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @InjectMocks + private NotificationService notificationService; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationPersistenceService notificationPersistenceService; + + @Mock + private FcmClient fcmClient; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private NotificationFactory notificationFactory; + + @Test + @DisplayName("알림 전송 결과 조회는 전송 성공 시에만 알림 레코드를 저장한다.") + void pushNotificationsWithResult_whenDelivered_savesNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isTrue(); + InOrder inOrder = inOrder(fcmClient, notificationPersistenceService); + inOrder.verify(fcmClient).sendMessageWithResult( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); + inOrder.verify(notificationPersistenceService).saveAfterSend(notification); + } + + @Test + @DisplayName("알림 전송 실패 시 알림 레코드를 저장하지 않는다.") + void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(false); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isFalse(); + verify(notificationPersistenceService, never()).saveAfterSend(notification); + } + + @Test + @DisplayName("배치 알림 중 일부 저장이 실패해도 발송 결과는 유지하고 다음 알림을 계속 처리한다.") + void pushNotificationsWithResult_whenSaveFails_keepsDeliveryResultAndContinuesNextNotification() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, true); + doThrow(new RuntimeException("save fail")).when(notificationPersistenceService).saveAfterSend(firstNotification); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isTrue(); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService).saveAfterSend(secondNotification); + } + + @Test + @DisplayName("배치 알림은 전송 성공 여부를 각각 반환하고 성공한 알림만 저장한다.") + void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, false); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isFalse(); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService, never()).saveAfterSend(secondNotification); + } + + @Test + @DisplayName("단건 알림 전송은 저장 후 FCM 전송을 수행한다.") + void pushNotification_savesNotificationBeforeSend() { + Notification notification = createNotification("device-token"); + + notificationService.pushNotification(notification); + + InOrder inOrder = inOrder(notificationRepository, fcmClient); + inOrder.verify(notificationRepository).saveAll(List.of(notification)); + inOrder.verify(fcmClient).sendMessage( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); + verify(notificationRepository, never()).save(notification); + } + + private Notification createNotification(String deviceToken) { + User user = UserFixture.id_설정_코인_유저(1); + user.permitNotification(deviceToken); + + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(user); + when(notification.getTitle()).thenReturn("title"); + when(notification.getMessage()).thenReturn("message"); + when(notification.getImageUrl()).thenReturn(null); + when(notification.getMobileAppPath()).thenReturn(null); + when(notification.getSchemeUri()).thenReturn("scheme-uri"); + when(notification.getType()).thenReturn("message"); + return notification; + } +}