From 5ccdfacbb2e6cccc6cead5f621b5fcb607ff12e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:25:09 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/common/event/LostItemKeywordEvent.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java 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 000000000..9b6f19174 --- /dev/null +++ b/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.common.event; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public record LostItemKeywordEvent( + Integer articleId, + Integer authorId, + Map matchedKeywordByUserId +) { + + public LostItemKeywordEvent { + matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + } +} From c9264c6ca3c144c18161a153ee4d8725fe98ca95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:26:50 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LostItemKeywordEventListener.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java 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 000000000..8c933a0c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.notification.eventlistener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +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 lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LostItemKeywordEventListener { + + @Async(value = "keywordNotificationTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void onLostItemKeywordRequest(LostItemKeywordEvent event) { + + } +} From 196f0f6b019825cbee63f2731ed994a35b3a6f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:33:28 +0900 Subject: [PATCH 03/26] =?UTF-8?q?feat:=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LostItemArticleService.java | 12 +-- .../util/LostItemKeywordExtractor.java | 97 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java 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 505722caf..2a43e0530 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 @@ -14,7 +14,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,15 +27,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.util.KeywordExtractor; +import in.koreatech.koin.domain.community.util.LostItemKeywordExtractor; import in.koreatech.koin.domain.organization.model.Organization; import in.koreatech.koin.domain.organization.repository.OrganizationRepository; import in.koreatech.koin.domain.user.model.User; @@ -66,7 +66,7 @@ public class LostItemArticleService { private final OrganizationRepository organizationRepository; private final PopularKeywordTracker popularKeywordTracker; private final ApplicationEventPublisher eventPublisher; - private final KeywordExtractor keywordExtractor; + private final LostItemKeywordExtractor keywordExtractor; @Transactional public LostItemArticlesResponse searchLostItemArticles(String query, Integer page, Integer limit, @@ -253,9 +253,9 @@ private Board getBoard(Integer boardId, Article article) { } private void sendKeywordNotification(List
articles, Integer authorId) { - List keywordEvents = keywordExtractor.matchKeyword(articles, authorId, KeywordCategory.LOST_ITEM); + List keywordEvents = keywordExtractor.matchKeyword(articles, authorId, KeywordCategory.LOST_ITEM); if (!keywordEvents.isEmpty()) { - for (ArticleKeywordEvent event : keywordEvents) { + for (LostItemKeywordEvent event : keywordEvents) { eventPublisher.publishEvent(event); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java new file mode 100644 index 000000000..fb4dfa712 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java @@ -0,0 +1,97 @@ +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.Component; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.common.event.LostItemKeywordEvent; +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.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LostItemKeywordExtractor { + + 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<>(); + int offset = 0; + + while (true) { + Pageable pageable = PageRequest.of(offset / KEYWORD_BATCH_SIZE, KEYWORD_BATCH_SIZE); + List keywords = articleKeywordRepository.findAllByCategory(category, pageable); + + 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 + ); + } + } + } + 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 LostItemKeywordEvent(article.getId(), authorId, matchedKeywordByUserId)); + } + } + + return keywordEvents; + } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; + } + return previousKeyword; + } +} From 0914c09d4784ee0923f6a4c4eb987f4fdb25268e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:36:03 +0900 Subject: [PATCH 04/26] =?UTF-8?q?fix:=20ArticleKeywordEvent=20=EC=8B=9C?= =?UTF-8?q?=EA=B7=B8=EB=8B=88=EC=B2=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...vent.java => KoreatechArticleKeywordEvent.java} | 8 ++------ .../community/keyword/service/KeywordService.java | 6 +++--- .../domain/community/util/KeywordExtractor.java | 8 ++++---- .../eventlistener/ArticleKeywordEventListener.java | 14 +++++++------- 4 files changed, 16 insertions(+), 20 deletions(-) rename src/main/java/in/koreatech/koin/common/event/{ArticleKeywordEvent.java => KoreatechArticleKeywordEvent.java} (62%) diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java similarity index 62% rename from src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java rename to src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java index 4ba7ea1f4..53eb1449b 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java @@ -4,16 +4,12 @@ import java.util.LinkedHashMap; import java.util.Map; -import in.koreatech.koin.domain.community.keyword.enums.KeywordCategory; - -public record ArticleKeywordEvent( +public record KoreatechArticleKeywordEvent( Integer articleId, - Integer authorId, - KeywordCategory category, Map matchedKeywordByUserId ) { - public ArticleKeywordEvent { + public KoreatechArticleKeywordEvent { matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); } } 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 f7ef91dc7..9ea80781a 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 @@ -25,7 +25,7 @@ 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.common.event.KoreatechArticleKeywordEvent; 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; @@ -174,8 +174,8 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { }) .toList(); - List keywordEvents = keywordExtractor.matchKeyword(articles, null, KeywordCategory.KOREATECH); - for (ArticleKeywordEvent event : keywordEvents) { + List keywordEvents = keywordExtractor.matchKeyword(articles, KeywordCategory.KOREATECH); + for (KoreatechArticleKeywordEvent event : keywordEvents) { eventPublisher.publishEvent(event); } } 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 f6affc0ad..9b8e35571 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 @@ -15,7 +15,7 @@ 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.common.event.KoreatechArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import lombok.RequiredArgsConstructor; @@ -30,7 +30,7 @@ public class KeywordExtractor { private final ArticleKeywordRepository articleKeywordRepository; private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; - public List matchKeyword(List
articles, Integer authorId, KeywordCategory category) { + public List matchKeyword(List
articles, KeywordCategory category) { Map> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>(); int offset = 0; @@ -77,11 +77,11 @@ public List matchKeyword(List
articles, Integer au offset += KEYWORD_BATCH_SIZE; } - List keywordEvents = new ArrayList<>(); + 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)); + keywordEvents.add(new KoreatechArticleKeywordEvent(article.getId(), matchedKeywordByUserId)); } } 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 5bed9890b..bdd9f014f 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 @@ -22,7 +22,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.util.StringUtils; -import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; 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; @@ -51,7 +51,7 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 @Async(value = "keywordNotificationTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) - public void onKeywordRequest(ArticleKeywordEvent event) { + public void onKeywordRequest(KoreatechArticleKeywordEvent event) { Map matchedKeywordByUserId = event.matchedKeywordByUserId(); if (matchedKeywordByUserId.isEmpty()) { @@ -110,7 +110,7 @@ private boolean hasDeviceToken(NotificationSubscribe subscribe) { return StringUtils.hasText(subscribe.getUser().getDeviceToken()); } - private NotificationSubscribeType getSubscribeType(ArticleKeywordEvent event) { + private NotificationSubscribeType getSubscribeType(KoreatechArticleKeywordEvent event) { if (event.category() == KOREATECH) { return ARTICLE_KEYWORD; } @@ -120,7 +120,7 @@ private NotificationSubscribeType getSubscribeType(ArticleKeywordEvent event) { throw new IllegalArgumentException("지원하지 않는 키워드 카테고리입니다: " + event.category()); } - private MobileAppPath getAppPath(ArticleKeywordEvent event) { + private MobileAppPath getAppPath(KoreatechArticleKeywordEvent event) { if (event.category() == KOREATECH) { return KEYWORD; } @@ -139,7 +139,7 @@ private Set getAlreadyNotifiedUserIds(Integer articleId, Set s ); } - private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) { + private boolean isMyArticle(KoreatechArticleKeywordEvent event, NotificationSubscribe subscribe) { Integer authorId = event.authorId(); Integer subscriberId = subscribe.getUser().getId(); return Objects.equals(authorId, subscriberId); @@ -149,7 +149,7 @@ private Notification createNotification( Article article, Board board, MobileAppPath appPath, - ArticleKeywordEvent event, + KoreatechArticleKeywordEvent event, String keyword, NotificationSubscribe subscribe ) { @@ -166,7 +166,7 @@ private Notification createNotification( ); } - private String generateDescription(ArticleKeywordEvent event, String keyword) { + private String generateDescription(KoreatechArticleKeywordEvent event, String keyword) { if (event.category() == LOST_ITEM) { return "방금 등록된 %s 분실물 게시물을 확인해보세요!".formatted(keyword); } From fd783ba29f151c5d45bc5d90bbac0d662a284a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:53:03 +0900 Subject: [PATCH 05/26] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC,=20=EB=B6=84=EC=8B=A4=EB=AC=BC=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/NotificationFactory.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 7979789dc..ec340f006 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); From bbf9c26d2fcb2bd10718c92acf53e49d526b6a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:58:28 +0900 Subject: [PATCH 06/26] =?UTF-8?q?fix:=20ArticleKeywordEventListene=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordEventListener.java | 59 +------------------ .../koin/domain/user/model/User.java | 4 ++ 2 files changed, 7 insertions(+), 56 deletions(-) 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 bdd9f014f..eff279428 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,17 +1,13 @@ 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; @@ -20,10 +16,8 @@ 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.KoreatechArticleKeywordEvent; -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; @@ -32,7 +26,6 @@ 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 lombok.RequiredArgsConstructor; @@ -60,12 +53,11 @@ public void onKeywordRequest(KoreatechArticleKeywordEvent event) { Article article = articleRepository.getById(event.articleId()); Board board = article.getBoard(); - MobileAppPath appPath = getAppPath(event); Map keywordSubscribersByUserId = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(getSubscribeType(event)) + .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) .stream() - .filter(this::hasDeviceToken) + .filter(NotificationSubscribe::hasDeviceToken) .collect(Collectors.toMap( subscribe -> subscribe.getUser().getId(), Function.identity(), @@ -85,12 +77,9 @@ public void onKeywordRequest(KoreatechArticleKeywordEvent event) { 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 )) @@ -106,30 +95,6 @@ public void onKeywordRequest(KoreatechArticleKeywordEvent event) { } } - private boolean hasDeviceToken(NotificationSubscribe subscribe) { - return StringUtils.hasText(subscribe.getUser().getDeviceToken()); - } - - private NotificationSubscribeType getSubscribeType(KoreatechArticleKeywordEvent 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(KoreatechArticleKeywordEvent 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(); @@ -139,37 +104,19 @@ private Set getAlreadyNotifiedUserIds(Integer articleId, Set s ); } - private boolean isMyArticle(KoreatechArticleKeywordEvent 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, - KoreatechArticleKeywordEvent event, String keyword, NotificationSubscribe subscribe ) { - String description = generateDescription(event, keyword); - return notificationFactory.generateKeywordNotification( - appPath, + KEYWORD, article.getId(), keyword, article.getTitle(), board.getId(), - description, subscribe.getUser() ); } - - private String generateDescription(KoreatechArticleKeywordEvent event, String keyword) { - if (event.category() == LOST_ITEM) { - return "방금 등록된 %s 분실물 게시물을 확인해보세요!".formatted(keyword); - } - return "방금 등록된 %s 공지를 확인해보세요!".formatted(keyword); - } } diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 22c0b8c5c..de616f893 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -254,4 +254,8 @@ public String getDisplayNickname() { } return "익명 사용자"; } + + public boolean hasDeviceToken() { + return StringUtils.hasText(this.deviceToken); + } } From 48c3e6224b1e8fda510cee393f740435ab6628ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 14:58:33 +0900 Subject: [PATCH 07/26] =?UTF-8?q?fix:=20ArticleKeywordEventListene=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/notification/model/NotificationSubscribe.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java index 78b245839..ccfa38ce3 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java +++ b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java @@ -57,4 +57,8 @@ private NotificationSubscribe( this.detailType = detailType; this.user = user; } + + public boolean hasDeviceToken() { + return user.hasDeviceToken(); + } } From c5599748fb0176db41563a935736e7cfa3e200ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 8 May 2026 15:02:52 +0900 Subject: [PATCH 08/26] =?UTF-8?q?feat:=20LostItemKeywordEventListener=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LostItemKeywordEventListener.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) 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 index 8c933a0c9..bbac2e9cd 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java @@ -1,21 +1,123 @@ package in.koreatech.koin.domain.notification.eventlistener; +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.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.common.model.MobileAppPath; +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.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.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor public class LostItemKeywordEventListener { + private final NotificationService notificationService; + private final NotificationFactory notificationFactory; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final UserNotificationStatusRepository userNotificationStatusRepository; + private final KeywordService keywordService; + private final ArticleRepository articleRepository; + @Async(value = "keywordNotificationTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void onLostItemKeywordRequest(LostItemKeywordEvent event) { + Map matchedKeywordByUserId = event.matchedKeywordByUserId(); + + if (matchedKeywordByUserId.isEmpty()) { + return; + } + + Article article = articleRepository.getById(event.articleId()); + + Map keywordSubscribersByUserId = notificationSubscribeRepository + .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(LOST_ITEM_KEYWORD) + .stream() + .filter(NotificationSubscribe::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, + 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 Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { + if (subscriberUserIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) + ); + } + + private boolean isMyArticle(LostItemKeywordEvent event, NotificationSubscribe subscribe) { + Integer authorId = event.authorId(); + Integer subscriberId = subscribe.getUser().getId(); + return Objects.equals(authorId, subscriberId); + } + private Notification createNotification( + Article article, + String keyword, + NotificationSubscribe subscribe + ) { + return notificationFactory.generateLostItemKeywordNotification( + MobileAppPath.LOST_ITEM, + article.getId(), + keyword, + article.getTitle(), + subscribe.getUser() + ); } } From cf5a468743841af0290758f413cd054489064d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:18:48 +0900 Subject: [PATCH 09/26] =?UTF-8?q?fix:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=8C=EB=A6=BC=20payload=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/KoreatechArticleKeywordEvent.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java index 53eb1449b..b8c4748e0 100644 --- a/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java @@ -2,14 +2,30 @@ import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; public record KoreatechArticleKeywordEvent( Integer articleId, - Map matchedKeywordByUserId + Integer boardId, + String articleTitle, + MatchedKeywordUsers matchedKeywordUsers ) { public KoreatechArticleKeywordEvent { - matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + matchedKeywordUsers = new MatchedKeywordUsers(matchedKeywordUsers.userIdsByKeyword()); + } + + public record MatchedKeywordUsers( + Map> userIdsByKeyword + ) { + + public MatchedKeywordUsers { + Map> copiedUserIdsByKeyword = new LinkedHashMap<>(); + userIdsByKeyword.forEach((keyword, userIds) -> + copiedUserIdsByKeyword.put(keyword, List.copyOf(userIds)) + ); + userIdsByKeyword = Collections.unmodifiableMap(copiedUserIdsByKeyword); + } } } From e1391cd11e3f6da12d6e51aa4bef32fd419ef20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:18:56 +0900 Subject: [PATCH 10/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EB=A7=A4=EC=B9=AD=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B1=85=EC=9E=84=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordUserMapRepository.java | 13 ++++ .../keyword/service/KeywordService.java | 49 ++++++++++++++- .../community/util/KeywordExtractor.java | 63 ++----------------- 3 files changed, 65 insertions(+), 60 deletions(-) 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 b1e325319..4cee4af7a 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 @@ -52,4 +52,17 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( ); 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 + """) + List findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + @Param("category") KeywordCategory category, + @Param("keywords") List keywords + ); } 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 9ea80781a..f92311361 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,10 @@ package in.koreatech.koin.domain.community.keyword.service; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -174,10 +177,50 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { }) .toList(); - List keywordEvents = keywordExtractor.matchKeyword(articles, KeywordCategory.KOREATECH); - for (KoreatechArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + for (Article article : articles) { + List matchedKeywords = keywordExtractor.matchKeywords(article.getTitle(), KeywordCategory.KOREATECH); + if (matchedKeywords.isEmpty()) { + continue; + } + + Map> userIdsByKeyword = findUserIdsByMatchedKeyword(matchedKeywords); + if (userIdsByKeyword.isEmpty()) { + continue; + } + + eventPublisher.publishEvent(new KoreatechArticleKeywordEvent( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + new KoreatechArticleKeywordEvent.MatchedKeywordUsers(userIdsByKeyword) + )); + } + } + + private Map> findUserIdsByMatchedKeyword(List matchedKeywords) { + Map keywordByUserId = new LinkedHashMap<>(); + articleKeywordUserMapRepository + .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.KOREATECH, matchedKeywords) + .stream() + .filter(keywordUserMap -> !keywordUserMap.getIsDeleted()) + .forEach(keywordUserMap -> keywordByUserId.merge( + keywordUserMap.getUser().getId(), + keywordUserMap.getArticleKeyword().getKeyword(), + this::pickHigherPriorityKeyword + )); + + Map> userIdsByKeyword = new LinkedHashMap<>(); + keywordByUserId.forEach((userId, keyword) -> + userIdsByKeyword.computeIfAbsent(keyword, ignored -> new ArrayList<>()).add(userId) + ); + return userIdsByKeyword; + } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; } + return previousKeyword; } private String validateAndGetKeyword(String keyword) { 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 9b8e35571..f0a59f69b 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.KoreatechArticleKeywordEvent; 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, 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, K 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 KoreatechArticleKeywordEvent(article.getId(), matchedKeywordByUserId)); - } - } - - return keywordEvents; - } - - private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { - if (candidateKeyword.length() > previousKeyword.length()) { - return candidateKeyword; - } - return previousKeyword; + return matchedKeywords; } } From 0b187528955d2a2f2b6fd14ea92b1a6c07bc4e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:19:09 +0900 Subject: [PATCH 11/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NotificationRepository.java | 15 ++++ .../NotificationSubscribeRepository.java | 14 +++ .../ArticleKeywordNotificationService.java | 87 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java 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 9e066cb29..68c738bb6 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 f9d00d254..cdf740787 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 000000000..6e804a26f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java @@ -0,0 +1,87 @@ +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.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.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(); + if (userIdsByKeyword.isEmpty()) { + return; + } + + List matchedUserIds = userIdsByKeyword.values().stream() + .flatMap(List::stream) + .distinct() + .toList(); + if (matchedUserIds.isEmpty()) { + return; + } + + Map subscribesByUserId = notificationSubscribeRepository + .findArticleKeywordSubscribesByUserIdIn(ARTICLE_KEYWORD, matchedUserIds) + .stream() + .collect(Collectors.toMap( + subscribe -> subscribe.getUser().getId(), + Function.identity(), + (existing, ignored) -> existing, + LinkedHashMap::new + )); + + Set alreadyNotifiedUserIds = notificationRepository + .findUserIdsBySchemeUriLikeAndUserIdIn("keyword?id=%d&%%".formatted(event.articleId()), matchedUserIds) + .stream() + .collect(Collectors.toSet()); + + List notifications = userIdsByKeyword.entrySet().stream() + .flatMap(entry -> entry.getValue().stream() + .filter(userId -> !alreadyNotifiedUserIds.contains(userId)) + .map(subscribesByUserId::get) + .filter(Objects::nonNull) + .map(subscribe -> createNotification(event, entry.getKey(), subscribe))) + .toList(); + + notificationService.pushNotificationsWithResult(notifications); + } + + private Notification createNotification( + KoreatechArticleKeywordEvent event, + String keyword, + NotificationSubscribe subscribe + ) { + return notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + keyword, + event.articleTitle(), + event.boardId(), + subscribe.getUser() + ); + } +} From 56845311fc9d7579efc079d60ca17510fe002d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:19:16 +0900 Subject: [PATCH 12/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=EB=84=88=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=EB=A7=8C=20=EC=88=98=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordEventListener.java | 104 +----------------- 1 file changed, 4 insertions(+), 100 deletions(-) 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 eff279428..04577dfa4 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,122 +1,26 @@ package in.koreatech.koin.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.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.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 in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; -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.repository.NotificationSubscribeRepository; -import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.notification.service.ArticleKeywordNotificationService; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor @Profile("!test") -public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈니스로직 제거 및 알림 책임만 갖도록) +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(KoreatechArticleKeywordEvent event) { - Map matchedKeywordByUserId = event.matchedKeywordByUserId(); - - if (matchedKeywordByUserId.isEmpty()) { - return; - } - - Article article = articleRepository.getById(event.articleId()); - Board board = article.getBoard(); - - Map keywordSubscribersByUserId = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) - .stream() - .filter(NotificationSubscribe::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())) - .map(subscribe -> createNotification( - article, - board, - 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 Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { - if (subscriberUserIds.isEmpty()) { - return Set.of(); - } - return new HashSet<>( - userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) - ); - } - - private Notification createNotification( - Article article, - Board board, - String keyword, - NotificationSubscribe subscribe - ) { - return notificationFactory.generateKeywordNotification( - KEYWORD, - article.getId(), - keyword, - article.getTitle(), - board.getId(), - subscribe.getUser() - ); + articleKeywordNotificationService.notifyArticleKeyword(event); } } From ea084bffc57220ebd51caf6b14effc27aa72122d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:20:56 +0900 Subject: [PATCH 13/26] =?UTF-8?q?fix:=20=EB=B0=A9=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../keyword/service/KeywordService.java | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) 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 f92311361..60dcbaeb3 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 @@ -15,7 +15,7 @@ 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; @@ -28,7 +28,6 @@ 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.KoreatechArticleKeywordEvent; 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; @@ -164,18 +163,7 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { return; } - 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
articles = articleRepository.findAllByIdIn(updateNotificationIds); for (Article article : articles) { List matchedKeywords = keywordExtractor.matchKeywords(article.getTitle(), KeywordCategory.KOREATECH); From 3b742a115cefcb0bbfc516dda3c414fc51bc20bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:22:16 +0900 Subject: [PATCH 14/26] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/repository/ArticleRepository.java | 3 ++- .../keyword/dto/KeywordNotificationRequest.java | 6 +++--- .../community/keyword/service/KeywordService.java | 10 ++-------- 3 files changed, 7 insertions(+), 12 deletions(-) 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 1733dc997..924c814e6 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/keyword/dto/KeywordNotificationRequest.java b/src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java index 627c9c002..f140a9ccc 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/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 60dcbaeb3..c258052f4 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 @@ -6,6 +6,7 @@ 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; @@ -155,14 +156,7 @@ public ArticleKeywordsSuggestionResponse suggestKeywords(KeywordCategory categor } public void sendKeywordNotification(KeywordNotificationRequest request) { - List updateNotificationIds = request.updateNotification().stream() - .distinct() - .toList(); - - if (updateNotificationIds.isEmpty()) { - return; - } - + Set updateNotificationIds = request.updateNotification(); List
articles = articleRepository.findAllByIdIn(updateNotificationIds); for (Article article : articles) { From 513a60b9e67726a61360036f816ae374226be73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:29:37 +0900 Subject: [PATCH 15/26] =?UTF-8?q?fix:=20KoreatechArticleKeywordEvent=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/KoreatechArticleKeywordEvent.java | 26 +++++++++---------- .../keyword/service/KeywordService.java | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java index b8c4748e0..c9b19b80d 100644 --- a/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java @@ -1,7 +1,5 @@ package in.koreatech.koin.common.event; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -11,21 +9,23 @@ public record KoreatechArticleKeywordEvent( String articleTitle, MatchedKeywordUsers matchedKeywordUsers ) { + public record MatchedKeywordUsers( + Map> userIdsByKeyword + ) { - public KoreatechArticleKeywordEvent { - matchedKeywordUsers = new MatchedKeywordUsers(matchedKeywordUsers.userIdsByKeyword()); } - public record MatchedKeywordUsers( + public static KoreatechArticleKeywordEvent of( + Integer articleId, + Integer boardId, + String articleTitle, Map> userIdsByKeyword ) { - - public MatchedKeywordUsers { - Map> copiedUserIdsByKeyword = new LinkedHashMap<>(); - userIdsByKeyword.forEach((keyword, userIds) -> - copiedUserIdsByKeyword.put(keyword, List.copyOf(userIds)) - ); - userIdsByKeyword = Collections.unmodifiableMap(copiedUserIdsByKeyword); - } + return new KoreatechArticleKeywordEvent( + articleId, + boardId, + articleTitle, + new MatchedKeywordUsers(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 c258052f4..a4f000cc2 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 @@ -170,11 +170,11 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { continue; } - eventPublisher.publishEvent(new KoreatechArticleKeywordEvent( + eventPublisher.publishEvent(KoreatechArticleKeywordEvent.of( article.getId(), article.getBoard().getId(), article.getTitle(), - new KoreatechArticleKeywordEvent.MatchedKeywordUsers(userIdsByKeyword) + userIdsByKeyword )); } } From 297644db420616cd3f14bf9e8a15c0ed1062173e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:42:53 +0900 Subject: [PATCH 16/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EB=A7=A4=EC=B9=AD=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=84=A0?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../keyword/model/ArticleKeyword.java | 8 ++++ .../ArticleKeywordUserMapRepository.java | 1 + .../keyword/service/KeywordService.java | 38 +++++++++---------- 3 files changed, 27 insertions(+), 20 deletions(-) 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 5502207ed..fce40650d 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/ArticleKeywordUserMapRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java index 4cee4af7a..1333cdb98 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 @@ -60,6 +60,7 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( JOIN FETCH akum.user WHERE akw.category = :category AND akw.keyword IN :keywords + AND akum.isDeleted = false """) List findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( @Param("category") KeywordCategory category, 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 a4f000cc2..348dca9ef 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 @@ -180,29 +180,27 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { } private Map> findUserIdsByMatchedKeyword(List matchedKeywords) { - Map keywordByUserId = new LinkedHashMap<>(); - articleKeywordUserMapRepository - .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.KOREATECH, matchedKeywords) - .stream() - .filter(keywordUserMap -> !keywordUserMap.getIsDeleted()) - .forEach(keywordUserMap -> keywordByUserId.merge( - keywordUserMap.getUser().getId(), - keywordUserMap.getArticleKeyword().getKeyword(), - this::pickHigherPriorityKeyword - )); + Map keywordByUserId = new LinkedHashMap<>(); + List keywordUserMaps = articleKeywordUserMapRepository + .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.KOREATECH, matchedKeywords); - Map> userIdsByKeyword = new LinkedHashMap<>(); - keywordByUserId.forEach((userId, keyword) -> - userIdsByKeyword.computeIfAbsent(keyword, ignored -> new ArrayList<>()).add(userId) - ); - return userIdsByKeyword; - } + for (ArticleKeywordUserMap keywordUserMap : keywordUserMaps) { + Integer userId = keywordUserMap.getUser().getId(); + ArticleKeyword keyword = keywordUserMap.getArticleKeyword(); + ArticleKeyword previousKeyword = keywordByUserId.get(userId); - private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { - if (candidateKeyword.length() > previousKeyword.length()) { - return candidateKeyword; + if (keyword.hasLongerKeywordThan(previousKeyword)) { + keywordByUserId.put(userId, keyword); + } } - return previousKeyword; + + 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; } private String validateAndGetKeyword(String keyword) { From 5a6e43c97e157b6a347ce44035881ae1ea5b8cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 04:58:42 +0900 Subject: [PATCH 17/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordNotificationService.java | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) 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 index 6e804a26f..6d8094ba4 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java @@ -3,13 +3,12 @@ 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.Objects; import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -32,44 +31,69 @@ public class ArticleKeywordNotificationService { public void notifyArticleKeyword(KoreatechArticleKeywordEvent event) { Map> userIdsByKeyword = event.matchedKeywordUsers().userIdsByKeyword(); - if (userIdsByKeyword.isEmpty()) { - return; - } - - List matchedUserIds = userIdsByKeyword.values().stream() - .flatMap(List::stream) - .distinct() - .toList(); + List matchedUserIds = getMatchedUserIds(userIdsByKeyword); if (matchedUserIds.isEmpty()) { return; } - Map subscribesByUserId = notificationSubscribeRepository - .findArticleKeywordSubscribesByUserIdIn(ARTICLE_KEYWORD, matchedUserIds) - .stream() - .collect(Collectors.toMap( - subscribe -> subscribe.getUser().getId(), - Function.identity(), - (existing, ignored) -> existing, - LinkedHashMap::new - )); - - Set alreadyNotifiedUserIds = notificationRepository - .findUserIdsBySchemeUriLikeAndUserIdIn("keyword?id=%d&%%".formatted(event.articleId()), matchedUserIds) - .stream() - .collect(Collectors.toSet()); - - List notifications = userIdsByKeyword.entrySet().stream() - .flatMap(entry -> entry.getValue().stream() - .filter(userId -> !alreadyNotifiedUserIds.contains(userId)) - .map(subscribesByUserId::get) - .filter(Objects::nonNull) - .map(subscribe -> createNotification(event, entry.getKey(), subscribe))) - .toList(); + 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("keyword?id=%d&%%".formatted(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, From 572c9b69d6c0c39500e4e6ee32b8f34c9ef5f4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:11:29 +0900 Subject: [PATCH 18/26] =?UTF-8?q?refactor:=20=EB=B6=84=EC=8B=A4=EB=AC=BC?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/LostItemKeywordEvent.java | 25 +++- .../service/LostItemArticleService.java | 50 +++++++- .../util/LostItemKeywordExtractor.java | 67 ++-------- .../LostItemKeywordEventListener.java | 105 +--------------- .../LostItemKeywordNotificationService.java | 119 ++++++++++++++++++ 5 files changed, 196 insertions(+), 170 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java diff --git a/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java index 9b6f19174..2269011f4 100644 --- a/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java @@ -1,16 +1,31 @@ package in.koreatech.koin.common.event; -import java.util.Collections; -import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; public record LostItemKeywordEvent( Integer articleId, + String articleTitle, Integer authorId, - Map matchedKeywordByUserId + MatchedKeywordUsers matchedKeywordUsers ) { + public record MatchedKeywordUsers( + Map> userIdsByKeyword + ) { - public LostItemKeywordEvent { - matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + } + + 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/service/LostItemArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java index 2a43e0530..2dae3ec6a 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 @@ -1,7 +1,9 @@ package in.koreatech.koin.domain.community.article.service; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -35,6 +37,9 @@ 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.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import in.koreatech.koin.domain.community.util.LostItemKeywordExtractor; import in.koreatech.koin.domain.organization.model.Organization; import in.koreatech.koin.domain.organization.repository.OrganizationRepository; @@ -67,6 +72,7 @@ public class LostItemArticleService { private final PopularKeywordTracker popularKeywordTracker; private final ApplicationEventPublisher eventPublisher; private final LostItemKeywordExtractor keywordExtractor; + private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; @Transactional public LostItemArticlesResponse searchLostItemArticles(String query, Integer page, Integer limit, @@ -253,11 +259,47 @@ 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 (LostItemKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + for (Article article : articles) { + List matchedKeywords = keywordExtractor.matchKeywords(article.getTitle(), KeywordCategory.LOST_ITEM); + if (matchedKeywords.isEmpty()) { + continue; } + + Map> userIdsByKeyword = findUserIdsByMatchedKeyword(matchedKeywords); + if (userIdsByKeyword.isEmpty()) { + continue; + } + + eventPublisher.publishEvent(LostItemKeywordEvent.of( + article.getId(), + article.getTitle(), + authorId, + userIdsByKeyword + )); + } + } + + private Map> findUserIdsByMatchedKeyword(List matchedKeywords) { + Map keywordByUserId = new LinkedHashMap<>(); + List keywordUserMaps = articleKeywordUserMapRepository + .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.LOST_ITEM, 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/util/LostItemKeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java index fb4dfa712..0485382ff 100644 --- a/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java +++ b/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java @@ -1,26 +1,19 @@ 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.Component; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.common.event.LostItemKeywordEvent; -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.domain.community.keyword.repository.ArticleKeywordRepository; -import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import lombok.RequiredArgsConstructor; -@Component +@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class LostItemKeywordExtractor { @@ -28,10 +21,9 @@ public class LostItemKeywordExtractor { 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 a 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 LostItemKeywordEvent(article.getId(), authorId, 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/LostItemKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java index bbac2e9cd..287e46dba 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java @@ -1,123 +1,24 @@ package in.koreatech.koin.domain.notification.eventlistener; -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.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.common.model.MobileAppPath; -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.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.repository.NotificationSubscribeRepository; -import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.notification.service.LostItemKeywordNotificationService; import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor public class LostItemKeywordEventListener { - 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 LostItemKeywordNotificationService lostItemKeywordNotificationService; @Async(value = "keywordNotificationTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void onLostItemKeywordRequest(LostItemKeywordEvent event) { - Map matchedKeywordByUserId = event.matchedKeywordByUserId(); - - if (matchedKeywordByUserId.isEmpty()) { - return; - } - - Article article = articleRepository.getById(event.articleId()); - - Map keywordSubscribersByUserId = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(LOST_ITEM_KEYWORD) - .stream() - .filter(NotificationSubscribe::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, - 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 Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { - if (subscriberUserIds.isEmpty()) { - return Set.of(); - } - return new HashSet<>( - userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) - ); - } - - private boolean isMyArticle(LostItemKeywordEvent event, NotificationSubscribe subscribe) { - Integer authorId = event.authorId(); - Integer subscriberId = subscribe.getUser().getId(); - return Objects.equals(authorId, subscriberId); - } - - private Notification createNotification( - Article article, - String keyword, - NotificationSubscribe subscribe - ) { - return notificationFactory.generateLostItemKeywordNotification( - MobileAppPath.LOST_ITEM, - article.getId(), - keyword, - article.getTitle(), - subscribe.getUser() - ); + lostItemKeywordNotificationService.notifyLostItemKeyword(event); } } 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 000000000..19a579f2e --- /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() + ); + } +} From 31267b1059267416a92366c62cb540a75fd19816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:25:17 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EC=B6=9C=EA=B8=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LostItemArticleService.java | 4 +- .../util/LostItemKeywordExtractor.java | 46 ------------------- 2 files changed, 2 insertions(+), 48 deletions(-) delete mode 100644 src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java 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 2dae3ec6a..1e3d7f5c2 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 @@ -40,7 +40,7 @@ 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 in.koreatech.koin.domain.community.util.LostItemKeywordExtractor; +import in.koreatech.koin.domain.community.util.KeywordExtractor; import in.koreatech.koin.domain.organization.model.Organization; import in.koreatech.koin.domain.organization.repository.OrganizationRepository; import in.koreatech.koin.domain.user.model.User; @@ -71,7 +71,7 @@ public class LostItemArticleService { private final OrganizationRepository organizationRepository; private final PopularKeywordTracker popularKeywordTracker; private final ApplicationEventPublisher eventPublisher; - private final LostItemKeywordExtractor keywordExtractor; + private final KeywordExtractor keywordExtractor; private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java deleted file mode 100644 index 0485382ff..000000000 --- a/src/main/java/in/koreatech/koin/domain/community/util/LostItemKeywordExtractor.java +++ /dev/null @@ -1,46 +0,0 @@ -package in.koreatech.koin.domain.community.util; - -import java.util.ArrayList; -import java.util.List; - -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.keyword.enums.KeywordCategory; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; -import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class LostItemKeywordExtractor { - - private static final int KEYWORD_BATCH_SIZE = 100; - - private final ArticleKeywordRepository articleKeywordRepository; - - public List matchKeywords(String title, KeywordCategory category) { - List matchedKeywords = new ArrayList<>(); - int offset = 0; - - while (true) { - Pageable pageable = PageRequest.of(offset / KEYWORD_BATCH_SIZE, KEYWORD_BATCH_SIZE); - List keywords = articleKeywordRepository.findAllByCategory(category, pageable); - - if (keywords.isEmpty()) { - break; - } - for (ArticleKeyword keyword : keywords) { - if (title.contains(keyword.getKeyword())) { - matchedKeywords.add(keyword.getKeyword()); - } - } - offset += KEYWORD_BATCH_SIZE; - } - - return matchedKeywords; - } -} From 3b355100e0c02029659c478d8e1156730b6e2129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:26:30 +0900 Subject: [PATCH 20/26] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A7=A4=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LostItemArticleService.java | 36 +++---------- .../service/ArticleKeywordUserMatcher.java | 50 +++++++++++++++++++ .../keyword/service/KeywordService.java | 32 ++---------- 3 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java 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 1e3d7f5c2..e63834ed3 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 @@ -1,7 +1,6 @@ package in.koreatech.koin.domain.community.article.service; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,9 +36,7 @@ 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.model.ArticleKeyword; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; -import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +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; @@ -72,7 +69,7 @@ public class LostItemArticleService { private final PopularKeywordTracker popularKeywordTracker; private final ApplicationEventPublisher eventPublisher; private final KeywordExtractor keywordExtractor; - private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + private final ArticleKeywordUserMatcher articleKeywordUserMatcher; @Transactional public LostItemArticlesResponse searchLostItemArticles(String query, Integer page, Integer limit, @@ -265,7 +262,10 @@ private void sendKeywordNotification(List
articles, Integer authorId) { continue; } - Map> userIdsByKeyword = findUserIdsByMatchedKeyword(matchedKeywords); + Map> userIdsByKeyword = articleKeywordUserMatcher.findUserIdsByMatchedKeyword( + KeywordCategory.LOST_ITEM, + matchedKeywords + ); if (userIdsByKeyword.isEmpty()) { continue; } @@ -278,28 +278,4 @@ private void sendKeywordNotification(List
articles, Integer authorId) { )); } } - - private Map> findUserIdsByMatchedKeyword(List matchedKeywords) { - Map keywordByUserId = new LinkedHashMap<>(); - List keywordUserMaps = articleKeywordUserMapRepository - .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.LOST_ITEM, 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/ArticleKeywordUserMatcher.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java new file mode 100644 index 000000000..fdb390e1b --- /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 348dca9ef..9ea2b9e6c 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,8 +1,6 @@ package in.koreatech.koin.domain.community.keyword.service; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -58,6 +56,7 @@ public class KeywordService { private final UserRepository userRepository; private final UserNotificationStatusRepository userNotificationStatusRepository; private final KeywordExtractor keywordExtractor; + private final ArticleKeywordUserMatcher articleKeywordUserMatcher; @Transactional public ArticleKeywordResponse createKeyword( @@ -165,7 +164,10 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { continue; } - Map> userIdsByKeyword = findUserIdsByMatchedKeyword(matchedKeywords); + Map> userIdsByKeyword = articleKeywordUserMatcher.findUserIdsByMatchedKeyword( + KeywordCategory.KOREATECH, + matchedKeywords + ); if (userIdsByKeyword.isEmpty()) { continue; } @@ -179,30 +181,6 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { } } - private Map> findUserIdsByMatchedKeyword(List matchedKeywords) { - Map keywordByUserId = new LinkedHashMap<>(); - List keywordUserMaps = articleKeywordUserMapRepository - .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KeywordCategory.KOREATECH, 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; - } - private String validateAndGetKeyword(String keyword) { if (keyword.contains(" ") || keyword.contains("\n")) { throw new KoinIllegalArgumentException("키워드에 공백을 포함할 수 없습니다."); From 71f299006025bcb16ca6e7a29a0efc79b6fce47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:33:45 +0900 Subject: [PATCH 21/26] =?UTF-8?q?test:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../keyword/service/KeywordServiceTest.java | 130 -------- .../community/util/KeywordExtractorTest.java | 158 --------- .../ArticleKeywordEventListenerTest.java | 300 ------------------ 3 files changed, 588 deletions(-) delete mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java delete mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java delete mode 100644 src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java 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 84f175065..000000000 --- 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 802011899..000000000 --- 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 cb0445b0f..000000000 --- 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(); - } -} From 2319bdf7799dd17d7b76a58b820bdc0d34d24740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:36:28 +0900 Subject: [PATCH 22/26] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ArticleKeywordRepository.java | 2 -- .../ArticleKeywordUserMapRepository.java | 15 --------------- .../community/keyword/service/KeywordService.java | 5 ----- 3 files changed, 22 deletions(-) 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 d7a46d5b1..1ff78c6f9 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 1333cdb98..e8560af9b 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,8 +38,6 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( @Param("userId") Integer userId ); - List findAllByArticleKeywordIdIn(List articleKeywordIds); - @Query(""" SELECT akum FROM ArticleKeywordUserMap akum 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 9ea2b9e6c..651d496d0 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 @@ -11,7 +11,6 @@ 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.common.event.KoreatechArticleKeywordEvent; @@ -220,8 +219,4 @@ public void fetchTopKeywordsFromLastWeek() { } } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void createNotifiedArticleStatus(Integer userId, Integer articleId) { - userNotificationStatusRepository.upsertLastNotifiedArticleId(userId, articleId); - } } From 52f9edeb551d01e8b20f4890102480a1175029d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:37:18 +0900 Subject: [PATCH 23/26] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/community/keyword/service/KeywordService.java | 2 -- 1 file changed, 2 deletions(-) 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 651d496d0..ba93c23aa 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 @@ -31,7 +31,6 @@ 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,7 +52,6 @@ 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; From ec1927f778c5fe965e188c3081eb6222ba4be19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 05:39:16 +0900 Subject: [PATCH 24/26] =?UTF-8?q?fix:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/notification/model/NotificationSubscribe.java | 4 ---- src/main/java/in/koreatech/koin/domain/user/model/User.java | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java index ccfa38ce3..78b245839 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java +++ b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribe.java @@ -57,8 +57,4 @@ private NotificationSubscribe( this.detailType = detailType; this.user = user; } - - public boolean hasDeviceToken() { - return user.hasDeviceToken(); - } } diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index de616f893..22c0b8c5c 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -254,8 +254,4 @@ public String getDisplayNickname() { } return "익명 사용자"; } - - public boolean hasDeviceToken() { - return StringUtils.hasText(this.deviceToken); - } } From 9dddac7354eb98a1f835d65c6da6a4addbd0934f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 07:57:11 +0900 Subject: [PATCH 25/26] =?UTF-8?q?feat:=20Profile=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/eventlistener/ArticleKeywordEventListener.java | 2 +- .../eventlistener/LostItemKeywordEventListener.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 04577dfa4..947252f92 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 @@ -12,8 +12,8 @@ import lombok.RequiredArgsConstructor; @Component -@RequiredArgsConstructor @Profile("!test") +@RequiredArgsConstructor public class ArticleKeywordEventListener { private final ArticleKeywordNotificationService articleKeywordNotificationService; 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 index 287e46dba..07483a9e9 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java @@ -2,6 +2,7 @@ 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; @@ -11,6 +12,7 @@ import lombok.RequiredArgsConstructor; @Component +@Profile("!test") @RequiredArgsConstructor public class LostItemKeywordEventListener { From 9179ca19cf7af1285f66304355ee270de956821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 9 May 2026 07:59:12 +0900 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20keyword=20=ED=95=98=EB=93=9C=20?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../notification/service/ArticleKeywordNotificationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6d8094ba4..de1a71dd4 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java @@ -66,7 +66,7 @@ private Map findSubscribesByUserId(List private Set getAlreadyNotifiedUserIds(Integer articleId, List userIds) { return new HashSet<>(notificationRepository - .findUserIdsBySchemeUriLikeAndUserIdIn("keyword?id=%d&%%".formatted(articleId), userIds)); + .findUserIdsBySchemeUriLikeAndUserIdIn("%s?id=%d&%%".formatted(KEYWORD.getPath(), articleId), userIds)); } private List createNotifications(