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 88c36ef16..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,10 +6,13 @@ import java.util.List; import java.util.Locale; import java.util.Set; +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; @@ -24,6 +27,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 +53,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 +133,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 +161,9 @@ public AdminWebsiteClubSheetImportResponse confirmImport( List savedClubs = clubsToSave.isEmpty() ? List.of() : webClubRepository.saveAll(clubsToSave); + if (!savedClubs.isEmpty()) { + invalidateWebsiteStatsAfterCommit(universityId); + } return AdminWebsiteClubSheetImportResponse.of( savedClubs.size(), @@ -159,8 +192,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 +209,7 @@ private SheetClubImportPlan buildImportPlan(List rows) { description, EMPTY_INTRODUCE, categoryEmoji, - true + contentWarnings.isEmpty() )); } @@ -204,6 +244,73 @@ 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, + 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(); + // 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); + } + + 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/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..64e37947e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteClubStatsReader.java @@ -0,0 +1,70 @@ +package gg.agit.konect.domain.website.service; + +import java.time.Duration; +import java.util.Locale; +import java.util.Map; + +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; + +@Component +@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 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.get( + universityId, + websiteQueryRepository::countClubsByUniversityId + ); + } + + public Map getCategoryCounts(Integer universityId, String query) { + CategoryCountCacheKey key = new CategoryCountCacheKey(universityId, normalizeQuery(query)); + return categoryCountCache.get( + key, + cacheKey -> Map.copyOf(websiteQueryRepository.countClubCategories( + cacheKey.universityId(), + cacheKey.query() + )) + ); + } + + public void invalidateUniversity(Integer 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; + } + 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/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java index b77532fd4..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 @@ -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; @@ -13,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; @@ -28,6 +31,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 +56,9 @@ class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { @Mock private WebClubRepository webClubRepository; + @Mock + private WebsiteClubStatsReader websiteClubStatsReader; + private AdminWebsiteClubSheetImportService service; @BeforeEach @@ -59,7 +66,8 @@ void setUp() { service = new AdminWebsiteClubSheetImportService( googleSheetsService, webUniversityRepository, - webClubRepository + webClubRepository, + websiteClubStatsReader ); } @@ -128,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()); @@ -151,6 +185,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) 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..4d655d3fd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/website/service/WebsiteClubStatsReaderTest.java @@ -0,0 +1,107 @@ +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; +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); + } + + @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(); + } + } +}