From 16bbe8cfbec67ad605329059f8a0dbc18a1956c6 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 11 Jun 2026 20:41:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9B=B9=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=8F=99=EC=95=84=EB=A6=AC=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WebsiteClubStatsReader.java | 56 +++++++++++++++ .../website/service/WebsiteService.java | 7 +- .../service/WebsiteClubStatsReaderTest.java | 69 +++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java new file mode 100644 index 000000000..753c0a584 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java @@ -0,0 +1,56 @@ +package gg.agit.konect.domain.website.service; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class WebsiteClubStatsReader { + + private final WebsiteQueryRepository websiteQueryRepository; + private final Map universityClubCountCache = new ConcurrentHashMap<>(); + private final Map> categoryCountCache = new ConcurrentHashMap<>(); + + public Long getUniversityClubCount(Integer universityId) { + return universityClubCountCache.computeIfAbsent( + universityId, + websiteQueryRepository::countClubsByUniversityId + ); + } + + public Map getCategoryCounts(Integer universityId, String query) { + CategoryCountCacheKey key = new CategoryCountCacheKey(universityId, normalizeQuery(query)); + return categoryCountCache.computeIfAbsent( + key, + cacheKey -> Map.copyOf(websiteQueryRepository.countClubCategories( + cacheKey.universityId(), + cacheKey.query() + )) + ); + } + + public void invalidateUniversity(Integer universityId) { + universityClubCountCache.remove(universityId); + categoryCountCache.keySet().removeIf(key -> key.universityId().equals(universityId)); + } + + private String normalizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + return query.trim().toLowerCase(Locale.ROOT); + } + + private record CategoryCountCacheKey( + Integer universityId, + String query + ) { + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java index 5cb118ab5..b801b076d 100644 --- a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java @@ -34,6 +34,7 @@ public class WebsiteService { private final WebsiteQueryRepository websiteQueryRepository; private final UniversitySearchMatcher universitySearchMatcher; private final UniversitySearchKeywordReader universitySearchKeywordReader; + private final WebsiteClubStatsReader websiteClubStatsReader; public WebsiteHomeResponse getHome(String query, UniversityRegion region) { List summaries = websiteQueryRepository.findUniversitySummaries(null, region) @@ -71,15 +72,15 @@ public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClub return WebsiteClubsResponse.of( university, clubs, - websiteQueryRepository.countClubsByUniversityId(universityId), - websiteQueryRepository.countClubCategories(universityId, condition.query()) + websiteClubStatsReader.getUniversityClubCount(universityId), + websiteClubStatsReader.getCategoryCounts(universityId, condition.query()) ); } public WebsiteClubDetailResponse getClubDetail(Integer clubId) { WebClub club = websiteQueryRepository.findClub(clubId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB)); - Long universityClubCount = websiteQueryRepository.countClubsByUniversityId(club.getUniversity().getId()); + Long universityClubCount = websiteClubStatsReader.getUniversityClubCount(club.getUniversity().getId()); return WebsiteClubDetailResponse.of(club, universityClubCount); } diff --git a/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java b/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java new file mode 100644 index 000000000..e091bcd3b --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java @@ -0,0 +1,69 @@ +package gg.agit.konect.unit.domain.website.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; +import gg.agit.konect.domain.website.service.WebsiteClubStatsReader; +import gg.agit.konect.support.ServiceTestSupport; + +class WebsiteClubStatsReaderTest extends ServiceTestSupport { + + private static final Integer UNIVERSITY_ID = 1; + + @Mock + private WebsiteQueryRepository websiteQueryRepository; + + private WebsiteClubStatsReader websiteClubStatsReader; + + @BeforeEach + void setUp() { + websiteClubStatsReader = new WebsiteClubStatsReader(websiteQueryRepository); + } + + @Test + void getUniversityClubCountCachesByUniversity() { + given(websiteQueryRepository.countClubsByUniversityId(UNIVERSITY_ID)).willReturn(3L); + + Long first = websiteClubStatsReader.getUniversityClubCount(UNIVERSITY_ID); + Long second = websiteClubStatsReader.getUniversityClubCount(UNIVERSITY_ID); + + assertThat(first).isEqualTo(3L); + assertThat(second).isEqualTo(3L); + verify(websiteQueryRepository, times(1)).countClubsByUniversityId(UNIVERSITY_ID); + } + + @Test + void getCategoryCountsCachesByUniversityAndNormalizedQuery() { + given(websiteQueryRepository.countClubCategories(UNIVERSITY_ID, "bcsd")) + .willReturn(Map.of(ClubCategory.ACADEMIC, 2L)); + + Map first = websiteClubStatsReader.getCategoryCounts(UNIVERSITY_ID, " bcsd "); + Map second = websiteClubStatsReader.getCategoryCounts(UNIVERSITY_ID, "BCSD"); + + assertThat(first).containsEntry(ClubCategory.ACADEMIC, 2L); + assertThat(second).containsEntry(ClubCategory.ACADEMIC, 2L); + verify(websiteQueryRepository, times(1)).countClubCategories(UNIVERSITY_ID, "bcsd"); + } + + @Test + void invalidateUniversityClearsCachedCounts() { + given(websiteQueryRepository.countClubsByUniversityId(UNIVERSITY_ID)).willReturn(3L, 4L); + + websiteClubStatsReader.getUniversityClubCount(UNIVERSITY_ID); + websiteClubStatsReader.invalidateUniversity(UNIVERSITY_ID); + Long refreshedCount = websiteClubStatsReader.getUniversityClubCount(UNIVERSITY_ID); + + assertThat(refreshedCount).isEqualTo(4L); + verify(websiteQueryRepository, times(2)).countClubsByUniversityId(UNIVERSITY_ID); + } +} From e4143852848faf3e5a65811e2b68f629e90f7195 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 11 Jun 2026 20:41:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=9B=B9=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=8B=9C=ED=8A=B8=20import=20?= =?UTF-8?q?=EC=9D=98=EC=8B=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminWebsiteClubSheetImportService.java | 90 ++++++++++++++++++- ...dminWebsiteClubSheetImportServiceTest.java | 67 +++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java index 88c36ef16..64dae8d29 100644 --- a/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java +++ b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java @@ -7,6 +7,7 @@ import java.util.Locale; import java.util.Set; import java.util.stream.Collectors; +import java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +25,7 @@ import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.domain.website.repository.WebClubRepository; import gg.agit.konect.domain.website.repository.WebUniversityRepository; +import gg.agit.konect.domain.website.service.WebsiteClubStatsReader; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -49,10 +51,26 @@ public class AdminWebsiteClubSheetImportService { private static final int TOPIC_COLUMN_INDEX = 3; private static final int CATEGORY_EMOJI_COLUMN_INDEX = 4; private static final int DESCRIPTION_COLUMN_INDEX = 5; + private static final Set PLACEHOLDER_TEXTS = Set.of( + "-", + "없음", + "미정", + "미확인", + "확인필요", + "확인 필요", + "조사필요", + "조사 필요", + "미분류" + ); + private static final Pattern URL_OR_SNS_PATTERN = Pattern.compile( + "(?i).*(https?://|www\\.|instagram\\.com|open\\.kakao|kakao\\.com|@\\w+).*" + ); + private static final Pattern PHONE_NUMBER_PATTERN = Pattern.compile(".*\\d{2,3}[- .]?\\d{3,4}[- .]?\\d{4}.*"); private final Sheets googleSheetsService; private final WebUniversityRepository webUniversityRepository; private final WebClubRepository webClubRepository; + private final WebsiteClubStatsReader websiteClubStatsReader; public AdminWebsiteClubSheetImportPreviewResponse previewClubs( Integer universityId, @@ -113,6 +131,16 @@ public AdminWebsiteClubSheetImportResponse confirmImport( warnings.add(String.format("%d행: 이미 등록된 동아리명 '%s'을 제외했습니다.", club.rowNumber(), name)); continue; } + List contentWarnings = validateClubContent( + club.rowNumber(), + name, + club.topic(), + club.description() + ); + if (!contentWarnings.isEmpty()) { + warnings.addAll(contentWarnings); + continue; + } clubsToSave.add(WebClub.builder() .university(university) @@ -131,6 +159,9 @@ public AdminWebsiteClubSheetImportResponse confirmImport( List savedClubs = clubsToSave.isEmpty() ? List.of() : webClubRepository.saveAll(clubsToSave); + if (!savedClubs.isEmpty()) { + websiteClubStatsReader.invalidateUniversity(universityId); + } return AdminWebsiteClubSheetImportResponse.of( savedClubs.size(), @@ -159,8 +190,15 @@ private SheetClubImportPlan buildImportPlan(List rows) { requiredText(row.description(), name + " 동아리입니다."), DESCRIPTION_MAX_LENGTH ); + List contentWarnings = validateClubContent( + row.rowNumber(), + row.name(), + row.topic(), + row.description() + ); addWarnings(row, category, topic, categoryEmoji, description, warnings); + warnings.addAll(contentWarnings); clubs.add(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub( row.rowNumber(), name, @@ -169,7 +207,7 @@ private SheetClubImportPlan buildImportPlan(List rows) { description, EMPTY_INTRODUCE, categoryEmoji, - true + contentWarnings.isEmpty() )); } @@ -204,6 +242,56 @@ private void addWarnings( } } + private List validateClubContent( + int rowNumber, + String name, + String topic, + String description + ) { + List warnings = new ArrayList<>(); + String normalizedName = optionalText(name); + if (isSuspiciousName(normalizedName)) { + warnings.add(String.format("%d행: 동아리명이 소개 문장 또는 시트 헤더처럼 보여 제외했습니다.", rowNumber)); + } + if (isSuspiciousShortText(topic)) { + warnings.add(String.format("%d행: 동아리 주제에 미확인/연락처성 문구가 있어 제외했습니다.", rowNumber)); + } + if (isSuspiciousShortText(description)) { + warnings.add(String.format("%d행: 한 줄 소개에 미확인/연락처성 문구가 있어 제외했습니다.", rowNumber)); + } + return warnings; + } + + private boolean isSuspiciousName(String name) { + if (name.isBlank()) { + return false; + } + String normalized = name.trim(); + return HEADER_NAME.equals(normalized) + || normalized.contains("한 줄 소개") + || normalized.contains("상세소개") + || normalized.contains("동아리입니다") + || normalized.endsWith("입니다.") + || normalized.endsWith("합니다.") + || URL_OR_SNS_PATTERN.matcher(normalized).matches() + || PHONE_NUMBER_PATTERN.matcher(normalized).matches() + || isPlaceholder(normalized); + } + + private boolean isSuspiciousShortText(String value) { + String normalized = optionalText(value); + if (normalized.isBlank()) { + return false; + } + return isPlaceholder(normalized) + || URL_OR_SNS_PATTERN.matcher(normalized).matches() + || PHONE_NUMBER_PATTERN.matcher(normalized).matches(); + } + + private boolean isPlaceholder(String value) { + return PLACEHOLDER_TEXTS.contains(value.trim().toLowerCase(Locale.ROOT)); + } + private List readClubRows(String spreadsheetId) { try { ValueRange response = googleSheetsService.spreadsheets().values() diff --git a/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java index b77532fd4..23dad3d18 100644 --- a/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -28,6 +29,7 @@ import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.domain.website.repository.WebClubRepository; import gg.agit.konect.domain.website.repository.WebUniversityRepository; +import gg.agit.konect.domain.website.service.WebsiteClubStatsReader; import gg.agit.konect.support.ServiceTestSupport; class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { @@ -52,6 +54,9 @@ class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { @Mock private WebClubRepository webClubRepository; + @Mock + private WebsiteClubStatsReader websiteClubStatsReader; + private AdminWebsiteClubSheetImportService service; @BeforeEach @@ -59,7 +64,8 @@ void setUp() { service = new AdminWebsiteClubSheetImportService( googleSheetsService, webUniversityRepository, - webClubRepository + webClubRepository, + websiteClubStatsReader ); } @@ -151,6 +157,65 @@ void confirmImportSkipsExistingClubNameCaseInsensitively() { verifyNoInteractions(googleSheetsService); } + @Test + void previewClubsDisablesSuspiciousRows() throws Exception { + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.get("sheet-id", "'작성 시트'!A1:F1000")).willReturn(getRequest); + given(getRequest.setValueRenderOption("FORMATTED_VALUE")).willReturn(getRequest); + given(getRequest.execute()).willReturn(new ValueRange().setValues(List.of( + List.of("title"), + List.of("description"), + List.of(), + List.of("동아리명", "동아리 분과", "기타 분과", "동아리 주제", "대표 이모지", "한 줄 소개"), + List.of("즐겁게 농구하는 중앙 농구 동아리입니다", "체육(운동)분과", "", "농구", "🏀", "농구 동아리"), + List.of("BCSD", "학술분과", "", "미확인", "IT", "개발 동아리"), + List.of("ZEST", "공연분과", "", "댄스", "🎭", "문의 https://example.com") + ))); + + AdminWebsiteClubSheetImportPreviewResponse preview = service.previewClubs( + UNIVERSITY_ID, + "https://docs.google.com/spreadsheets/d/sheet-id/edit" + ); + + assertThat(preview.clubs()) + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::enabled) + .containsExactly(false, false, false); + assertThat(preview.warnings()) + .anyMatch(warning -> warning.contains("5행") && warning.contains("동아리명")) + .anyMatch(warning -> warning.contains("6행") && warning.contains("동아리 주제")) + .anyMatch(warning -> warning.contains("7행") && warning.contains("한 줄 소개")); + } + + @Test + void confirmImportSkipsSuspiciousEnabledClub() { + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); + given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())).willReturn(Set.of()); + + AdminWebsiteClubSheetImportResponse response = service.confirmImport( + UNIVERSITY_ID, + List.of(new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 5, + "즐겁게 농구하는 중앙 농구 동아리입니다", + ClubCategory.SPORTS, + "농구", + "농구 동아리", + "", + "🏀", + true + )) + ); + + assertThat(response.importedCount()).isZero(); + assertThat(response.skippedCount()).isEqualTo(1); + assertThat(response.warnings()).singleElement() + .asString() + .contains("동아리명"); + verify(webClubRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + verifyNoInteractions(googleSheetsService); + } + private WebUniversity university() { return WebUniversity.builder() .id(UNIVERSITY_ID) From bb24aaf83bb9ae32915f575623423ab29000d635 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 12 Jun 2026 15:29:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=9B=B9=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EB=8F=99=EC=95=84=EB=A6=AC=20=ED=86=B5=EA=B3=84=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../AdminWebsiteClubSheetImportService.java | 23 ++++++++++- .../service/WebsiteClubStatsReader.java | 28 ++++++++++---- ...dminWebsiteClubSheetImportServiceTest.java | 28 ++++++++++++++ .../service/WebsiteClubStatsReaderTest.java | 38 +++++++++++++++++++ 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 23415d2ba..4f40fae46 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.ben-manes.caffeine:caffeine' // db implementation 'com.mysql:mysql-connector-j' diff --git a/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java index 64dae8d29..42bf565eb 100644 --- a/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java +++ b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java @@ -6,11 +6,13 @@ import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.stream.Collectors; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -160,7 +162,7 @@ public AdminWebsiteClubSheetImportResponse confirmImport( ? List.of() : webClubRepository.saveAll(clubsToSave); if (!savedClubs.isEmpty()) { - websiteClubStatsReader.invalidateUniversity(universityId); + invalidateWebsiteStatsAfterCommit(universityId); } return AdminWebsiteClubSheetImportResponse.of( @@ -242,6 +244,20 @@ private void addWarnings( } } + private void invalidateWebsiteStatsAfterCommit(Integer universityId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + websiteClubStatsReader.invalidateUniversity(universityId); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + websiteClubStatsReader.invalidateUniversity(universityId); + } + }); + } + private List validateClubContent( int rowNumber, String name, @@ -267,12 +283,15 @@ private boolean isSuspiciousName(String name) { return false; } String normalized = name.trim(); + // Header/label fragments mean a sheet row or intro column leaked into the name field. return HEADER_NAME.equals(normalized) || normalized.contains("한 줄 소개") || normalized.contains("상세소개") + // Sentence-like names usually came from one-line introductions rather than club names. || normalized.contains("동아리입니다") || normalized.endsWith("입니다.") || normalized.endsWith("합니다.") + // Contact handles and placeholders are not stable display names. || URL_OR_SNS_PATTERN.matcher(normalized).matches() || PHONE_NUMBER_PATTERN.matcher(normalized).matches() || isPlaceholder(normalized); diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java index 753c0a584..64e37947e 100644 --- a/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java @@ -1,11 +1,14 @@ package gg.agit.konect.domain.website.service; +import java.time.Duration; import java.util.Locale; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Component; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; import lombok.RequiredArgsConstructor; @@ -14,12 +17,22 @@ @RequiredArgsConstructor public class WebsiteClubStatsReader { + private static final long UNIVERSITY_CLUB_COUNT_CACHE_MAX_SIZE = 500; + private static final long CATEGORY_COUNT_CACHE_MAX_SIZE = 10_000; + private static final Duration CACHE_TTL = Duration.ofMinutes(10); + private final WebsiteQueryRepository websiteQueryRepository; - private final Map universityClubCountCache = new ConcurrentHashMap<>(); - private final Map> categoryCountCache = new ConcurrentHashMap<>(); + private final Cache universityClubCountCache = Caffeine.newBuilder() + .maximumSize(UNIVERSITY_CLUB_COUNT_CACHE_MAX_SIZE) + .expireAfterWrite(CACHE_TTL) + .build(); + private final Cache> categoryCountCache = Caffeine.newBuilder() + .maximumSize(CATEGORY_COUNT_CACHE_MAX_SIZE) + .expireAfterWrite(CACHE_TTL) + .build(); public Long getUniversityClubCount(Integer universityId) { - return universityClubCountCache.computeIfAbsent( + return universityClubCountCache.get( universityId, websiteQueryRepository::countClubsByUniversityId ); @@ -27,7 +40,7 @@ public Long getUniversityClubCount(Integer universityId) { public Map getCategoryCounts(Integer universityId, String query) { CategoryCountCacheKey key = new CategoryCountCacheKey(universityId, normalizeQuery(query)); - return categoryCountCache.computeIfAbsent( + return categoryCountCache.get( key, cacheKey -> Map.copyOf(websiteQueryRepository.countClubCategories( cacheKey.universityId(), @@ -37,10 +50,11 @@ public Map getCategoryCounts(Integer universityId, String qu } public void invalidateUniversity(Integer universityId) { - universityClubCountCache.remove(universityId); - categoryCountCache.keySet().removeIf(key -> key.universityId().equals(universityId)); + universityClubCountCache.invalidate(universityId); + categoryCountCache.asMap().keySet().removeIf(key -> key.universityId().equals(universityId)); } + // null/blank queries share one "no search" cache key; trim and Locale.ROOT keep query keys stable. private String normalizeQuery(String query) { if (query == null || query.isBlank()) { return null; diff --git a/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java index 23dad3d18..0b42a76ea 100644 --- a/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -134,9 +136,35 @@ void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { && savedClubs.getFirst().getName().equals("BCSD") && savedClubs.getFirst().getIntroduce().isEmpty() )); + verify(websiteClubStatsReader).invalidateUniversity(UNIVERSITY_ID); verifyNoInteractions(googleSheetsService); } + @Test + void confirmImportInvalidatesStatsAfterTransactionCommit() { + List clubs = List.of( + confirmClub(5, "BCSD", ClubCategory.ACADEMIC, true) + ); + + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); + given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())) + .willReturn(Set.of()); + given(webClubRepository.saveAll(org.mockito.ArgumentMatchers.>any())) + .willAnswer(invocation -> invocation.getArgument(0)); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.confirmImport(UNIVERSITY_ID, clubs); + + verify(websiteClubStatsReader, never()).invalidateUniversity(UNIVERSITY_ID); + TransactionSynchronizationManager.getSynchronizations() + .forEach(TransactionSynchronization::afterCommit); + verify(websiteClubStatsReader).invalidateUniversity(UNIVERSITY_ID); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + @Test void confirmImportSkipsExistingClubNameCaseInsensitively() { given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); diff --git a/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java b/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java index e091bcd3b..4d655d3fd 100644 --- a/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java @@ -1,11 +1,20 @@ package gg.agit.konect.unit.domain.website.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -66,4 +75,33 @@ void invalidateUniversityClearsCachedCounts() { assertThat(refreshedCount).isEqualTo(4L); verify(websiteQueryRepository, times(2)).countClubsByUniversityId(UNIVERSITY_ID); } + + @Test + void invalidateUniversityIsSafeUnderConcurrentAccess() { + given(websiteQueryRepository.countClubCategories(eq(UNIVERSITY_ID), anyString())) + .willReturn(Map.of(ClubCategory.ACADEMIC, 2L)); + ExecutorService executor = Executors.newFixedThreadPool(8); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < 4; i++) { + futures.add(executor.submit(() -> { + for (int j = 0; j < 100; j++) { + websiteClubStatsReader.getCategoryCounts(UNIVERSITY_ID, "query" + j); + } + })); + futures.add(executor.submit(() -> { + for (int j = 0; j < 100; j++) { + websiteClubStatsReader.invalidateUniversity(UNIVERSITY_ID); + } + })); + } + + for (Future future : futures) { + assertThatCode(() -> future.get(5, TimeUnit.SECONDS)).doesNotThrowAnyException(); + } + } finally { + executor.shutdownNow(); + } + } }