From b0a6d7653419298a246053ece266f8d60c073a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=80=EC=9A=B0?= <103417427+JanooGwan@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:39:03 +0900 Subject: [PATCH 1/2] =?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=20import=20=ED=92=88=EC=A7=88=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=ED=86=B5=EA=B3=84=20=EC=BA=90?= =?UTF-8?q?=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 * feat: 웹사이트 동아리 통계 캐시 추가 * fix: 웹사이트 동아리 시트 import 의심 데이터 차단 * fix: 웹사이트 동아리 통계 캐시 무효화 안정화 --- build.gradle | 1 + .../AdminWebsiteClubSheetImportService.java | 109 +++++++++++++++++- .../service/WebsiteClubStatsReader.java | 70 +++++++++++ .../website/service/WebsiteService.java | 7 +- ...dminWebsiteClubSheetImportServiceTest.java | 95 ++++++++++++++- .../service/WebsiteClubStatsReaderTest.java | 107 +++++++++++++++++ 6 files changed, 384 insertions(+), 5 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/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(); + } + } +} From 73506d0fb316ccb0ae8daf93729fb58bf436cf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=80=EC=9A=B0?= <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:52:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9B=B9=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=ED=95=9C=20=EC=A4=84=20=EC=86=8C=EA=B0=9C=EB=A5=BC?= =?UTF-8?q?=20100=EC=9E=90=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 웹 동아리 한 줄 소개 컬럼을 100자로 확장 * fix: 웹 동아리 소개 엔티티 길이를 100자로 변경 * fix: 웹 동아리 한 줄 소개 입력을 100자로 허용 --- ...nWebsiteClubSheetImportConfirmRequest.java | 2 +- .../AdminWebsiteClubSheetImportService.java | 2 +- .../dto/ClubInformationUpdateRequestDto.java | 4 +- .../club/dto/ClubRegistrationRequestDto.java | 4 +- .../model/ClubInformationUpdateRequest.java | 2 +- .../club/model/ClubRegistrationRequest.java | 2 +- .../konect/domain/website/model/WebClub.java | 2 +- ...V89__expand_web_club_short_description.sql | 8 ++ .../club/ClubRegistrationRequestApiTest.java | 74 +++++++++++++++++++ ...dminWebsiteClubSheetImportServiceTest.java | 23 ++++++ 10 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/db/migration/V89__expand_web_club_short_description.sql diff --git a/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java index c61d134c0..88d7d1e99 100644 --- a/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java +++ b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java @@ -30,7 +30,7 @@ public record ConfirmClub( String topic, @NotBlank - @Size(max = 30) + @Size(max = 100) String description, @NotNull 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 42bf565eb..d1ffdba0b 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 @@ -44,7 +44,7 @@ public class AdminWebsiteClubSheetImportService { private static final int DEFAULT_HEADER_INDEX = 3; private static final int NAME_MAX_LENGTH = 50; private static final int TOPIC_MAX_LENGTH = 20; - private static final int DESCRIPTION_MAX_LENGTH = 30; + private static final int DESCRIPTION_MAX_LENGTH = 100; private static final int CATEGORY_EMOJI_MAX_LENGTH = 255; private static final String EMPTY_INTRODUCE = ""; private static final int NAME_COLUMN_INDEX = 0; diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java index 3b4d6b8fa..dd5103d84 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java @@ -32,12 +32,12 @@ public record ClubInformationUpdateRequestDto( String clubTopic, @Schema( - description = "한 줄 소개 (최대 30자)", + description = "한 줄 소개 (최대 100자)", example = "코딩 동아리입니다.", requiredMode = REQUIRED ) @NotBlank(message = "한 줄 소개는 필수입니다.") - @Size(max = 30, message = "한 줄 소개는 최대 30자입니다.") + @Size(max = 100, message = "한 줄 소개는 최대 100자입니다.") String shortDescription, @Schema( diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java index d3f662747..a5e5dae5a 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java @@ -37,9 +37,9 @@ public record ClubRegistrationRequestDto( @Size(max = 10, message = "동아리 이모지는 최대 10자입니다.") String clubEmoji, - @Schema(description = "한 줄 소개 (최대 30자)", example = "코딩 동아리입니다.", requiredMode = REQUIRED) + @Schema(description = "한 줄 소개 (최대 100자)", example = "코딩 동아리입니다.", requiredMode = REQUIRED) @NotBlank(message = "한 줄 소개는 필수입니다.") - @Size(max = 30, message = "한 줄 소개는 최대 30자입니다.") + @Size(max = 100, message = "한 줄 소개는 최대 100자입니다.") String shortDescription, @Schema(description = "동아리 소개 (최대 2000자)", example = "상세한 동아리 소개 내용...", requiredMode = REQUIRED) diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java index 3466a779c..3e3b3a315 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java @@ -64,7 +64,7 @@ public class ClubInformationUpdateRequest extends BaseEntity { private String clubTopic; @NotNull - @Column(name = "short_description", length = 30, nullable = false) + @Column(name = "short_description", length = 100, nullable = false) private String shortDescription; @NotNull diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java index 154480ebb..79219259e 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java @@ -56,7 +56,7 @@ public class ClubRegistrationRequest extends BaseEntity { private String clubEmoji; @NotNull - @Column(name = "short_description", length = 30, nullable = false) + @Column(name = "short_description", length = 100, nullable = false) private String shortDescription; @NotNull diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebClub.java b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java index f01eda431..746418ff4 100644 --- a/src/main/java/gg/agit/konect/domain/website/model/WebClub.java +++ b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java @@ -46,7 +46,7 @@ public class WebClub extends BaseEntity { @Column(name = "topic", length = 20, nullable = false) private String topic; - @Column(name = "description", length = 30, nullable = false) + @Column(name = "description", length = 100, nullable = false) private String description; @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) diff --git a/src/main/resources/db/migration/V89__expand_web_club_short_description.sql b/src/main/resources/db/migration/V89__expand_web_club_short_description.sql new file mode 100644 index 000000000..1aa142da9 --- /dev/null +++ b/src/main/resources/db/migration/V89__expand_web_club_short_description.sql @@ -0,0 +1,8 @@ +ALTER TABLE web_club + MODIFY COLUMN description VARCHAR(100) NOT NULL; + +ALTER TABLE club_registration_request + MODIFY COLUMN short_description VARCHAR(100) NOT NULL; + +ALTER TABLE club_information_update_request + MODIFY COLUMN short_description VARCHAR(100) NOT NULL; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java index aa5442499..9cfbdd899 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -126,6 +126,42 @@ void registerClubWithLongIntroduction() throws Exception { .andExpect(status().isBadRequest()); } + @Test + @DisplayName("한 줄 소개는 100자까지 등록할 수 있다") + void registerClubWithHundredCharacterShortDescription() throws Exception { + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "가".repeat(100), + "상세한 동아리 소개 내용입니다.", + List.of() + ); + + performPost("/konect/clubs/registration-requests", request) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("한 줄 소개가 100자를 초과하면 등록 요청을 거절한다") + void registerClubWithTooLongShortDescription() throws Exception { + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "가".repeat(101), + "상세한 동아리 소개 내용입니다.", + List.of() + ); + + performPost("/konect/clubs/registration-requests", request) + .andExpect(status().isBadRequest()); + } + @Test @DisplayName("비로그인 사용자도 기존 동아리 정보 수정 요청을 보낼 수 있다") void requestClubInformationUpdateWithoutLogin() throws Exception { @@ -171,6 +207,44 @@ void requestClubInformationUpdateWithMissingFields() throws Exception { .andExpect(status().isBadRequest()); } + @Test + @DisplayName("한 줄 소개는 100자까지 정보 수정을 요청할 수 있다") + void requestClubInformationUpdateWithHundredCharacterShortDescription() throws Exception { + WebUniversity university = persist(WebUniversityFixture.create()); + WebClub club = persist(WebClubFixture.create(university)); + ClubInformationUpdateRequestDto request = new ClubInformationUpdateRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "가".repeat(100), + "수정 상세 소개입니다.", + List.of() + ); + + performPost("/konect/clubs/" + club.getId() + "/information-update-requests", request) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("한 줄 소개가 100자를 초과하면 정보 수정 요청을 거절한다") + void requestClubInformationUpdateWithTooLongShortDescription() throws Exception { + WebUniversity university = persist(WebUniversityFixture.create()); + WebClub club = persist(WebClubFixture.create(university)); + ClubInformationUpdateRequestDto request = new ClubInformationUpdateRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "가".repeat(101), + "수정 상세 소개입니다.", + List.of() + ); + + performPost("/konect/clubs/" + club.getId() + "/information-update-requests", request) + .andExpect(status().isBadRequest()); + } + private ClubInformationUpdateRequestDto createInformationUpdateRequest() { return new ClubInformationUpdateRequestDto( "한국기술교육대학교", 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 0b42a76ea..b13e384ca 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 @@ -109,6 +109,29 @@ void previewClubsReadsFixedSheetColumns() throws Exception { assertThat(preview.warnings()).hasSize(3); } + @Test + void previewClubsPreservesDescriptionUpToHundredCharacters() throws Exception { + String description = "가".repeat(100); + 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("동아리명", "동아리 분과", "기타 분과", "동아리 주제", "대표 이모지", "한 줄 소개"), + List.of("BCSD", "학술분과", "", "개발", "💻", description) + ))); + + AdminWebsiteClubSheetImportPreviewResponse preview = service.previewClubs( + UNIVERSITY_ID, + "https://docs.google.com/spreadsheets/d/sheet-id/edit" + ); + + assertThat(preview.clubs()).singleElement() + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::description) + .isEqualTo(description); + } + @Test void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { List clubs = List.of(