diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java deleted file mode 100644 index 4ba7ea1f4f..0000000000 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package in.koreatech.koin.common.event; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -import in.koreatech.koin.domain.community.keyword.enums.KeywordCategory; - -public record ArticleKeywordEvent( - Integer articleId, - Integer authorId, - KeywordCategory category, - Map matchedKeywordByUserId -) { - - public ArticleKeywordEvent { - matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); - } -} diff --git a/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java new file mode 100644 index 0000000000..c9b19b80db --- /dev/null +++ b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.common.event; + +import java.util.List; +import java.util.Map; + +public record KoreatechArticleKeywordEvent( + Integer articleId, + Integer boardId, + String articleTitle, + MatchedKeywordUsers matchedKeywordUsers +) { + public record MatchedKeywordUsers( + Map> userIdsByKeyword + ) { + + } + + public static KoreatechArticleKeywordEvent of( + Integer articleId, + Integer boardId, + String articleTitle, + Map> userIdsByKeyword + ) { + return new KoreatechArticleKeywordEvent( + articleId, + boardId, + articleTitle, + new MatchedKeywordUsers(userIdsByKeyword) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java new file mode 100644 index 0000000000..2269011f40 --- /dev/null +++ b/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.common.event; + +import java.util.List; +import java.util.Map; + +public record LostItemKeywordEvent( + Integer articleId, + String articleTitle, + Integer authorId, + MatchedKeywordUsers matchedKeywordUsers +) { + public record MatchedKeywordUsers( + Map> userIdsByKeyword + ) { + + } + + public static LostItemKeywordEvent of( + Integer articleId, + String articleTitle, + Integer authorId, + Map> userIdsByKeyword + ) { + return new LostItemKeywordEvent( + articleId, + articleTitle, + authorId, + new MatchedKeywordUsers(userIdsByKeyword) + ); + } +} 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 1733dc9973..924c814e66 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 @@ -4,6 +4,7 @@ import static in.koreatech.koin.domain.community.article.service.ArticleService.NOTICE_BOARD_ID; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -29,7 +30,7 @@ public interface ArticleRepository extends Repository { Optional
findById(Integer articleId); - List
findAllByIdIn(List articleIds); + List
findAllByIdIn(Collection articleIds); Page
findAll(Pageable pageable); diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java index 505722cafd..e63834ed3f 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -14,7 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.common.event.LostItemKeywordEvent; import in.koreatech.koin.common.model.Criteria; import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponse; import in.koreatech.koin.domain.community.article.dto.LostItemArticleResponseV2; @@ -27,14 +28,15 @@ import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.model.LostItemArticle; import in.koreatech.koin.domain.community.article.model.filter.LostItemAuthorFilter; -import in.koreatech.koin.domain.community.article.model.filter.LostItemFoundStatus; import in.koreatech.koin.domain.community.article.model.filter.LostItemCategoryFilter; +import in.koreatech.koin.domain.community.article.model.filter.LostItemFoundStatus; import in.koreatech.koin.domain.community.article.model.filter.LostItemSortType; import in.koreatech.koin.domain.community.article.model.redis.PopularKeywordTracker; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; import in.koreatech.koin.domain.community.article.repository.BoardRepository; import in.koreatech.koin.domain.community.article.repository.LostItemArticleRepository; import in.koreatech.koin.domain.community.keyword.enums.KeywordCategory; +import in.koreatech.koin.domain.community.keyword.service.ArticleKeywordUserMatcher; import in.koreatech.koin.domain.community.util.KeywordExtractor; import in.koreatech.koin.domain.organization.model.Organization; import in.koreatech.koin.domain.organization.repository.OrganizationRepository; @@ -67,6 +69,7 @@ public class LostItemArticleService { private final PopularKeywordTracker popularKeywordTracker; private final ApplicationEventPublisher eventPublisher; private final KeywordExtractor keywordExtractor; + private final ArticleKeywordUserMatcher articleKeywordUserMatcher; @Transactional public LostItemArticlesResponse searchLostItemArticles(String query, Integer page, Integer limit, @@ -253,11 +256,26 @@ private Board getBoard(Integer boardId, Article article) { } private void sendKeywordNotification(List
articles, Integer authorId) { - List keywordEvents = keywordExtractor.matchKeyword(articles, authorId, KeywordCategory.LOST_ITEM); - if (!keywordEvents.isEmpty()) { - for (ArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + for (Article article : articles) { + List matchedKeywords = keywordExtractor.matchKeywords(article.getTitle(), KeywordCategory.LOST_ITEM); + if (matchedKeywords.isEmpty()) { + continue; } + + Map> userIdsByKeyword = articleKeywordUserMatcher.findUserIdsByMatchedKeyword( + KeywordCategory.LOST_ITEM, + matchedKeywords + ); + if (userIdsByKeyword.isEmpty()) { + continue; + } + + eventPublisher.publishEvent(LostItemKeywordEvent.of( + article.getId(), + article.getTitle(), + authorId, + userIdsByKeyword + )); } } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java b/src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java index 627c9c0026..f140a9ccc4 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java @@ -1,6 +1,6 @@ package in.koreatech.koin.domain.community.keyword.dto; -import java.util.List; +import java.util.Set; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -9,10 +9,10 @@ import jakarta.validation.constraints.NotNull; @JsonNaming(SnakeCaseStrategy.class) -public record KeywordNotificationRequest ( +public record KeywordNotificationRequest( @Schema(description = "업데이트 된 공지사항 목록", example = "[1, 2, 3]") @NotNull - List updateNotification + Set updateNotification ) { } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java index 5502207ede..fce40650d6 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java @@ -78,6 +78,14 @@ public void applyFiltered(Boolean isFiltered) { this.isFiltered = isFiltered; } + public boolean hasLongerKeywordThan(ArticleKeyword other) { + return other == null || compareKeywordLength(other) > 0; + } + + private int compareKeywordLength(ArticleKeyword other) { + return Integer.compare(keyword.length(), other.keyword.length()); + } + public void delete() { this.isDeleted = true; } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java index d7a46d5b1f..1ff78c6f9d 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java @@ -29,8 +29,6 @@ Optional findByKeywordAndCategoryIncludingDeleted( ArticleKeyword save(ArticleKeyword articleKeyword); - void deleteById(Integer id); - Optional findById(Integer id); List findAllByCategory(KeywordCategory category, Pageable pageable); 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 b1e325319c..e8560af9bb 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 @@ -17,8 +17,6 @@ public interface ArticleKeywordUserMapRepository extends Repository findById(Integer keywordUserMapId); @@ -30,17 +28,6 @@ default ArticleKeywordUserMap getById(Integer keywordUserMapId) { List findAllByUserIdAndArticleKeywordCategory(Integer userId, KeywordCategory category); - @Query(""" - SELECT akw.keyword FROM ArticleKeywordUserMap akum - JOIN akum.articleKeyword akw - WHERE akum.user.id = :userId - AND akw.category = :category - """) - List findAllKeywordByUserIdAndCategory( - @Param("userId") Integer userId, - @Param("category") KeywordCategory category - ); - @Query(value = """ SELECT * FROM article_keyword_user_map akum WHERE akum.keyword_id = :articleKeywordId @@ -51,5 +38,17 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( @Param("userId") Integer userId ); - List findAllByArticleKeywordIdIn(List articleKeywordIds); + @Query(""" + SELECT akum + FROM ArticleKeywordUserMap akum + JOIN FETCH akum.articleKeyword akw + JOIN FETCH akum.user + WHERE akw.category = :category + AND akw.keyword IN :keywords + AND akum.isDeleted = false + """) + List findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + @Param("category") KeywordCategory category, + @Param("keywords") List keywords + ); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java new file mode 100644 index 0000000000..fdb390e1b6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.community.keyword.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.community.keyword.enums.KeywordCategory; +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.ArticleKeywordUserMapRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArticleKeywordUserMatcher { + + private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + public Map> findUserIdsByMatchedKeyword( + KeywordCategory category, + List matchedKeywords + ) { + Map keywordByUserId = new LinkedHashMap<>(); + List keywordUserMaps = articleKeywordUserMapRepository + .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(category, matchedKeywords); + + for (ArticleKeywordUserMap keywordUserMap : keywordUserMaps) { + Integer userId = keywordUserMap.getUser().getId(); + ArticleKeyword keyword = keywordUserMap.getArticleKeyword(); + ArticleKeyword previousKeyword = keywordByUserId.get(userId); + + if (keyword.hasLongerKeywordThan(previousKeyword)) { + keywordByUserId.put(userId, keyword); + } + } + + Map> userIdsByKeyword = new LinkedHashMap<>(); + for (Map.Entry entry : keywordByUserId.entrySet()) { + Integer userId = entry.getKey(); + String keyword = entry.getValue().getKeyword(); + userIdsByKeyword.computeIfAbsent(keyword, ignored -> new ArrayList<>()).add(userId); + } + return userIdsByKeyword; + } +} 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 f7ef91dc72..ba93c23aa2 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 @@ -2,17 +2,18 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.domain.community.article.exception.ArticleNotFoundException; +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; 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; @@ -25,13 +26,11 @@ import in.koreatech.koin.domain.community.keyword.exception.KeywordDuplicationException; import in.koreatech.koin.domain.community.keyword.exception.KeywordLimitExceededException; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; -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.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.util.KeywordExtractor; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.global.auth.exception.AuthorizationException; @@ -53,8 +52,8 @@ public class KeywordService { private final ArticleKeywordSuggestRepository articleKeywordSuggestRepository; private final ArticleRepository articleRepository; private final UserRepository userRepository; - private final UserNotificationStatusRepository userNotificationStatusRepository; private final KeywordExtractor keywordExtractor; + private final ArticleKeywordUserMatcher articleKeywordUserMatcher; @Transactional public ArticleKeywordResponse createKeyword( @@ -153,30 +152,29 @@ public ArticleKeywordsSuggestionResponse suggestKeywords(KeywordCategory categor } public void sendKeywordNotification(KeywordNotificationRequest request) { - List updateNotificationIds = request.updateNotification().stream() - .distinct() - .toList(); + Set updateNotificationIds = request.updateNotification(); + List
articles = articleRepository.findAllByIdIn(updateNotificationIds); - if (updateNotificationIds.isEmpty()) { - return; - } + for (Article article : articles) { + List matchedKeywords = keywordExtractor.matchKeywords(article.getTitle(), KeywordCategory.KOREATECH); + if (matchedKeywords.isEmpty()) { + continue; + } - 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(); + Map> userIdsByKeyword = articleKeywordUserMatcher.findUserIdsByMatchedKeyword( + KeywordCategory.KOREATECH, + matchedKeywords + ); + if (userIdsByKeyword.isEmpty()) { + continue; + } - List keywordEvents = keywordExtractor.matchKeyword(articles, null, KeywordCategory.KOREATECH); - for (ArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + eventPublisher.publishEvent(KoreatechArticleKeywordEvent.of( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + userIdsByKeyword + )); } } @@ -219,8 +217,4 @@ public void fetchTopKeywordsFromLastWeek() { } } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void createNotifiedArticleStatus(Integer userId, Integer 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 f6affc0ad6..f0a59f69bd 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,23 +1,16 @@ 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; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.keyword.enums.KeywordCategory; 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 @@ -28,10 +21,9 @@ 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, KeywordCategory category) { - Map> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>(); + public List matchKeywords(String title, KeywordCategory category) { + List matchedKeywords = new ArrayList<>(); int offset = 0; while (true) { @@ -41,57 +33,14 @@ 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())) { - 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 - ); - } + for (ArticleKeyword keyword : keywords) { + if (title.contains(keyword.getKeyword())) { + matchedKeywords.add(keyword.getKeyword()); } } 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, category, matchedKeywordByUserId)); - } - } - - return keywordEvents; - } - - private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { - if (candidateKeyword.length() > previousKeyword.length()) { - return candidateKeyword; - } - return previousKeyword; + return matchedKeywords; } } 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 5bed9890ba..947252f920 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 @@ -1,175 +1,26 @@ package in.koreatech.koin.domain.notification.eventlistener; -import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; -import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; -import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.LOST_ITEM; -import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; -import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.LOST_ITEM_KEYWORD; import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; -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.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; -import org.springframework.util.StringUtils; -import in.koreatech.koin.common.event.ArticleKeywordEvent; -import in.koreatech.koin.common.model.MobileAppPath; -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.model.Notification; -import in.koreatech.koin.domain.notification.model.NotificationFactory; -import in.koreatech.koin.domain.notification.model.NotificationSubscribe; -import in.koreatech.koin.domain.notification.model.NotificationSubscribeType; -import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; -import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; +import in.koreatech.koin.domain.notification.service.ArticleKeywordNotificationService; import lombok.RequiredArgsConstructor; @Component -@RequiredArgsConstructor @Profile("!test") -public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈니스로직 제거 및 알림 책임만 갖도록) +@RequiredArgsConstructor +public class ArticleKeywordEventListener { - private final NotificationService notificationService; - private final NotificationFactory notificationFactory; - private final NotificationSubscribeRepository notificationSubscribeRepository; - private final UserNotificationStatusRepository userNotificationStatusRepository; - private final KeywordService keywordService; - private final ArticleRepository articleRepository; + private final ArticleKeywordNotificationService articleKeywordNotificationService; @Async(value = "keywordNotificationTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) - public void onKeywordRequest(ArticleKeywordEvent event) { - Map matchedKeywordByUserId = event.matchedKeywordByUserId(); - - if (matchedKeywordByUserId.isEmpty()) { - return; - } - - Article article = articleRepository.getById(event.articleId()); - Board board = article.getBoard(); - MobileAppPath appPath = getAppPath(event); - - Map keywordSubscribersByUserId = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(getSubscribeType(event)) - .stream() - .filter(this::hasDeviceToken) - .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 -> createNotification( - article, - board, - appPath, - event, - matchedKeywordByUserId.get(subscribe.getUser().getId()), - subscribe - )) - .toList(); - - 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 StringUtils.hasText(subscribe.getUser().getDeviceToken()); - } - - private NotificationSubscribeType getSubscribeType(ArticleKeywordEvent event) { - if (event.category() == KOREATECH) { - return ARTICLE_KEYWORD; - } - if (event.category() == LOST_ITEM) { - return LOST_ITEM_KEYWORD; - } - throw new IllegalArgumentException("지원하지 않는 키워드 카테고리입니다: " + event.category()); - } - - private MobileAppPath getAppPath(ArticleKeywordEvent event) { - if (event.category() == KOREATECH) { - return KEYWORD; - } - if (event.category() == LOST_ITEM) { - return MobileAppPath.LOST_ITEM; - } - throw new IllegalArgumentException("지원하지 않는 키워드 카테고리입니다: " + event.category()); - } - - 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) { - Integer authorId = event.authorId(); - Integer subscriberId = subscribe.getUser().getId(); - return Objects.equals(authorId, subscriberId); - } - - private Notification createNotification( - Article article, - Board board, - MobileAppPath appPath, - ArticleKeywordEvent event, - String keyword, - NotificationSubscribe subscribe - ) { - String description = generateDescription(event, keyword); - - return notificationFactory.generateKeywordNotification( - appPath, - article.getId(), - keyword, - article.getTitle(), - board.getId(), - description, - subscribe.getUser() - ); - } - - private String generateDescription(ArticleKeywordEvent event, String keyword) { - if (event.category() == LOST_ITEM) { - return "방금 등록된 %s 분실물 게시물을 확인해보세요!".formatted(keyword); - } - return "방금 등록된 %s 공지를 확인해보세요!".formatted(keyword); + public void onKeywordRequest(KoreatechArticleKeywordEvent event) { + articleKeywordNotificationService.notifyArticleKeyword(event); } } diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java new file mode 100644 index 0000000000..07483a9e9d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.notification.eventlistener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.common.event.LostItemKeywordEvent; +import in.koreatech.koin.domain.notification.service.LostItemKeywordNotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@Profile("!test") +@RequiredArgsConstructor +public class LostItemKeywordEventListener { + + private final LostItemKeywordNotificationService lostItemKeywordNotificationService; + + @Async(value = "keywordNotificationTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onLostItemKeywordRequest(LostItemKeywordEvent event) { + lostItemKeywordNotificationService.notifyLostItemKeyword(event); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java index 7979789dc5..ec340f0065 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java @@ -146,14 +146,31 @@ public Notification generateKeywordNotification( String keyword, String title, Integer boardId, - String description, User target ) { return new Notification( path, generateKeywordSchemeUri(path, eventKeywordId, keyword, boardId), title, - description, + "방금 등록된 %s 공지를 확인해보세요!".formatted(keyword), + null, + NotificationType.MESSAGE, + target + ); + } + + public Notification generateLostItemKeywordNotification( + MobileAppPath path, + Integer eventKeywordId, + String keyword, + String title, + User target + ) { + return new Notification( + path, + generateLostItemKeywordSchemeUri(path, eventKeywordId, keyword), + title, + "방금 등록된 %s 분실물 게시물을 확인해보세요!".formatted(keyword), null, NotificationType.MESSAGE, target @@ -212,12 +229,16 @@ private String generateKeywordSchemeUri(MobileAppPath path, Integer eventId, Str if (keyword == null) { return generateSchemeUri(path, eventId); } - if (path == MobileAppPath.LOST_ITEM) { - return String.format("%s?id=%d&keyword=%s", path.getPath(), eventId, keyword); - } return String.format("%s?id=%d&keyword=%s&board-id=%s", path.getPath(), eventId, keyword, boardId); } + private String generateLostItemKeywordSchemeUri(MobileAppPath path, Integer eventId, String keyword) { + if (keyword == null) { + return generateSchemeUri(path, eventId); + } + return String.format("%s?id=%d&keyword=%s", path.getPath(), eventId, keyword); + } + private String generateChatMessageSchemeUri(MobileAppPath path, Integer articleId, Integer chatRoomId) { if (chatRoomId == null) { return generateSchemeUri(path, articleId); 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 9e066cb298..68c738bb69 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 @@ -1,6 +1,10 @@ package in.koreatech.koin.domain.notification.repository; +import java.util.List; + +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.notification.model.Notification; @@ -9,4 +13,15 @@ public interface NotificationRepository extends Repository { Notification save(Notification notification); void saveAll(Iterable notifications); + + @Query(""" + SELECT DISTINCT n.user.id + FROM Notification n + WHERE n.schemeUri LIKE :schemeUriPattern + AND n.user.id IN :userIds + """) + List findUserIdsBySchemeUriLikeAndUserIdIn( + @Param("schemeUriPattern") String schemeUriPattern, + @Param("userIds") List userIds + ); } 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 f9d00d2541..cdf7407875 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 @@ -28,6 +28,20 @@ List findAllBySubscribeTypeAndDetailTypeIsNullWithUser( @Param("subscribeType") NotificationSubscribeType subscribeType ); + @Query(""" + SELECT ns + FROM NotificationSubscribe ns + JOIN FETCH ns.user u + WHERE ns.subscribeType = :subscribeType + AND ns.detailType IS NULL + AND u.deviceToken IS NOT NULL + AND u.id IN :userIds + """) + List findArticleKeywordSubscribesByUserIdIn( + @Param("subscribeType") NotificationSubscribeType subscribeType, + @Param("userIds") List userIds + ); + boolean existsByUserIdAndSubscribeTypeAndDetailTypeIsNull(Integer userId, NotificationSubscribeType type); boolean existsByUserIdAndSubscribeTypeAndDetailType( diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java new file mode 100644 index 0000000000..de1a71dd41 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java @@ -0,0 +1,111 @@ +package in.koreatech.koin.domain.notification.service; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; +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.NotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ArticleKeywordNotificationService { + + private final NotificationFactory notificationFactory; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final NotificationRepository notificationRepository; + private final NotificationService notificationService; + + public void notifyArticleKeyword(KoreatechArticleKeywordEvent event) { + Map> userIdsByKeyword = event.matchedKeywordUsers().userIdsByKeyword(); + List matchedUserIds = getMatchedUserIds(userIdsByKeyword); + if (matchedUserIds.isEmpty()) { + return; + } + + Map subscribesByUserId = findSubscribesByUserId(matchedUserIds); + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds(event.articleId(), matchedUserIds); + List notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId, + alreadyNotifiedUserIds); + + notificationService.pushNotificationsWithResult(notifications); + } + + private List getMatchedUserIds(Map> userIdsByKeyword) { + Set userIds = new HashSet<>(); + for (List keywordUserIds : userIdsByKeyword.values()) { + userIds.addAll(keywordUserIds); + } + return new ArrayList<>(userIds); + } + + private Map findSubscribesByUserId(List userIds) { + Map subscribesByUserId = new LinkedHashMap<>(); + List subscribes = notificationSubscribeRepository + .findArticleKeywordSubscribesByUserIdIn(ARTICLE_KEYWORD, userIds); + + for (NotificationSubscribe subscribe : subscribes) { + Integer userId = subscribe.getUser().getId(); + subscribesByUserId.putIfAbsent(userId, subscribe); + } + return subscribesByUserId; + } + + private Set getAlreadyNotifiedUserIds(Integer articleId, List userIds) { + return new HashSet<>(notificationRepository + .findUserIdsBySchemeUriLikeAndUserIdIn("%s?id=%d&%%".formatted(KEYWORD.getPath(), articleId), userIds)); + } + + private List createNotifications( + KoreatechArticleKeywordEvent event, + Map> userIdsByKeyword, + Map subscribesByUserId, + Set alreadyNotifiedUserIds + ) { + List notifications = new ArrayList<>(); + for (Map.Entry> entry : userIdsByKeyword.entrySet()) { + String keyword = entry.getKey(); + for (Integer userId : entry.getValue()) { + if (alreadyNotifiedUserIds.contains(userId)) { + continue; + } + + NotificationSubscribe subscribe = subscribesByUserId.get(userId); + if (subscribe == null) { + continue; + } + + notifications.add(createNotification(event, keyword, subscribe)); + } + } + return notifications; + } + + private Notification createNotification( + KoreatechArticleKeywordEvent event, + String keyword, + NotificationSubscribe subscribe + ) { + return notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + keyword, + event.articleTitle(), + event.boardId(), + subscribe.getUser() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java new file mode 100644 index 0000000000..19a579f2e7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java @@ -0,0 +1,119 @@ +package in.koreatech.koin.domain.notification.service; + +import static in.koreatech.koin.common.model.MobileAppPath.LOST_ITEM; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.LOST_ITEM_KEYWORD; + +import java.util.ArrayList; +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 org.springframework.stereotype.Service; + +import in.koreatech.koin.common.event.LostItemKeywordEvent; +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.NotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LostItemKeywordNotificationService { + + private final NotificationFactory notificationFactory; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final NotificationRepository notificationRepository; + private final NotificationService notificationService; + + public void notifyLostItemKeyword(LostItemKeywordEvent event) { + Map> userIdsByKeyword = event.matchedKeywordUsers().userIdsByKeyword(); + List matchedUserIds = getMatchedUserIds(userIdsByKeyword); + if (matchedUserIds.isEmpty()) { + return; + } + + Map subscribesByUserId = findSubscribesByUserId(matchedUserIds); + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds(event.articleId(), matchedUserIds); + List notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId, + alreadyNotifiedUserIds); + + notificationService.pushNotificationsWithResult(notifications); + } + + private List getMatchedUserIds(Map> userIdsByKeyword) { + Set userIds = new HashSet<>(); + for (List keywordUserIds : userIdsByKeyword.values()) { + userIds.addAll(keywordUserIds); + } + return new ArrayList<>(userIds); + } + + private Map findSubscribesByUserId(List userIds) { + Map subscribesByUserId = new LinkedHashMap<>(); + List subscribes = notificationSubscribeRepository + .findArticleKeywordSubscribesByUserIdIn(LOST_ITEM_KEYWORD, userIds); + + for (NotificationSubscribe subscribe : subscribes) { + Integer userId = subscribe.getUser().getId(); + subscribesByUserId.putIfAbsent(userId, subscribe); + } + return subscribesByUserId; + } + + private Set getAlreadyNotifiedUserIds(Integer articleId, List userIds) { + String schemeUriPattern = "%s?id=%d&%%".formatted(LOST_ITEM.getPath(), articleId); + return new HashSet<>(notificationRepository.findUserIdsBySchemeUriLikeAndUserIdIn(schemeUriPattern, userIds)); + } + + private List createNotifications( + LostItemKeywordEvent event, + Map> userIdsByKeyword, + Map subscribesByUserId, + Set alreadyNotifiedUserIds + ) { + List notifications = new ArrayList<>(); + for (Map.Entry> entry : userIdsByKeyword.entrySet()) { + String keyword = entry.getKey(); + for (Integer userId : entry.getValue()) { + if (alreadyNotifiedUserIds.contains(userId)) { + continue; + } + + if (isMyArticle(event, userId)) { + continue; + } + + NotificationSubscribe subscribe = subscribesByUserId.get(userId); + if (subscribe == null) { + continue; + } + + notifications.add(createNotification(event, keyword, subscribe)); + } + } + return notifications; + } + + private boolean isMyArticle(LostItemKeywordEvent event, Integer subscriberId) { + return Objects.equals(event.authorId(), subscriberId); + } + + private Notification createNotification( + LostItemKeywordEvent event, + String keyword, + NotificationSubscribe subscribe + ) { + return notificationFactory.generateLostItemKeywordNotification( + LOST_ITEM, + event.articleId(), + keyword, + event.articleTitle(), + subscribe.getUser() + ); + } +} 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 deleted file mode 100644 index 84f1750653..0000000000 --- a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package in.koreatech.koin.unit.domain.community.keyword.service; - -import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import java.lang.reflect.Method; -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 org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -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.model.ArticleKeyword; -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, - KOREATECH, - Map.of(1, "A") - ); - ArticleKeywordEvent event11 = new ArticleKeywordEvent( - 11, - null, - KOREATECH, - Map.of(2, "B") - ); - when(keywordExtractor.matchKeyword(List.of(article10, article11), null, KOREATECH)) - .thenReturn(List.of(event10, event11)); - - keywordService.sendKeywordNotification(request); - - verify(articleRepository).findAllByIdIn(List.of(10, 11)); - verify(keywordExtractor).matchKeyword(List.of(article10, article11), null, KOREATECH); - 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); - } - - @Test - @DisplayName("발송 이력 저장은 항상 새로운 트랜잭션에서 수행한다.") - void createNotifiedArticleStatus_startsNewTransaction() throws NoSuchMethodException { - Method method = KeywordService.class.getMethod("createNotifiedArticleStatus", Integer.class, Integer.class); - Transactional transactional = method.getAnnotation(Transactional.class); - - assertThat(transactional).isNotNull(); - assertThat(transactional.propagation()).isEqualTo(Propagation.REQUIRES_NEW); - } - - private ArticleKeyword argThatKeywordCategory( - in.koreatech.koin.domain.community.keyword.enums.KeywordCategory category) { - return org.mockito.ArgumentMatchers.argThat(keyword -> keyword.getCategory() == category); - } -} 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 deleted file mode 100644 index 802011899d..0000000000 --- a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package in.koreatech.koin.unit.domain.community.util; - -import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -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.findAllByCategory(eq(KOREATECH), 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, KOREATECH); - - assertThat(result).hasSize(1); - ArticleKeywordEvent event = result.get(0); - assertThat(event.articleId()).isEqualTo(1); - assertThat(event.authorId()).isNull(); - assertThat(event.category()).isEqualTo(KOREATECH); - 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.findAllByCategory(eq(KOREATECH), 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, KOREATECH); - - 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.findAllByCategory(eq(KOREATECH), 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, - KOREATECH); - - assertThat(result).hasSize(2); - assertThat(result.get(0).articleId()).isEqualTo(1); - assertThat(result.get(0).category()).isEqualTo(KOREATECH); - assertThat(result.get(0).matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로")); - assertThat(result.get(1).articleId()).isEqualTo(2); - assertThat(result.get(1).category()).isEqualTo(KOREATECH); - 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.findAllByCategory(eq(KOREATECH), any(Pageable.class))).thenReturn(List.of()); - - List result = keywordExtractor.matchKeyword(List.of(article), null, KOREATECH); - - 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 deleted file mode 100644 index cb0445b0fe..0000000000 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ /dev/null @@ -1,300 +0,0 @@ -package in.koreatech.koin.unit.domain.notification.eventlistener; - -import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; -import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; -import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; - -import java.lang.reflect.Method; -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 org.springframework.scheduling.annotation.Async; -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.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.model.NotificationSubscribeType; -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 - void 키워드_알림_이벤트_리스너는_전용_executor에서_커밋_이후_비동기로_실행된다() throws NoSuchMethodException { - Method method = ArticleKeywordEventListener.class.getMethod("onKeywordRequest", ArticleKeywordEvent.class); - - Async async = method.getAnnotation(Async.class); - TransactionalEventListener transactionalEventListener = method.getAnnotation(TransactionalEventListener.class); - - assertThat(async).isNotNull(); - assertThat(async.value()).isEqualTo("keywordNotificationTaskExecutor"); - assertThat(transactionalEventListener).isNotNull(); - assertThat(transactionalEventListener.phase()).isEqualTo(AFTER_COMMIT); - } - - @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, KOREATECH, 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, KOREATECH, 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, KOREATECH, 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, KOREATECH, 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, KOREATECH, 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 createSubscribe(user, ARTICLE_KEYWORD); - } - - private NotificationSubscribe createSubscribe(User user, NotificationSubscribeType subscribeType) { - return NotificationSubscribe.builder() - .subscribeType(subscribeType) - .user(user) - .build(); - } -}