From 3d8da9ad2f190595e2b77c5179de1cd25367c636 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: Sun, 31 May 2026 22:08:17 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=9B=B9=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 웹 동아리 목록 정렬 옵션 추가 --- .../website/dto/WebsiteClubListCondition.java | 11 +++++- .../domain/website/dto/WebsiteClubSortBy.java | 6 ++++ .../repository/WebsiteQueryRepository.java | 34 ++++++++++++++++++- .../website/service/WebsiteService.java | 1 + .../domain/website/WebsiteApiTest.java | 27 +++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubSortBy.java diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java index f6f5c4678..194f04ea2 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java @@ -21,14 +21,23 @@ public record WebsiteClubListCondition( String query, @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = NOT_REQUIRED) - ClubCategory category + ClubCategory category, + + @Schema( + description = "동아리 목록 정렬 기준 (NAME: 가나다, CATEGORY: 분과)", + example = "NAME", + requiredMode = NOT_REQUIRED + ) + WebsiteClubSortBy sortBy ) { private static final int DEFAULT_PAGE = 1; private static final int DEFAULT_LIMIT = 12; + private static final WebsiteClubSortBy DEFAULT_SORT_BY = WebsiteClubSortBy.NAME; public WebsiteClubListCondition { page = page == null ? DEFAULT_PAGE : page; limit = limit == null ? DEFAULT_LIMIT : limit; + sortBy = sortBy == null ? DEFAULT_SORT_BY : sortBy; } } diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubSortBy.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubSortBy.java new file mode 100644 index 000000000..4c7027090 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubSortBy.java @@ -0,0 +1,6 @@ +package gg.agit.konect.domain.website.dto; + +public enum WebsiteClubSortBy { + NAME, + CATEGORY +} diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java index c8d2acec3..20314d6ee 100644 --- a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -15,12 +15,15 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.dto.WebsiteClubSortBy; import gg.agit.konect.domain.website.model.WebClub; import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; @@ -84,6 +87,7 @@ public Page findClubs( Integer universityId, String query, ClubCategory category, + WebsiteClubSortBy sortBy, PageRequest pageable ) { BooleanBuilder condition = createClubCondition(universityId, query, category); @@ -92,7 +96,7 @@ public Page findClubs( .selectFrom(webClub) .join(webClub.university, webUniversity).fetchJoin() .where(condition) - .orderBy(webClub.name.asc(), webClub.id.asc()) + .orderBy(createClubOrder(sortBy)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); @@ -106,6 +110,34 @@ public Page findClubs( return new PageImpl<>(clubs, pageable, total == null ? 0 : total); } + private OrderSpecifier[] createClubOrder(WebsiteClubSortBy sortBy) { + if (sortBy == WebsiteClubSortBy.CATEGORY) { + return new OrderSpecifier[] { + createCategoryOrder().asc(), + webClub.name.asc(), + webClub.id.asc() + }; + } + + return new OrderSpecifier[] { + webClub.name.asc(), + webClub.id.asc() + }; + } + + private NumberExpression createCategoryOrder() { + return new CaseBuilder() + .when(webClub.clubCategory.eq(ClubCategory.PERFORMANCE)).then(1) + .when(webClub.clubCategory.eq(ClubCategory.SOCIAL_SERVICE)).then(2) + .when(webClub.clubCategory.eq(ClubCategory.EXHIBITION_CREATION)).then(3) + .when(webClub.clubCategory.eq(ClubCategory.RELIGION)).then(4) + .when(webClub.clubCategory.eq(ClubCategory.SPORTS)).then(5) + .when(webClub.clubCategory.eq(ClubCategory.HOBBY)).then(6) + .when(webClub.clubCategory.eq(ClubCategory.ACADEMIC)).then(7) + .when(webClub.clubCategory.eq(ClubCategory.ETC)).then(8) + .otherwise(Integer.MAX_VALUE); + } + public Map countClubCategories(Integer universityId, String query) { BooleanBuilder condition = createClubCondition(universityId, query, null); NumberExpression clubCount = webClub.count(); 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 6b782f561..19b0e883c 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 @@ -51,6 +51,7 @@ public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClub universityId, condition.query(), condition.category(), + condition.sortBy(), pageable ); diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index 8ea13d278..19b5095bb 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -151,6 +151,33 @@ void getUniversityClubsWithFilters() throws Exception { .andExpect(jsonPath("$.categories[7].category").value("ETC")); } + @Test + @DisplayName("sortBy=CATEGORY이면 분과 표시 순서와 동아리명 순서로 정렬한다") + void getUniversityClubsSortedByCategory() throws Exception { + // given + WebUniversity university = persist(WebUniversityFixture.create( + "Koreatech", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + persist(WebClubFixture.create(university, "Zeta", ClubCategory.ACADEMIC)); + persist(WebClubFixture.create(university, "Alpha", ClubCategory.ACADEMIC)); + persist(WebClubFixture.create(university, "Runner", ClubCategory.SPORTS)); + persist(WebClubFixture.create(university, "Band", ClubCategory.PERFORMANCE)); + clearPersistenceContext(); + + // when & then + performGet("/konect/universities/" + university.getId() + "/clubs?sortBy=CATEGORY") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clubs[*].name", contains("Band", "Runner", "Alpha", "Zeta"))) + .andExpect(jsonPath("$.clubs[*].category", contains( + "PERFORMANCE", + "SPORTS", + "ACADEMIC", + "ACADEMIC" + ))); + } + @Test @DisplayName("존재하지 않는 대학이면 404를 반환한다") void getUniversityClubsNotFound() throws Exception { From 2c9c6d11cadb2db8f7ec364822247901b5fdd396 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, 1 Jun 2026 10:06:49 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EB=8C=80=ED=95=99=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=95=BD=EC=B9=AD=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 대학 검색 키워드 테이블 추가 * feat: 대학 검색 약칭 데이터 추가 * refactor: 대학 검색 약칭 조회 분리 * test: 대학 검색 약칭 데이터 검증 추가 * fix: 대학 검색 키워드 마이그레이션 안정성 보강 * test: 대학 검색 키워드 시드 검증 추가 * fix: 대학 검색 키워드 연관 필드 문자열 제외 --- .../enums/UniversitySearchKeywordType.java | 6 + .../model/UniversitySearchKeyword.java | 76 ++++++++++ .../UniversitySearchKeywordRepository.java | 23 +++ .../UniversitySearchKeywordReader.java | 34 +++++ .../service/UniversitySearchMatcher.java | 54 ++----- .../university/service/UniversityService.java | 13 +- .../website/service/WebsiteService.java | 19 ++- .../V86__create_university_search_keyword.sql | 17 +++ .../V87__seed_university_search_keywords.sql | 63 ++++++++ .../domain/university/UniversityApiTest.java | 8 +- .../domain/website/WebsiteApiTest.java | 15 ++ .../UniversitySearchKeywordFixture.java | 19 +++ .../UniversitySearchKeywordMigrationTest.java | 141 ++++++++++++++++++ 13 files changed, 440 insertions(+), 48 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/university/enums/UniversitySearchKeywordType.java create mode 100644 src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java create mode 100644 src/main/java/gg/agit/konect/domain/university/repository/UniversitySearchKeywordRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/university/service/UniversitySearchKeywordReader.java create mode 100644 src/main/resources/db/migration/V86__create_university_search_keyword.sql create mode 100644 src/main/resources/db/migration/V87__seed_university_search_keywords.sql create mode 100644 src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java diff --git a/src/main/java/gg/agit/konect/domain/university/enums/UniversitySearchKeywordType.java b/src/main/java/gg/agit/konect/domain/university/enums/UniversitySearchKeywordType.java new file mode 100644 index 000000000..90a50ab9d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/enums/UniversitySearchKeywordType.java @@ -0,0 +1,6 @@ +package gg.agit.konect.domain.university.enums; + +public enum UniversitySearchKeywordType { + ALIAS, + ENGLISH_ALIAS +} diff --git a/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java b/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java new file mode 100644 index 000000000..6c0b0b2d0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java @@ -0,0 +1,76 @@ +package gg.agit.konect.domain.university.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.university.enums.UniversitySearchKeywordType; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@Table( + name = "university_search_keyword", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_university_search_keyword_university_keyword", + columnNames = {"university_id", "normalized_keyword"} + ), + }) +@NoArgsConstructor(access = PROTECTED) +public class UniversitySearchKeyword extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @NotNull + @ToString.Exclude + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "university_id", nullable = false) + private University university; + + @NotNull + @Column(name = "keyword", length = 100, nullable = false) + private String keyword; + + @NotNull + @Column(name = "normalized_keyword", length = 100, nullable = false) + private String normalizedKeyword; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "keyword_type", length = 50, nullable = false) + private UniversitySearchKeywordType keywordType; + + @Builder + private UniversitySearchKeyword( + Integer id, + University university, + String keyword, + String normalizedKeyword, + UniversitySearchKeywordType keywordType + ) { + this.id = id; + this.university = university; + this.keyword = keyword; + this.normalizedKeyword = normalizedKeyword; + this.keywordType = keywordType; + } +} diff --git a/src/main/java/gg/agit/konect/domain/university/repository/UniversitySearchKeywordRepository.java b/src/main/java/gg/agit/konect/domain/university/repository/UniversitySearchKeywordRepository.java new file mode 100644 index 000000000..7d61e1491 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/repository/UniversitySearchKeywordRepository.java @@ -0,0 +1,23 @@ +package gg.agit.konect.domain.university.repository; + +import java.util.Collection; +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 gg.agit.konect.domain.university.model.UniversitySearchKeyword; + +public interface UniversitySearchKeywordRepository extends Repository { + + @Query(""" + SELECT keyword + FROM UniversitySearchKeyword keyword + JOIN FETCH keyword.university university + WHERE university.koreanName IN :universityNames + """) + List findAllByUniversityNames( + @Param("universityNames") Collection universityNames + ); +} diff --git a/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchKeywordReader.java b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchKeywordReader.java new file mode 100644 index 000000000..cd8f65eac --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchKeywordReader.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.university.service; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.university.model.UniversitySearchKeyword; +import gg.agit.konect.domain.university.repository.UniversitySearchKeywordRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UniversitySearchKeywordReader { + + private final UniversitySearchKeywordRepository universitySearchKeywordRepository; + + public Map> getKeywordsByUniversityName(Collection universityNames) { + if (universityNames.isEmpty()) { + return Map.of(); + } + + return universitySearchKeywordRepository.findAllByUniversityNames(universityNames) + .stream() + .collect(Collectors.groupingBy( + keyword -> keyword.getUniversity().getKoreanName(), + Collectors.mapping(UniversitySearchKeyword::getKeyword, Collectors.toList()) + )); + } +} diff --git a/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java index 95dd8ee4f..8de6a191a 100644 --- a/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java +++ b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java @@ -2,6 +2,7 @@ import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.stream.Stream; @@ -38,60 +39,29 @@ public class UniversitySearchMatcher { Map.entry("ㅄ", "ㅂㅅ") ); - private static final Map> UNIVERSITY_ALIASES = Map.ofEntries( - Map.entry("가톨릭대학교", List.of("가대")), - Map.entry("건국대학교", List.of("건대")), - Map.entry("경북대학교", List.of("경대", "경북대")), - Map.entry("경희대학교", List.of("경희대")), - Map.entry("고려대학교", List.of("고대")), - Map.entry("광주과학기술원", List.of("광주과기원", "지스트", "gist")), - Map.entry("단국대학교", List.of("단대")), - Map.entry("대구경북과학기술원", List.of("대경과기원", "디지스트", "dgist")), - Map.entry("동국대학교", List.of("동대")), - Map.entry("부산대학교", List.of("부대", "부산대")), - Map.entry("서강대학교", List.of("서강대")), - Map.entry("서울과학기술대학교", List.of("과기대", "서울과기대")), - Map.entry("서울대학교", List.of("설대", "서울대")), - Map.entry("서울시립대학교", List.of("시립대", "서울시립대")), - Map.entry("성균관대학교", List.of("성대")), - Map.entry("연세대학교", List.of("연대")), - Map.entry("울산과학기술원", List.of("울산과기원", "유니스트", "unist")), - Map.entry("육군사관학교", List.of("육사")), - Map.entry("이화여자대학교", List.of("이대", "이화여대")), - Map.entry("전남대학교", List.of("전대", "전남대")), - Map.entry("중앙대학교", List.of("중대")), - Map.entry("충남대학교", List.of("충대", "충남대")), - Map.entry("충북대학교", List.of("충북대")), - Map.entry("포항공과대학교", List.of("포공", "포스텍", "postech")), - Map.entry("한국공학대학교", List.of("한공대", "한국공대")), - Map.entry("한국과학기술원", List.of("카이스트", "kaist")), - Map.entry("한국교통대학교", List.of("교통대", "한국교통대")), - Map.entry("한국기술교육대학교", List.of("한기대", "코리아텍", "koreatech")), - Map.entry("한국외국어대학교", List.of("외대", "한국외대")), - Map.entry("한국체육대학교", List.of("한체대")), - Map.entry("한국항공대학교", List.of("항공대", "한국항공대")), - Map.entry("한국해양대학교", List.of("해양대", "한국해양대")), - Map.entry("해군사관학교", List.of("해사")), - Map.entry("홍익대학교", List.of("홍대")) - ); - public boolean matches(University university, String query) { - return matches(university.getKoreanName(), query); + return matches(university.getKoreanName(), query, List.of()); } public boolean matches(String universityName, String query) { + return matches(universityName, query, List.of()); + } + + public boolean matches(String universityName, String query, List managedKeywords) { if (!StringUtils.hasText(query)) { return true; } String normalizedQuery = normalize(query); - return getSearchTokens(universityName) + return getSearchTokens(universityName, managedKeywords) .anyMatch(token -> token.contains(normalizedQuery)); } - private Stream getSearchTokens(String universityName) { + private Stream getSearchTokens(String universityName, List managedKeywords) { Set aliases = getDefaultAliases(universityName); - aliases.addAll(UNIVERSITY_ALIASES.getOrDefault(universityName, List.of())); + if (managedKeywords != null) { + aliases.addAll(managedKeywords); + } List normalizedAliases = aliases.stream() .map(this::normalize) @@ -130,7 +100,7 @@ private Set getDefaultAliases(String universityName) { private String normalize(String value) { return expandCompatibilityJamoClusters(value) .replaceAll("\\s", "") - .toLowerCase(); + .toLowerCase(Locale.ROOT); } private String expandCompatibilityJamoClusters(String value) { diff --git a/src/main/java/gg/agit/konect/domain/university/service/UniversityService.java b/src/main/java/gg/agit/konect/domain/university/service/UniversityService.java index 8a4f8bf24..0b25bd340 100644 --- a/src/main/java/gg/agit/konect/domain/university/service/UniversityService.java +++ b/src/main/java/gg/agit/konect/domain/university/service/UniversityService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.university.service; import java.util.List; +import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,11 +19,21 @@ public class UniversityService { private final UniversityRepository universityRepository; private final UniversitySearchMatcher universitySearchMatcher; + private final UniversitySearchKeywordReader universitySearchKeywordReader; public UniversitiesResponse getUniversities(String query) { List universities = universityRepository.findAllByOrderByKoreanNameAsc(); + Map> keywordsByUniversityName = universitySearchKeywordReader.getKeywordsByUniversityName( + universities.stream() + .map(University::getKoreanName) + .toList() + ); List filteredUniversities = universities.stream() - .filter(university -> universitySearchMatcher.matches(university, query)) + .filter(university -> universitySearchMatcher.matches( + university.getKoreanName(), + query, + keywordsByUniversityName.getOrDefault(university.getKoreanName(), List.of()) + )) .toList(); return UniversitiesResponse.from(filteredUniversities); 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 19b0e883c..5cb118ab5 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 @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.service.UniversitySearchKeywordReader; import gg.agit.konect.domain.university.service.UniversitySearchMatcher; import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; @@ -32,14 +33,26 @@ public class WebsiteService { private final WebsiteQueryRepository websiteQueryRepository; private final UniversitySearchMatcher universitySearchMatcher; + private final UniversitySearchKeywordReader universitySearchKeywordReader; public WebsiteHomeResponse getHome(String query, UniversityRegion region) { - // 초성/약칭 검색은 SQL로 표현하기 어려워 전체 대학을 조회한 뒤 UniversitySearchMatcher로 필터링한다. List summaries = websiteQueryRepository.findUniversitySummaries(null, region) .stream() - .filter(summary -> universitySearchMatcher.matches(summary.name(), query)) .toList(); - return WebsiteHomeResponse.from(summaries); + Map> keywordsByUniversityName = universitySearchKeywordReader.getKeywordsByUniversityName( + summaries.stream() + .map(WebsiteUniversitySummary::name) + .toList() + ); + + List filteredSummaries = summaries.stream() + .filter(summary -> universitySearchMatcher.matches( + summary.name(), + query, + keywordsByUniversityName.getOrDefault(summary.name(), List.of()) + )) + .toList(); + return WebsiteHomeResponse.from(filteredSummaries); } public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClubListCondition condition) { diff --git a/src/main/resources/db/migration/V86__create_university_search_keyword.sql b/src/main/resources/db/migration/V86__create_university_search_keyword.sql new file mode 100644 index 000000000..4502204de --- /dev/null +++ b/src/main/resources/db/migration/V86__create_university_search_keyword.sql @@ -0,0 +1,17 @@ +CREATE TABLE university_search_keyword +( + id INT AUTO_INCREMENT PRIMARY KEY, + university_id INT NOT NULL, + keyword VARCHAR(100) NOT NULL, + normalized_keyword VARCHAR(100) NOT NULL, + keyword_type VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT chk_university_search_keyword_keyword_type CHECK (keyword_type IN ('ALIAS', 'ENGLISH_ALIAS')), + CONSTRAINT fk_university_search_keyword_university FOREIGN KEY (university_id) REFERENCES university (id), + CONSTRAINT uq_university_search_keyword_university_keyword UNIQUE (university_id, normalized_keyword) +); + +CREATE INDEX idx_university_search_keyword_normalized_keyword + ON university_search_keyword (normalized_keyword); diff --git a/src/main/resources/db/migration/V87__seed_university_search_keywords.sql b/src/main/resources/db/migration/V87__seed_university_search_keywords.sql new file mode 100644 index 000000000..64fd60711 --- /dev/null +++ b/src/main/resources/db/migration/V87__seed_university_search_keywords.sql @@ -0,0 +1,63 @@ +INSERT INTO university_search_keyword (university_id, keyword, normalized_keyword, keyword_type) +SELECT university.id, expected_keyword.keyword, expected_keyword.normalized_keyword, expected_keyword.keyword_type +FROM ( + SELECT '가톨릭대학교' AS university_name, '가대' AS keyword, '가대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '건국대학교' AS university_name, '건대' AS keyword, '건대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경대' AS keyword, '경대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경북대' AS keyword, '경북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경희대학교' AS university_name, '경희대' AS keyword, '경희대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '고려대학교' AS university_name, '고대' AS keyword, '고대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '광주과기원' AS keyword, '광주과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '지스트' AS keyword, '지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, 'gist' AS keyword, 'gist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '단국대학교' AS university_name, '단대' AS keyword, '단대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '대경과기원' AS keyword, '대경과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '디지스트' AS keyword, '디지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, 'dgist' AS keyword, 'dgist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '동국대학교' AS university_name, '동대' AS keyword, '동대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부대' AS keyword, '부대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부산대' AS keyword, '부산대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서강대학교' AS university_name, '서강대' AS keyword, '서강대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '과기대' AS keyword, '과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '서울과기대' AS keyword, '서울과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '설대' AS keyword, '설대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '서울대' AS keyword, '서울대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '시립대' AS keyword, '시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '서울시립대' AS keyword, '서울시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '성균관대학교' AS university_name, '성대' AS keyword, '성대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '연세대학교' AS university_name, '연대' AS keyword, '연대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '울산과기원' AS keyword, '울산과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '유니스트' AS keyword, '유니스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, 'unist' AS keyword, 'unist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '육군사관학교' AS university_name, '육사' AS keyword, '육사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이대' AS keyword, '이대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이화여대' AS keyword, '이화여대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전대' AS keyword, '전대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전남대' AS keyword, '전남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '중앙대학교' AS university_name, '중대' AS keyword, '중대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충대' AS keyword, '충대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충남대' AS keyword, '충남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충북대학교' AS university_name, '충북대' AS keyword, '충북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포공' AS keyword, '포공' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포스텍' AS keyword, '포스텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, 'postech' AS keyword, 'postech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한공대' AS keyword, '한공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한국공대' AS keyword, '한국공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, '카이스트' AS keyword, '카이스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, 'kaist' AS keyword, 'kaist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '교통대' AS keyword, '교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '한국교통대' AS keyword, '한국교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '한기대' AS keyword, '한기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '코리아텍' AS keyword, '코리아텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, 'koreatech' AS keyword, 'koreatech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '외대' AS keyword, '외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '한국외대' AS keyword, '한국외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국체육대학교' AS university_name, '한체대' AS keyword, '한체대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '항공대' AS keyword, '항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '한국항공대' AS keyword, '한국항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '해양대' AS keyword, '해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '한국해양대' AS keyword, '한국해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '해군사관학교' AS university_name, '해사' AS keyword, '해사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '홍익대학교' AS university_name, '홍대' AS keyword, '홍대' AS normalized_keyword, 'ALIAS' AS keyword_type +) expected_keyword +LEFT JOIN university ON university.korean_name = expected_keyword.university_name; diff --git a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java index dc778047a..bf0018bd6 100644 --- a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java @@ -9,7 +9,9 @@ import org.junit.jupiter.api.Test; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.model.University; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversitySearchKeywordFixture; import gg.agit.konect.support.fixture.UniversityFixture; class UniversityApiTest extends IntegrationTestSupport { @@ -98,9 +100,11 @@ void getUniversitiesByChoseongQueryWithCompatibilityJamoCluster() throws Excepti @DisplayName("query가 대학교 약칭이면 일치하는 대학 목록을 조회한다") void getUniversitiesByAliasQuery() throws Exception { // given - persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); - persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); + University koreatech = persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + University seoulTech = persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); persist(UniversityFixture.create("서울대학교", Campus.MAIN)); + persist(UniversitySearchKeywordFixture.createAlias(koreatech, "한기대")); + persist(UniversitySearchKeywordFixture.createAlias(seoulTech, "과기대")); clearPersistenceContext(); // when & then diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index 19b5095bb..5069ee5a5 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -18,9 +18,12 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.website.model.WebClub; import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UniversitySearchKeywordFixture; import gg.agit.konect.support.fixture.WebClubFixture; import gg.agit.konect.support.fixture.WebUniversityFixture; @@ -78,6 +81,16 @@ void getHomeWithoutLogin() throws Exception { @DisplayName("대학교 이름 초성과 약칭으로 웹사이트 대학 목록을 검색한다") void getHomeSearchesUniversitiesByChoseongAndAlias() throws Exception { // given + University koreatech = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + University seoulTech = persist(UniversityFixture.create( + "서울과학기술대학교", + Campus.MAIN, + UniversityRegion.SEOUL + )); persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, @@ -89,6 +102,8 @@ void getHomeSearchesUniversitiesByChoseongAndAlias() throws Exception { Campus.MAIN, UniversityRegion.SEOUL )); + persist(UniversitySearchKeywordFixture.createAlias(koreatech, "한기대")); + persist(UniversitySearchKeywordFixture.createAlias(seoulTech, "과기대")); clearPersistenceContext(); // when & then diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java new file mode 100644 index 000000000..cab5b3b5c --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java @@ -0,0 +1,19 @@ +package gg.agit.konect.support.fixture; + +import java.util.Locale; + +import gg.agit.konect.domain.university.enums.UniversitySearchKeywordType; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.university.model.UniversitySearchKeyword; + +public class UniversitySearchKeywordFixture { + + public static UniversitySearchKeyword createAlias(University university, String keyword) { + return UniversitySearchKeyword.builder() + .university(university) + .keyword(keyword) + .normalizedKeyword(keyword.replaceAll("\\s", "").toLowerCase(Locale.ROOT)) + .keywordType(UniversitySearchKeywordType.ALIAS) + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java new file mode 100644 index 000000000..68384f038 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java @@ -0,0 +1,141 @@ +package gg.agit.konect.unit.domain.university; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +class UniversitySearchKeywordMigrationTest { + + private static final List SEEDED_UNIVERSITY_NAMES = List.of( + "가톨릭대학교", + "건국대학교", + "경북대학교", + "경희대학교", + "고려대학교", + "광주과학기술원", + "단국대학교", + "대구경북과학기술원", + "동국대학교", + "부산대학교", + "서강대학교", + "서울과학기술대학교", + "서울대학교", + "서울시립대학교", + "성균관대학교", + "연세대학교", + "울산과학기술원", + "육군사관학교", + "이화여자대학교", + "전남대학교", + "중앙대학교", + "충남대학교", + "충북대학교", + "포항공과대학교", + "한국공학대학교", + "한국과학기술원", + "한국교통대학교", + "한국기술교육대학교", + "한국외국어대학교", + "한국체육대학교", + "한국항공대학교", + "한국해양대학교", + "해군사관학교", + "홍익대학교" + ); + + private static final int EXPECTED_KEYWORD_COUNT = 58; + + @Test + void seedUniversitySearchKeywords() throws Exception { + JdbcTemplate jdbcTemplate = createJdbcTemplate("seedSuccess"); + createUniversityTable(jdbcTemplate); + insertUniversities(jdbcTemplate, SEEDED_UNIVERSITY_NAMES); + + executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); + executeMigration(jdbcTemplate, "db/migration/V87__seed_university_search_keywords.sql"); + + Integer keywordCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM university_search_keyword", + Integer.class + ); + Integer koreatechAliasCount = jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM university_search_keyword keyword + JOIN university ON university.id = keyword.university_id + WHERE university.korean_name = '한국기술교육대학교' + AND keyword.keyword IN ('한기대', '코리아텍', 'koreatech') + """, + Integer.class + ); + Integer seoulTechAliasCount = jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM university_search_keyword keyword + JOIN university ON university.id = keyword.university_id + WHERE university.korean_name = '서울과학기술대학교' + AND keyword.keyword IN ('과기대', '서울과기대') + """, + Integer.class + ); + + assertThat(keywordCount).isEqualTo(EXPECTED_KEYWORD_COUNT); + assertThat(koreatechAliasCount).isEqualTo(3); + assertThat(seoulTechAliasCount).isEqualTo(2); + } + + @Test + void failWhenSeedTargetUniversityIsMissing() throws Exception { + JdbcTemplate jdbcTemplate = createJdbcTemplate("seedMissingUniversity"); + createUniversityTable(jdbcTemplate); + insertUniversities(jdbcTemplate, SEEDED_UNIVERSITY_NAMES.stream() + .filter(universityName -> !universityName.equals("한국기술교육대학교")) + .toList()); + executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); + + assertThatThrownBy(() -> + executeMigration(jdbcTemplate, "db/migration/V87__seed_university_search_keywords.sql")) + .isInstanceOf(Exception.class); + } + + private JdbcTemplate createJdbcTemplate(String databaseName) { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.h2.Driver"); + dataSource.setUrl("jdbc:h2:mem:" + databaseName + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); + dataSource.setUsername("sa"); + dataSource.setPassword(""); + return new JdbcTemplate(dataSource); + } + + private void createUniversityTable(JdbcTemplate jdbcTemplate) { + jdbcTemplate.execute(""" + CREATE TABLE university + ( + id INT AUTO_INCREMENT PRIMARY KEY, + korean_name VARCHAR(255) NOT NULL + ) + """); + } + + private void insertUniversities(JdbcTemplate jdbcTemplate, List universityNames) { + for (String universityName : universityNames) { + jdbcTemplate.update("INSERT INTO university (korean_name) VALUES (?)", universityName); + } + } + + private void executeMigration(JdbcTemplate jdbcTemplate, String path) throws SQLException { + try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { + ScriptUtils.executeSqlScript(connection, new EncodedResource(new ClassPathResource(path), "UTF-8")); + } + } +} From 4a70e4e4bd581732f88a4bb7f60239e1ec657bf1 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, 1 Jun 2026 12:05:00 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=EB=8C=80?= =?UTF-8?q?=ED=95=99=20=EA=B2=80=EC=83=89=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 기존 대학 검색 키워드 시드 추가 * test: 대학 검색 키워드 시드 검증 강화 --- ...ed_existing_university_search_keywords.sql | 68 +++++++++++++++++++ .../UniversitySearchKeywordMigrationTest.java | 27 ++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql diff --git a/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql b/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql new file mode 100644 index 000000000..05f8a38ea --- /dev/null +++ b/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql @@ -0,0 +1,68 @@ +-- V88 uses expected_keyword JOIN university to seed only universities that already exist. +-- ON DUPLICATE KEY UPDATE keeps this corrective seed safe when keywords were partially inserted. +INSERT INTO university_search_keyword (university_id, keyword, normalized_keyword, keyword_type) +SELECT university.id, expected_keyword.keyword, expected_keyword.normalized_keyword, expected_keyword.keyword_type +FROM ( + SELECT '가톨릭대학교' AS university_name, '가대' AS keyword, '가대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '건국대학교' AS university_name, '건대' AS keyword, '건대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경대' AS keyword, '경대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경북대' AS keyword, '경북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경희대학교' AS university_name, '경희대' AS keyword, '경희대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '고려대학교' AS university_name, '고대' AS keyword, '고대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '광주과기원' AS keyword, '광주과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '지스트' AS keyword, '지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, 'gist' AS keyword, 'gist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '단국대학교' AS university_name, '단대' AS keyword, '단대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '대경과기원' AS keyword, '대경과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '디지스트' AS keyword, '디지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, 'dgist' AS keyword, 'dgist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '동국대학교' AS university_name, '동대' AS keyword, '동대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부대' AS keyword, '부대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부산대' AS keyword, '부산대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서강대학교' AS university_name, '서강대' AS keyword, '서강대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '과기대' AS keyword, '과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '서울과기대' AS keyword, '서울과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '설대' AS keyword, '설대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '서울대' AS keyword, '서울대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '시립대' AS keyword, '시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '서울시립대' AS keyword, '서울시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '성균관대학교' AS university_name, '성대' AS keyword, '성대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '연세대학교' AS university_name, '연대' AS keyword, '연대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '울산과기원' AS keyword, '울산과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '유니스트' AS keyword, '유니스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, 'unist' AS keyword, 'unist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '육군사관학교' AS university_name, '육사' AS keyword, '육사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이대' AS keyword, '이대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이화여대' AS keyword, '이화여대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전대' AS keyword, '전대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전남대' AS keyword, '전남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '중앙대학교' AS university_name, '중대' AS keyword, '중대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충대' AS keyword, '충대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충남대' AS keyword, '충남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충북대학교' AS university_name, '충북대' AS keyword, '충북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포공' AS keyword, '포공' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포스텍' AS keyword, '포스텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, 'postech' AS keyword, 'postech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한공대' AS keyword, '한공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한국공대' AS keyword, '한국공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, '카이스트' AS keyword, '카이스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, 'kaist' AS keyword, 'kaist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '교통대' AS keyword, '교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '한국교통대' AS keyword, '한국교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '한기대' AS keyword, '한기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '코리아텍' AS keyword, '코리아텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, 'koreatech' AS keyword, 'koreatech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '외대' AS keyword, '외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '한국외대' AS keyword, '한국외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국체육대학교' AS university_name, '한체대' AS keyword, '한체대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '항공대' AS keyword, '항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '한국항공대' AS keyword, '한국항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '해양대' AS keyword, '해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '한국해양대' AS keyword, '한국해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '해군사관학교' AS university_name, '해사' AS keyword, '해사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '홍익대학교' AS university_name, '홍대' AS keyword, '홍대' AS normalized_keyword, 'ALIAS' AS keyword_type +) expected_keyword +JOIN university ON university.korean_name = expected_keyword.university_name +ON DUPLICATE KEY UPDATE + keyword = VALUES(keyword), + keyword_type = VALUES(keyword_type); diff --git a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java index 68384f038..76e011ec4 100644 --- a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java @@ -108,6 +108,33 @@ void failWhenSeedTargetUniversityIsMissing() throws Exception { .isInstanceOf(Exception.class); } + @Test + void seedExistingUniversitySearchKeywords() throws Exception { + JdbcTemplate jdbcTemplate = createJdbcTemplate("seedExistingUniversity"); + createUniversityTable(jdbcTemplate); + insertUniversities(jdbcTemplate, List.of("한국기술교육대학교")); + executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); + + executeMigration(jdbcTemplate, "db/migration/V88__seed_existing_university_search_keywords.sql"); + + Integer keywordCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM university_search_keyword", + Integer.class + ); + List keywords = jdbcTemplate.queryForList( + """ + SELECT keyword.keyword + FROM university_search_keyword keyword + JOIN university ON university.id = keyword.university_id + WHERE university.korean_name = '한국기술교육대학교' + """, + String.class + ); + + assertThat(keywordCount).isEqualTo(3); + assertThat(keywords).containsExactlyInAnyOrder("한기대", "코리아텍", "koreatech"); + } + private JdbcTemplate createJdbcTemplate(String databaseName) { DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName("org.h2.Driver"); From 6be8eb1887308cefa9948dfa0580bf23707a0084 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, 1 Jun 2026 12:17:16 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=EB=8C=80=ED=95=99=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=8B=9C=EB=93=9C=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V87__seed_university_search_keywords.sql | 7 +- ...ed_existing_university_search_keywords.sql | 68 ------------------- .../UniversitySearchKeywordMigrationTest.java | 21 +----- 3 files changed, 9 insertions(+), 87 deletions(-) delete mode 100644 src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql diff --git a/src/main/resources/db/migration/V87__seed_university_search_keywords.sql b/src/main/resources/db/migration/V87__seed_university_search_keywords.sql index 64fd60711..d4aa1f267 100644 --- a/src/main/resources/db/migration/V87__seed_university_search_keywords.sql +++ b/src/main/resources/db/migration/V87__seed_university_search_keywords.sql @@ -1,3 +1,5 @@ +-- Seed keywords only for universities that already exist in each environment. +-- This migration can be retried after flyway repair without duplicating existing keywords. INSERT INTO university_search_keyword (university_id, keyword, normalized_keyword, keyword_type) SELECT university.id, expected_keyword.keyword, expected_keyword.normalized_keyword, expected_keyword.keyword_type FROM ( @@ -60,4 +62,7 @@ FROM ( UNION ALL SELECT '해군사관학교' AS university_name, '해사' AS keyword, '해사' AS normalized_keyword, 'ALIAS' AS keyword_type UNION ALL SELECT '홍익대학교' AS university_name, '홍대' AS keyword, '홍대' AS normalized_keyword, 'ALIAS' AS keyword_type ) expected_keyword -LEFT JOIN university ON university.korean_name = expected_keyword.university_name; +JOIN university ON university.korean_name = expected_keyword.university_name +ON DUPLICATE KEY UPDATE + keyword = VALUES(keyword), + keyword_type = VALUES(keyword_type); diff --git a/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql b/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql deleted file mode 100644 index 05f8a38ea..000000000 --- a/src/main/resources/db/migration/V88__seed_existing_university_search_keywords.sql +++ /dev/null @@ -1,68 +0,0 @@ --- V88 uses expected_keyword JOIN university to seed only universities that already exist. --- ON DUPLICATE KEY UPDATE keeps this corrective seed safe when keywords were partially inserted. -INSERT INTO university_search_keyword (university_id, keyword, normalized_keyword, keyword_type) -SELECT university.id, expected_keyword.keyword, expected_keyword.normalized_keyword, expected_keyword.keyword_type -FROM ( - SELECT '가톨릭대학교' AS university_name, '가대' AS keyword, '가대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '건국대학교' AS university_name, '건대' AS keyword, '건대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '경북대학교' AS university_name, '경대' AS keyword, '경대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '경북대학교' AS university_name, '경북대' AS keyword, '경북대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '경희대학교' AS university_name, '경희대' AS keyword, '경희대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '고려대학교' AS university_name, '고대' AS keyword, '고대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '광주과학기술원' AS university_name, '광주과기원' AS keyword, '광주과기원' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '광주과학기술원' AS university_name, '지스트' AS keyword, '지스트' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '광주과학기술원' AS university_name, 'gist' AS keyword, 'gist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '단국대학교' AS university_name, '단대' AS keyword, '단대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '대구경북과학기술원' AS university_name, '대경과기원' AS keyword, '대경과기원' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '대구경북과학기술원' AS university_name, '디지스트' AS keyword, '디지스트' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '대구경북과학기술원' AS university_name, 'dgist' AS keyword, 'dgist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '동국대학교' AS university_name, '동대' AS keyword, '동대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '부산대학교' AS university_name, '부대' AS keyword, '부대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '부산대학교' AS university_name, '부산대' AS keyword, '부산대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서강대학교' AS university_name, '서강대' AS keyword, '서강대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울과학기술대학교' AS university_name, '과기대' AS keyword, '과기대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울과학기술대학교' AS university_name, '서울과기대' AS keyword, '서울과기대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울대학교' AS university_name, '설대' AS keyword, '설대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울대학교' AS university_name, '서울대' AS keyword, '서울대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울시립대학교' AS university_name, '시립대' AS keyword, '시립대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '서울시립대학교' AS university_name, '서울시립대' AS keyword, '서울시립대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '성균관대학교' AS university_name, '성대' AS keyword, '성대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '연세대학교' AS university_name, '연대' AS keyword, '연대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '울산과학기술원' AS university_name, '울산과기원' AS keyword, '울산과기원' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '울산과학기술원' AS university_name, '유니스트' AS keyword, '유니스트' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '울산과학기술원' AS university_name, 'unist' AS keyword, 'unist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '육군사관학교' AS university_name, '육사' AS keyword, '육사' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '이화여자대학교' AS university_name, '이대' AS keyword, '이대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '이화여자대학교' AS university_name, '이화여대' AS keyword, '이화여대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '전남대학교' AS university_name, '전대' AS keyword, '전대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '전남대학교' AS university_name, '전남대' AS keyword, '전남대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '중앙대학교' AS university_name, '중대' AS keyword, '중대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '충남대학교' AS university_name, '충대' AS keyword, '충대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '충남대학교' AS university_name, '충남대' AS keyword, '충남대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '충북대학교' AS university_name, '충북대' AS keyword, '충북대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '포항공과대학교' AS university_name, '포공' AS keyword, '포공' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '포항공과대학교' AS university_name, '포스텍' AS keyword, '포스텍' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '포항공과대학교' AS university_name, 'postech' AS keyword, 'postech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '한국공학대학교' AS university_name, '한공대' AS keyword, '한공대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국공학대학교' AS university_name, '한국공대' AS keyword, '한국공대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국과학기술원' AS university_name, '카이스트' AS keyword, '카이스트' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국과학기술원' AS university_name, 'kaist' AS keyword, 'kaist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '한국교통대학교' AS university_name, '교통대' AS keyword, '교통대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국교통대학교' AS university_name, '한국교통대' AS keyword, '한국교통대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국기술교육대학교' AS university_name, '한기대' AS keyword, '한기대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국기술교육대학교' AS university_name, '코리아텍' AS keyword, '코리아텍' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국기술교육대학교' AS university_name, 'koreatech' AS keyword, 'koreatech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type - UNION ALL SELECT '한국외국어대학교' AS university_name, '외대' AS keyword, '외대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국외국어대학교' AS university_name, '한국외대' AS keyword, '한국외대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국체육대학교' AS university_name, '한체대' AS keyword, '한체대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국항공대학교' AS university_name, '항공대' AS keyword, '항공대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국항공대학교' AS university_name, '한국항공대' AS keyword, '한국항공대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국해양대학교' AS university_name, '해양대' AS keyword, '해양대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '한국해양대학교' AS university_name, '한국해양대' AS keyword, '한국해양대' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '해군사관학교' AS university_name, '해사' AS keyword, '해사' AS normalized_keyword, 'ALIAS' AS keyword_type - UNION ALL SELECT '홍익대학교' AS university_name, '홍대' AS keyword, '홍대' AS normalized_keyword, 'ALIAS' AS keyword_type -) expected_keyword -JOIN university ON university.korean_name = expected_keyword.university_name -ON DUPLICATE KEY UPDATE - keyword = VALUES(keyword), - keyword_type = VALUES(keyword_type); diff --git a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java index 76e011ec4..c1b3fd8d9 100644 --- a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java @@ -1,7 +1,6 @@ package gg.agit.konect.unit.domain.university; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.sql.Connection; import java.sql.SQLException; @@ -95,27 +94,13 @@ AND keyword.keyword IN ('과기대', '서울과기대') } @Test - void failWhenSeedTargetUniversityIsMissing() throws Exception { - JdbcTemplate jdbcTemplate = createJdbcTemplate("seedMissingUniversity"); - createUniversityTable(jdbcTemplate); - insertUniversities(jdbcTemplate, SEEDED_UNIVERSITY_NAMES.stream() - .filter(universityName -> !universityName.equals("한국기술교육대학교")) - .toList()); - executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); - - assertThatThrownBy(() -> - executeMigration(jdbcTemplate, "db/migration/V87__seed_university_search_keywords.sql")) - .isInstanceOf(Exception.class); - } - - @Test - void seedExistingUniversitySearchKeywords() throws Exception { - JdbcTemplate jdbcTemplate = createJdbcTemplate("seedExistingUniversity"); + void seedOnlyExistingUniversitySearchKeywords() throws Exception { + JdbcTemplate jdbcTemplate = createJdbcTemplate("seedOnlyExistingUniversity"); createUniversityTable(jdbcTemplate); insertUniversities(jdbcTemplate, List.of("한국기술교육대학교")); executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); - executeMigration(jdbcTemplate, "db/migration/V88__seed_existing_university_search_keywords.sql"); + executeMigration(jdbcTemplate, "db/migration/V87__seed_university_search_keywords.sql"); Integer keywordCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM university_search_keyword", From a17a54e85dceeffcd863e92883bd026907a0bec3 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, 1 Jun 2026 14:19:17 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EC=9B=B9=20=EB=8C=80=ED=95=99=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EA=B2=80=EC=83=89=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 웹 대학 기준 검색 키워드 보정 * fix: 웹 대학 검색 키워드 마이그레이션 순서 보정 --- .../model/UniversitySearchKeyword.java | 5 +- ...sity_search_keywords_to_web_university.sql | 96 +++++++++++++++++++ .../domain/university/UniversityApiTest.java | 8 +- .../domain/website/WebsiteApiTest.java | 16 +--- .../UniversitySearchKeywordFixture.java | 4 +- .../UniversitySearchKeywordMigrationTest.java | 46 +++++++++ 6 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/db/migration/V88__move_university_search_keywords_to_web_university.sql diff --git a/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java b/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java index 6c0b0b2d0..c799f27b5 100644 --- a/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java +++ b/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java @@ -6,6 +6,7 @@ import static lombok.AccessLevel.PROTECTED; import gg.agit.konect.domain.university.enums.UniversitySearchKeywordType; +import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.global.model.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -44,7 +45,7 @@ public class UniversitySearchKeyword extends BaseEntity { @ToString.Exclude @ManyToOne(fetch = LAZY) @JoinColumn(name = "university_id", nullable = false) - private University university; + private WebUniversity university; @NotNull @Column(name = "keyword", length = 100, nullable = false) @@ -62,7 +63,7 @@ public class UniversitySearchKeyword extends BaseEntity { @Builder private UniversitySearchKeyword( Integer id, - University university, + WebUniversity university, String keyword, String normalizedKeyword, UniversitySearchKeywordType keywordType diff --git a/src/main/resources/db/migration/V88__move_university_search_keywords_to_web_university.sql b/src/main/resources/db/migration/V88__move_university_search_keywords_to_web_university.sql new file mode 100644 index 000000000..85bd5599a --- /dev/null +++ b/src/main/resources/db/migration/V88__move_university_search_keywords_to_web_university.sql @@ -0,0 +1,96 @@ +CREATE TABLE university_search_keyword_web_university_seed +( + university_id INT NOT NULL, + keyword VARCHAR(100) NOT NULL, + normalized_keyword VARCHAR(100) NOT NULL, + keyword_type VARCHAR(50) NOT NULL, + + CONSTRAINT fk_university_search_keyword_seed_web_university + FOREIGN KEY (university_id) REFERENCES web_university (id), + CONSTRAINT uq_university_search_keyword_seed_university_keyword + UNIQUE (university_id, normalized_keyword) +); + +-- Search keywords are used by the public website, so seed them from web_university. +INSERT INTO university_search_keyword_web_university_seed (university_id, keyword, normalized_keyword, keyword_type) +SELECT web_university.id, expected_keyword.keyword, expected_keyword.normalized_keyword, expected_keyword.keyword_type +FROM ( + SELECT '가톨릭대학교' AS university_name, '가대' AS keyword, '가대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '건국대학교' AS university_name, '건대' AS keyword, '건대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경대' AS keyword, '경대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경북대학교' AS university_name, '경북대' AS keyword, '경북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '경희대학교' AS university_name, '경희대' AS keyword, '경희대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '고려대학교' AS university_name, '고대' AS keyword, '고대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '광주과기원' AS keyword, '광주과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, '지스트' AS keyword, '지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '광주과학기술원' AS university_name, 'gist' AS keyword, 'gist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '단국대학교' AS university_name, '단대' AS keyword, '단대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '대경과기원' AS keyword, '대경과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, '디지스트' AS keyword, '디지스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '대구경북과학기술원' AS university_name, 'dgist' AS keyword, 'dgist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '동국대학교' AS university_name, '동대' AS keyword, '동대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부대' AS keyword, '부대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '부산대학교' AS university_name, '부산대' AS keyword, '부산대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서강대학교' AS university_name, '서강대' AS keyword, '서강대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '과기대' AS keyword, '과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울과학기술대학교' AS university_name, '서울과기대' AS keyword, '서울과기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '설대' AS keyword, '설대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울대학교' AS university_name, '서울대' AS keyword, '서울대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '시립대' AS keyword, '시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '서울시립대학교' AS university_name, '서울시립대' AS keyword, '서울시립대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '성균관대학교' AS university_name, '성대' AS keyword, '성대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '연세대학교' AS university_name, '연대' AS keyword, '연대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '울산과기원' AS keyword, '울산과기원' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, '유니스트' AS keyword, '유니스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '울산과학기술원' AS university_name, 'unist' AS keyword, 'unist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '육군사관학교' AS university_name, '육사' AS keyword, '육사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이대' AS keyword, '이대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '이화여자대학교' AS university_name, '이화여대' AS keyword, '이화여대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전대' AS keyword, '전대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '전남대학교' AS university_name, '전남대' AS keyword, '전남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '중앙대학교' AS university_name, '중대' AS keyword, '중대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충대' AS keyword, '충대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충남대학교' AS university_name, '충남대' AS keyword, '충남대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '충북대학교' AS university_name, '충북대' AS keyword, '충북대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포공' AS keyword, '포공' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, '포스텍' AS keyword, '포스텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '포항공과대학교' AS university_name, 'postech' AS keyword, 'postech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한공대' AS keyword, '한공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국공학대학교' AS university_name, '한국공대' AS keyword, '한국공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, '카이스트' AS keyword, '카이스트' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국과학기술원' AS university_name, 'kaist' AS keyword, 'kaist' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '교통대' AS keyword, '교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국교통대학교' AS university_name, '한국교통대' AS keyword, '한국교통대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '한기대' AS keyword, '한기대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, '코리아텍' AS keyword, '코리아텍' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국기술교육대학교' AS university_name, 'koreatech' AS keyword, 'koreatech' AS normalized_keyword, 'ENGLISH_ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '외대' AS keyword, '외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국외국어대학교' AS university_name, '한국외대' AS keyword, '한국외대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국체육대학교' AS university_name, '한체대' AS keyword, '한체대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '항공대' AS keyword, '항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국항공대학교' AS university_name, '한국항공대' AS keyword, '한국항공대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '해양대' AS keyword, '해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '한국해양대학교' AS university_name, '한국해양대' AS keyword, '한국해양대' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '해군사관학교' AS university_name, '해사' AS keyword, '해사' AS normalized_keyword, 'ALIAS' AS keyword_type + UNION ALL SELECT '홍익대학교' AS university_name, '홍대' AS keyword, '홍대' AS normalized_keyword, 'ALIAS' AS keyword_type +) expected_keyword +JOIN web_university ON web_university.korean_name = expected_keyword.university_name +ON DUPLICATE KEY UPDATE + keyword = VALUES(keyword), + normalized_keyword = VALUES(normalized_keyword), + keyword_type = VALUES(keyword_type); + +ALTER TABLE university_search_keyword + DROP FOREIGN KEY fk_university_search_keyword_university; + +DELETE FROM university_search_keyword; + +ALTER TABLE university_search_keyword + ADD CONSTRAINT fk_university_search_keyword_web_university + FOREIGN KEY (university_id) REFERENCES web_university (id); + +INSERT INTO university_search_keyword (university_id, keyword, normalized_keyword, keyword_type) +SELECT university_id, keyword, normalized_keyword, keyword_type +FROM university_search_keyword_web_university_seed; + +DROP TABLE university_search_keyword_web_university_seed; diff --git a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java index bf0018bd6..0ef0f618a 100644 --- a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java @@ -10,9 +10,11 @@ import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.UniversitySearchKeywordFixture; import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.WebUniversityFixture; class UniversityApiTest extends IntegrationTestSupport { @@ -103,8 +105,10 @@ void getUniversitiesByAliasQuery() throws Exception { University koreatech = persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); University seoulTech = persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); persist(UniversityFixture.create("서울대학교", Campus.MAIN)); - persist(UniversitySearchKeywordFixture.createAlias(koreatech, "한기대")); - persist(UniversitySearchKeywordFixture.createAlias(seoulTech, "과기대")); + WebUniversity webKoreatech = persist(WebUniversityFixture.create(koreatech.getKoreanName(), Campus.MAIN)); + WebUniversity webSeoulTech = persist(WebUniversityFixture.create(seoulTech.getKoreanName(), Campus.MAIN)); + persist(UniversitySearchKeywordFixture.createAlias(webKoreatech, "한기대")); + persist(UniversitySearchKeywordFixture.createAlias(webSeoulTech, "과기대")); clearPersistenceContext(); // when & then diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index 5069ee5a5..87f2db2be 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -18,11 +18,9 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.website.model.WebClub; import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.support.IntegrationTestSupport; -import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UniversitySearchKeywordFixture; import gg.agit.konect.support.fixture.WebClubFixture; import gg.agit.konect.support.fixture.WebUniversityFixture; @@ -81,23 +79,13 @@ void getHomeWithoutLogin() throws Exception { @DisplayName("대학교 이름 초성과 약칭으로 웹사이트 대학 목록을 검색한다") void getHomeSearchesUniversitiesByChoseongAndAlias() throws Exception { // given - University koreatech = persist(UniversityFixture.create( - "한국기술교육대학교", - Campus.MAIN, - UniversityRegion.CHUNGCHEONG - )); - University seoulTech = persist(UniversityFixture.create( - "서울과학기술대학교", - Campus.MAIN, - UniversityRegion.SEOUL - )); - persist(WebUniversityFixture.create( + WebUniversity koreatech = persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, UniversityRegion.CHUNGCHEONG, "https://example.com/koreatech-logo.png" )); - persist(WebUniversityFixture.create( + WebUniversity seoulTech = persist(WebUniversityFixture.create( "서울과학기술대학교", Campus.MAIN, UniversityRegion.SEOUL diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java index cab5b3b5c..0a755f4ab 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversitySearchKeywordFixture.java @@ -3,12 +3,12 @@ import java.util.Locale; import gg.agit.konect.domain.university.enums.UniversitySearchKeywordType; -import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.university.model.UniversitySearchKeyword; +import gg.agit.konect.domain.website.model.WebUniversity; public class UniversitySearchKeywordFixture { - public static UniversitySearchKeyword createAlias(University university, String keyword) { + public static UniversitySearchKeyword createAlias(WebUniversity university, String keyword) { return UniversitySearchKeyword.builder() .university(university) .keyword(keyword) diff --git a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java index c1b3fd8d9..4675dbafa 100644 --- a/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java @@ -120,6 +120,36 @@ void seedOnlyExistingUniversitySearchKeywords() throws Exception { assertThat(keywords).containsExactlyInAnyOrder("한기대", "코리아텍", "koreatech"); } + @Test + void moveUniversitySearchKeywordsToWebUniversity() throws Exception { + JdbcTemplate jdbcTemplate = createJdbcTemplate("moveToWebUniversity"); + createUniversityTable(jdbcTemplate); + createWebUniversityTable(jdbcTemplate); + insertUniversities(jdbcTemplate, List.of("한국기술교육대학교")); + insertWebUniversities(jdbcTemplate, List.of("한국기술교육대학교", "포항공과대학교")); + + executeMigration(jdbcTemplate, "db/migration/V86__create_university_search_keyword.sql"); + executeMigration(jdbcTemplate, "db/migration/V87__seed_university_search_keywords.sql"); + executeMigration(jdbcTemplate, "db/migration/V88__move_university_search_keywords_to_web_university.sql"); + + Integer keywordCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM university_search_keyword", + Integer.class + ); + List postechKeywords = jdbcTemplate.queryForList( + """ + SELECT keyword.keyword + FROM university_search_keyword keyword + JOIN web_university university ON university.id = keyword.university_id + WHERE university.korean_name = '포항공과대학교' + """, + String.class + ); + + assertThat(keywordCount).isEqualTo(6); + assertThat(postechKeywords).containsExactlyInAnyOrder("포공", "포스텍", "postech"); + } + private JdbcTemplate createJdbcTemplate(String databaseName) { DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName("org.h2.Driver"); @@ -139,12 +169,28 @@ korean_name VARCHAR(255) NOT NULL """); } + private void createWebUniversityTable(JdbcTemplate jdbcTemplate) { + jdbcTemplate.execute(""" + CREATE TABLE web_university + ( + id INT AUTO_INCREMENT PRIMARY KEY, + korean_name VARCHAR(255) NOT NULL + ) + """); + } + private void insertUniversities(JdbcTemplate jdbcTemplate, List universityNames) { for (String universityName : universityNames) { jdbcTemplate.update("INSERT INTO university (korean_name) VALUES (?)", universityName); } } + private void insertWebUniversities(JdbcTemplate jdbcTemplate, List universityNames) { + for (String universityName : universityNames) { + jdbcTemplate.update("INSERT INTO web_university (korean_name) VALUES (?)", universityName); + } + } + private void executeMigration(JdbcTemplate jdbcTemplate, String path) throws SQLException { try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { ScriptUtils.executeSqlScript(connection, new EncodedResource(new ClassPathResource(path), "UTF-8")); From e345c2c3ce594eea90580778dcc151e1a7c8fcef 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, 1 Jun 2026 15:25:43 +0900 Subject: [PATCH 06/10] =?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=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웹사이트 동아리 시트 가져오기 추가 * fix: 웹사이트 동아리 시트 가져오기 안정화 * test: 웹사이트 동아리 시트 커버리지 보강 --- .../AdminWebsiteClubSheetImportApi.java | 46 ++ ...AdminWebsiteClubSheetImportController.java | 43 ++ ...nWebsiteClubSheetImportConfirmRequest.java | 46 ++ ...WebsiteClubSheetImportPreviewResponse.java | 38 ++ .../AdminWebsiteClubSheetImportRequest.java | 17 + .../AdminWebsiteClubSheetImportResponse.java | 22 + .../AdminWebsiteClubSheetImportService.java | 438 ++++++++++++++++++ .../website/repository/WebClubRepository.java | 26 ++ .../repository/WebUniversityRepository.java | 19 + ...nWebsiteClubSheetImportControllerTest.java | 84 ++++ ...dminWebsiteClubSheetImportServiceTest.java | 251 ++++++++++ 11 files changed, 1030 insertions(+) create mode 100644 src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java create mode 100644 src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportController.java create mode 100644 src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java create mode 100644 src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportPreviewResponse.java create mode 100644 src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportRequest.java create mode 100644 src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportResponse.java create mode 100644 src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java create mode 100644 src/main/java/gg/agit/konect/domain/website/repository/WebClubRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/website/repository/WebUniversityRepository.java create mode 100644 src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java create mode 100644 src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java diff --git a/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java new file mode 100644 index 000000000..7b14120ff --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java @@ -0,0 +1,46 @@ +package gg.agit.konect.admin.website.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportConfirmRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportPreviewResponse; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Website Club Sheet Import", description = "konect.space 대학별 동아리 목록 시트 등록 API") +@RequestMapping("/admin/konect/universities") +public interface AdminWebsiteClubSheetImportApi { + + @Operation( + summary = "Google Sheets 동아리 등록 양식을 분석하고 미리보기 JSON을 반환한다.", + description = """ + 작성 시트의 동아리 목록을 읽고 Claude Haiku로 KONECT 웹사이트용 동아리 JSON을 생성합니다. + 이 API는 DB에 저장하지 않고, 사용자가 확인/수정할 수 있는 중간 결과만 반환합니다. + """ + ) + @PostMapping("/{universityId}/clubs/sheet/import/preview") + ResponseEntity previewClubs( + @PathVariable(name = "universityId") Integer universityId, + @Valid @RequestBody AdminWebsiteClubSheetImportRequest request + ); + + @Operation( + summary = "미리보기 JSON을 최종 동아리 목록으로 저장한다.", + description = """ + preview 응답을 그대로 보내거나 수정한 뒤 보내면 web_club에 저장합니다. + enabled=false인 항목과 이미 같은 대학에 등록된 같은 이름의 동아리는 저장하지 않습니다. + """ + ) + @PostMapping("/{universityId}/clubs/sheet/import/confirm") + ResponseEntity confirmImport( + @PathVariable(name = "universityId") Integer universityId, + @Valid @RequestBody AdminWebsiteClubSheetImportConfirmRequest request + ); +} diff --git a/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportController.java b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportController.java new file mode 100644 index 000000000..5d2bee733 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportController.java @@ -0,0 +1,43 @@ +package gg.agit.konect.admin.website.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportConfirmRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportPreviewResponse; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportResponse; +import gg.agit.konect.admin.website.service.AdminWebsiteClubSheetImportService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/konect/universities") +@Auth(roles = {UserRole.ADMIN}) +public class AdminWebsiteClubSheetImportController implements AdminWebsiteClubSheetImportApi { + + private final AdminWebsiteClubSheetImportService adminWebsiteClubSheetImportService; + + @Override + public ResponseEntity previewClubs( + Integer universityId, + AdminWebsiteClubSheetImportRequest request + ) { + AdminWebsiteClubSheetImportPreviewResponse response = + adminWebsiteClubSheetImportService.previewClubs(universityId, request.spreadsheetUrl()); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity confirmImport( + Integer universityId, + AdminWebsiteClubSheetImportConfirmRequest request + ) { + AdminWebsiteClubSheetImportResponse response = + adminWebsiteClubSheetImportService.confirmImport(universityId, request.clubs()); + return ResponseEntity.ok(response); + } +} 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 new file mode 100644 index 000000000..0dc4d9ce0 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportConfirmRequest.java @@ -0,0 +1,46 @@ +package gg.agit.konect.admin.website.dto; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminWebsiteClubSheetImportConfirmRequest( + + @NotEmpty + List<@Valid ConfirmClub> clubs +) { + + public record ConfirmClub( + int rowNumber, + + @NotBlank + @Size(max = 50) + String name, + + @NotNull + ClubCategory clubCategory, + + @NotBlank + @Size(max = 20) + String topic, + + @NotBlank + @Size(max = 30) + String description, + + @NotBlank + String introduce, + + @NotBlank + @Size(max = 255) + String categoryEmoji, + + boolean enabled + ) { + } +} diff --git a/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportPreviewResponse.java b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportPreviewResponse.java new file mode 100644 index 000000000..a816f60cd --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportPreviewResponse.java @@ -0,0 +1,38 @@ +package gg.agit.konect.admin.website.dto; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubCategory; + +public record AdminWebsiteClubSheetImportPreviewResponse( + Integer universityId, + int previewCount, + List clubs, + List warnings +) { + + public static AdminWebsiteClubSheetImportPreviewResponse of( + Integer universityId, + List clubs, + List warnings + ) { + return new AdminWebsiteClubSheetImportPreviewResponse( + universityId, + clubs.size(), + clubs, + warnings == null ? List.of() : warnings + ); + } + + public record PreviewClub( + int rowNumber, + String name, + ClubCategory clubCategory, + String topic, + String description, + String introduce, + String categoryEmoji, + boolean enabled + ) { + } +} diff --git a/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportRequest.java b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportRequest.java new file mode 100644 index 000000000..3db820ee3 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportRequest.java @@ -0,0 +1,17 @@ +package gg.agit.konect.admin.website.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record AdminWebsiteClubSheetImportRequest( + + @Schema( + description = "동아리 등록 양식 Google Sheets URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + @NotBlank + @Pattern(regexp = "^https://docs\\.google\\.com/spreadsheets/(?:u/\\d+/)?d/[A-Za-z0-9_-]+.*") + String spreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportResponse.java b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportResponse.java new file mode 100644 index 000000000..8e04ef169 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/dto/AdminWebsiteClubSheetImportResponse.java @@ -0,0 +1,22 @@ +package gg.agit.konect.admin.website.dto; + +import java.util.List; + +public record AdminWebsiteClubSheetImportResponse( + int importedCount, + int skippedCount, + List warnings +) { + + public static AdminWebsiteClubSheetImportResponse of( + int importedCount, + int skippedCount, + List warnings + ) { + return new AdminWebsiteClubSheetImportResponse( + importedCount, + skippedCount, + warnings == null ? List.of() : warnings + ); + } +} 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 new file mode 100644 index 000000000..8b60543e5 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java @@ -0,0 +1,438 @@ +package gg.agit.konect.admin.website.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportConfirmRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportPreviewResponse; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.service.GoogleSheetApiExceptionHelper; +import gg.agit.konect.domain.club.service.SpreadsheetUrlParser; +import gg.agit.konect.domain.website.model.WebClub; +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.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class AdminWebsiteClubSheetImportService { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + private static final String MODEL = "claude-haiku-4-5-20251001"; + private static final String INPUT_SHEET_RANGE = "'작성 시트'!A1:F1000"; + private static final int MAX_TOKENS = 4096; + 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 CATEGORY_EMOJI_MAX_LENGTH = 255; + private static final int NAME_COLUMN_INDEX = 0; + private static final int CATEGORY_COLUMN_INDEX = 1; + private static final int CUSTOM_CATEGORY_COLUMN_INDEX = 2; + 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 final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + private final ObjectMapper objectMapper; + private final RestClient restClient; + private final WebUniversityRepository webUniversityRepository; + private final WebClubRepository webClubRepository; + + public AdminWebsiteClubSheetImportService( + Sheets googleSheetsService, + ClaudeProperties claudeProperties, + ObjectMapper objectMapper, + RestClient.Builder restClientBuilder, + WebUniversityRepository webUniversityRepository, + WebClubRepository webClubRepository + ) { + this.googleSheetsService = googleSheetsService; + this.claudeProperties = claudeProperties; + this.objectMapper = objectMapper; + this.restClient = restClientBuilder.build(); + this.webUniversityRepository = webUniversityRepository; + this.webClubRepository = webClubRepository; + } + + public AdminWebsiteClubSheetImportPreviewResponse previewClubs( + Integer universityId, + String spreadsheetUrl + ) { + webUniversityRepository.getById(universityId); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + List rows = readClubRows(spreadsheetId); + AnalyzedClubSheet analyzedSheet = analyzeRowsWithClaude(rows); + return AdminWebsiteClubSheetImportPreviewResponse.of( + universityId, + analyzedSheet.clubs(), + analyzedSheet.warnings() + ); + } + + @Transactional + public AdminWebsiteClubSheetImportResponse confirmImport( + Integer universityId, + List clubs + ) { + WebUniversity university = webUniversityRepository.getById(universityId); + List warnings = new ArrayList<>(); + List enabledClubs = clubs == null + ? List.of() + : clubs.stream() + .filter(AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub::enabled) + .toList(); + + Set requestNames = new LinkedHashSet<>(); + enabledClubs.stream() + .map(AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub::name) + .map(String::trim) + .filter(name -> !name.isBlank()) + .forEach(requestNames::add); + + Set existingNames = requestNames.isEmpty() + ? Set.of() + : webClubRepository.findExistingNamesByUniversityId(universityId, requestNames); + Set normalizedExistingNames = existingNames.stream() + .map(name -> name.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + Set seenNames = new LinkedHashSet<>(); + List clubsToSave = new ArrayList<>(); + + for (AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub club : enabledClubs) { + String name = club.name().trim(); + String normalizedName = name.toLowerCase(Locale.ROOT); + if (name.isBlank()) { + warnings.add(String.format("%d행: 동아리명이 비어 있어 제외했습니다.", club.rowNumber())); + continue; + } + if (!seenNames.add(normalizedName)) { + warnings.add(String.format("%d행: 요청 안에서 중복된 동아리명 '%s'을 제외했습니다.", club.rowNumber(), name)); + continue; + } + if (normalizedExistingNames.contains(normalizedName)) { + warnings.add(String.format("%d행: 이미 등록된 동아리명 '%s'을 제외했습니다.", club.rowNumber(), name)); + continue; + } + + clubsToSave.add(WebClub.builder() + .university(university) + .clubCategory(club.clubCategory()) + .name(name) + .topic(limit(requiredText(club.topic(), "기타"), TOPIC_MAX_LENGTH)) + .description(limit(requiredText(club.description(), name), DESCRIPTION_MAX_LENGTH)) + .introduce(requiredText(club.introduce(), club.description())) + .categoryEmoji(limit( + requiredText(club.categoryEmoji(), emojiOf(club.clubCategory())), + CATEGORY_EMOJI_MAX_LENGTH + )) + .build()); + } + + List savedClubs = clubsToSave.isEmpty() + ? List.of() + : webClubRepository.saveAll(clubsToSave); + + return AdminWebsiteClubSheetImportResponse.of( + savedClubs.size(), + enabledClubs.size() - savedClubs.size(), + warnings + ); + } + + private List readClubRows(String spreadsheetId) { + try { + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, INPUT_SHEET_RANGE) + .setValueRenderOption("FORMATTED_VALUE") + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + int headerIndex = findHeaderIndex(values); + List rows = new ArrayList<>(); + for (int rowIndex = headerIndex + 1; rowIndex < values.size(); rowIndex++) { + List row = values.get(rowIndex); + RawClubRow rawClubRow = new RawClubRow( + rowIndex + 1, + cell(row, NAME_COLUMN_INDEX), + cell(row, CATEGORY_COLUMN_INDEX), + cell(row, CUSTOM_CATEGORY_COLUMN_INDEX), + cell(row, TOPIC_COLUMN_INDEX), + cell(row, CATEGORY_EMOJI_COLUMN_INDEX), + cell(row, DESCRIPTION_COLUMN_INDEX) + ); + if (!rawClubRow.isEmpty()) { + rows.add(rawClubRow); + } + } + return rows; + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to read website club import sheet. spreadsheetId={}", spreadsheetId, e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private int findHeaderIndex(List> values) { + for (int i = 0; i < values.size(); i++) { + if ("동아리명".equals(cell(values.get(i), 0))) { + return i; + } + } + return DEFAULT_HEADER_INDEX; + } + + private AnalyzedClubSheet analyzeRowsWithClaude(List rows) { + if (rows.isEmpty()) { + return new AnalyzedClubSheet(List.of(), List.of("분석할 동아리 행이 없습니다.")); + } + + String prompt = buildPrompt(rows); + Map request = Map.of( + "model", MODEL, + "max_tokens", MAX_TOKENS, + "messages", List.of(Map.of("role", "user", "content", prompt)) + ); + + try { + String response = restClient.post() + .uri(API_URL) + .header("x-api-key", claudeProperties.apiKey()) + .header("anthropic-version", ANTHROPIC_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + JsonNode root = objectMapper.readTree(response); + String rawJson = extractClaudeText(root, response); + return parseClaudeResult(rawJson); + } catch (Exception e) { + log.error("Failed to analyze website club import sheet with Claude.", e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private String buildPrompt(List rows) { + try { + String rowsJson = objectMapper.writeValueAsString(rows); + return """ + You normalize Korean university club registration sheet rows for KONECT. + + Respond ONLY with JSON: + { + "clubs": [ + { + "rowNumber": 5, + "name": "BCSD", + "clubCategory": "ACADEMIC", + "topic": "개발", + "description": "30자 이내 한 줄 소개", + "introduce": "서비스에 표시할 상세 소개", + "categoryEmoji": "💻" + } + ], + "warnings": ["row-specific warning in Korean"] + } + + Club category enum mapping: + - 공연분과 -> PERFORMANCE + - 사회/봉사분과 -> SOCIAL_SERVICE + - 전시/창작분과 -> EXHIBITION_CREATION + - 종교분과 -> RELIGION + - 체육(운동)분과 -> SPORTS + - 취미분과 -> HOBBY + - 학술분과 -> ACADEMIC + - 기타분과, 기타, unknown -> ETC + + Rules: + - Skip example rows and blank rows. + - Keep name within 50 characters. + - Keep topic within 20 characters. + - Keep description within 30 Korean characters. If blank, create one from name/topic/category. + - introduce is required. If no detailed content is available, reuse description. + - categoryEmoji is required. If blank or unsuitable, choose one relevant emoji. + - Add warnings for missing or corrected important fields. + + Rows: + %s + """.formatted(rowsJson); + } catch (IOException e) { + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private String extractClaudeText(JsonNode root, String response) { + JsonNode content = root.path("content"); + if (!content.isArray() || content.isEmpty()) { + throw new IllegalArgumentException("Claude API returned empty content. response=" + response); + } + + String text = content.get(0).path("text").asText(); + if (text.isBlank()) { + throw new IllegalArgumentException("Claude API returned blank text. response=" + response); + } + return text; + } + + private AnalyzedClubSheet parseClaudeResult(String rawJson) throws IOException { + String cleaned = extractJsonObject(rawJson); + JsonNode root = objectMapper.readTree(cleaned); + List clubs = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (JsonNode warning : root.path("warnings")) { + warnings.add(warning.asText()); + } + + for (JsonNode clubNode : root.path("clubs")) { + String name = limit(requiredText(clubNode.path("name").asText(), ""), NAME_MAX_LENGTH); + if (name.isBlank() || name.startsWith("예시)")) { + continue; + } + + ClubCategory category = resolveCategory(clubNode.path("clubCategory").asText()); + String topic = limit(requiredText(clubNode.path("topic").asText(), "기타"), TOPIC_MAX_LENGTH); + String description = limit( + requiredText(clubNode.path("description").asText(), name + " 동아리입니다."), + DESCRIPTION_MAX_LENGTH + ); + String introduce = requiredText(clubNode.path("introduce").asText(), description); + String categoryEmoji = limit( + requiredText(clubNode.path("categoryEmoji").asText(), emojiOf(category)), + CATEGORY_EMOJI_MAX_LENGTH + ); + + clubs.add(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub( + clubNode.path("rowNumber").asInt(0), + name, + category, + topic, + description, + introduce, + categoryEmoji, + true + )); + } + + return new AnalyzedClubSheet(clubs, warnings); + } + + private ClubCategory resolveCategory(String value) { + if (value == null || value.isBlank()) { + return ClubCategory.ETC; + } + String normalized = value.trim(); + for (ClubCategory category : ClubCategory.values()) { + if (category.name().equalsIgnoreCase(normalized)) { + return category; + } + } + return switch (normalized) { + case "공연분과" -> ClubCategory.PERFORMANCE; + case "사회/봉사분과" -> ClubCategory.SOCIAL_SERVICE; + case "전시/창작분과" -> ClubCategory.EXHIBITION_CREATION; + case "종교분과" -> ClubCategory.RELIGION; + case "체육(운동)분과" -> ClubCategory.SPORTS; + case "취미분과" -> ClubCategory.HOBBY; + case "학술분과" -> ClubCategory.ACADEMIC; + default -> ClubCategory.ETC; + }; + } + + private static String cell(List row, int index) { + if (row == null || index >= row.size() || row.get(index) == null) { + return ""; + } + return row.get(index).toString().trim(); + } + + private static String requiredText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static String limit(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + + private static String extractJsonObject(String rawJson) { + String cleaned = rawJson == null ? "" : rawJson.trim(); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end < start) { + throw new IllegalArgumentException("No JSON object found"); + } + return cleaned.substring(start, end + 1); + } + + private static String emojiOf(ClubCategory category) { + return switch (category) { + case PERFORMANCE -> "🎭"; + case SOCIAL_SERVICE -> "🤝"; + case EXHIBITION_CREATION -> "🎨"; + case RELIGION -> "🙏"; + case SPORTS -> "⚽"; + case HOBBY -> "📷"; + case ACADEMIC -> "📚"; + case ETC -> "✨"; + }; + } + + private record RawClubRow( + int rowNumber, + String name, + String category, + String customCategory, + String topic, + String categoryEmoji, + String description + ) { + private boolean isEmpty() { + return name.isBlank() + && category.isBlank() + && customCategory.isBlank() + && topic.isBlank() + && categoryEmoji.isBlank() + && description.isBlank(); + } + } + + private record AnalyzedClubSheet( + List clubs, + List warnings + ) { + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebClubRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebClubRepository.java new file mode 100644 index 000000000..89718c381 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebClubRepository.java @@ -0,0 +1,26 @@ +package gg.agit.konect.domain.website.repository; + +import java.util.List; +import java.util.Set; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.website.model.WebClub; + +public interface WebClubRepository extends Repository { + + @Query(""" + SELECT c.name + FROM WebClub c + WHERE c.university.id = :universityId + AND c.name IN :names + """) + Set findExistingNamesByUniversityId( + @Param("universityId") Integer universityId, + @Param("names") Set names + ); + + List saveAll(Iterable clubs); +} diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebUniversityRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebUniversityRepository.java new file mode 100644 index 000000000..e128cf6a9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebUniversityRepository.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.website.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.website.model.WebUniversity; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public interface WebUniversityRepository extends Repository { + + Optional findById(Integer id); + + default WebUniversity getById(Integer id) { + return findById(id).orElseThrow(() -> + CustomException.of(ApiResponseCode.UNIVERSITY_NOT_FOUND)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java b/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java new file mode 100644 index 000000000..d197ab7ce --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java @@ -0,0 +1,84 @@ +package gg.agit.konect.unit.admin.website.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.ResponseEntity; + +import gg.agit.konect.admin.website.controller.AdminWebsiteClubSheetImportController; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportConfirmRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportPreviewResponse; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportResponse; +import gg.agit.konect.admin.website.service.AdminWebsiteClubSheetImportService; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.support.ServiceTestSupport; + +class AdminWebsiteClubSheetImportControllerTest extends ServiceTestSupport { + + private static final Integer UNIVERSITY_ID = 1; + private static final String SPREADSHEET_URL = "https://docs.google.com/spreadsheets/d/sheet-id/edit"; + + @Mock + private AdminWebsiteClubSheetImportService service; + + @InjectMocks + private AdminWebsiteClubSheetImportController controller; + + @Test + void previewClubsReturnsServiceResponse() { + AdminWebsiteClubSheetImportPreviewResponse serviceResponse = + AdminWebsiteClubSheetImportPreviewResponse.of( + UNIVERSITY_ID, + List.of(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub( + 5, + "BCSD", + ClubCategory.ACADEMIC, + "dev", + "dev club", + "dev club", + "IT", + true + )), + List.of() + ); + given(service.previewClubs(UNIVERSITY_ID, SPREADSHEET_URL)).willReturn(serviceResponse); + + ResponseEntity response = controller.previewClubs( + UNIVERSITY_ID, + new AdminWebsiteClubSheetImportRequest(SPREADSHEET_URL) + ); + + assertThat(response.getBody()).isSameAs(serviceResponse); + } + + @Test + void confirmImportReturnsServiceResponse() { + AdminWebsiteClubSheetImportResponse serviceResponse = + AdminWebsiteClubSheetImportResponse.of(1, 0, List.of()); + AdminWebsiteClubSheetImportConfirmRequest request = + new AdminWebsiteClubSheetImportConfirmRequest(List.of( + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 5, + "BCSD", + ClubCategory.ACADEMIC, + "dev", + "dev club", + "dev club", + "IT", + true + ) + )); + given(service.confirmImport(UNIVERSITY_ID, request.clubs())).willReturn(serviceResponse); + + ResponseEntity response = + controller.confirmImport(UNIVERSITY_ID, request); + + assertThat(response.getBody()).isSameAs(serviceResponse); + } +} 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 new file mode 100644 index 000000000..3e156ab31 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java @@ -0,0 +1,251 @@ +package gg.agit.konect.unit.admin.website.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportConfirmRequest; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportPreviewResponse; +import gg.agit.konect.admin.website.dto.AdminWebsiteClubSheetImportResponse; +import gg.agit.konect.admin.website.service.AdminWebsiteClubSheetImportService; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.model.WebClub; +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.infrastructure.claude.config.ClaudeProperties; +import gg.agit.konect.support.ServiceTestSupport; + +class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { + + private static final Integer UNIVERSITY_ID = 1; + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Get getRequest; + + @Mock + private WebUniversityRepository webUniversityRepository; + + @Mock + private WebClubRepository webClubRepository; + + private AdminWebsiteClubSheetImportService service; + private MockRestServiceServer mockServer; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + RestClient.Builder restClientBuilder = RestClient.builder(); + mockServer = MockRestServiceServer.bindTo(restClientBuilder).build(); + service = new AdminWebsiteClubSheetImportService( + googleSheetsService, + new ClaudeProperties("test-api-key", "test-model"), + objectMapper, + restClientBuilder, + webUniversityRepository, + webClubRepository + ); + } + + @Test + void previewClubsReadsSheetAndReturnsClaudeAnalysis() throws Exception { + WebUniversity university = WebUniversity.builder() + .id(UNIVERSITY_ID) + .koreanName("Koreatech") + .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) + .imageUrl("https://example.com/logo.png") + .build(); + + 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("BCSD", "학술분과", "", "dev", "IT", "dev club") + ))); + + String claudeText = """ + { + "clubs": [ + { + "rowNumber": 5, + "name": "BCSD", + "clubCategory": "ACADEMIC", + "topic": "dev", + "description": "dev club", + "introduce": "dev club", + "categoryEmoji": "IT" + } + ], + "warnings": ["checked"] + } + """; + String response = """ + {"content":[{"type":"text","text":%s}]} + """.formatted(objectMapper.writeValueAsString(claudeText)); + + mockServer.expect(requestTo("https://api.anthropic.com/v1/messages")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + + AdminWebsiteClubSheetImportPreviewResponse preview = service.previewClubs( + UNIVERSITY_ID, + "https://docs.google.com/spreadsheets/d/sheet-id/edit" + ); + + assertThat(preview.previewCount()).isEqualTo(1); + assertThat(preview.clubs()).singleElement() + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::name) + .isEqualTo("BCSD"); + assertThat(preview.warnings()).containsExactly("checked"); + mockServer.verify(); + } + + @Test + void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { + WebUniversity university = WebUniversity.builder() + .id(UNIVERSITY_ID) + .koreanName("한국기술교육대학교") + .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) + .imageUrl("https://example.com/logo.png") + .build(); + + List clubs = List.of( + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 5, + "BCSD", + ClubCategory.ACADEMIC, + "개발", + "IT 동아리입니다.", + "IT 동아리입니다.", + "💻", + true + ), + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 6, + "농구동아리", + ClubCategory.SPORTS, + "농구", + "농구 동아리입니다.", + "농구 동아리입니다.", + "🏀", + true + ), + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 7, + "댄스동아리", + ClubCategory.PERFORMANCE, + "댄스", + "댄스 동아리입니다.", + "댄스 동아리입니다.", + "💃", + false + ), + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 8, + "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)); + + AdminWebsiteClubSheetImportResponse response = service.confirmImport(UNIVERSITY_ID, clubs); + + assertThat(response.importedCount()).isEqualTo(1); + assertThat(response.skippedCount()).isEqualTo(2); + assertThat(response.warnings()).hasSize(2); + assertThat(response.warnings()).anyMatch(warning -> warning.contains("농구동아리")); + assertThat(response.warnings()).anyMatch(warning -> warning.contains("BCSD")); + verify(webClubRepository).saveAll(org.mockito.ArgumentMatchers.>argThat(savedClubs -> + savedClubs.size() == 1 && savedClubs.getFirst().getName().equals("BCSD") + )); + verifyNoInteractions(googleSheetsService); + } + + @Test + void confirmImportSkipsExistingClubNameCaseInsensitively() { + WebUniversity university = WebUniversity.builder() + .id(UNIVERSITY_ID) + .koreanName("한국기술교육대학교") + .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) + .imageUrl("https://example.com/logo.png") + .build(); + + List clubs = List.of( + new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + 5, + "BCSD", + ClubCategory.ACADEMIC, + "개발", + "IT 동아리입니다.", + "IT 동아리입니다.", + "💻", + true + ) + ); + + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university); + given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())) + .willReturn(Set.of("bcsd")); + + AdminWebsiteClubSheetImportResponse response = service.confirmImport(UNIVERSITY_ID, clubs); + + assertThat(response.importedCount()).isZero(); + assertThat(response.skippedCount()).isEqualTo(1); + assertThat(response.warnings()).singleElement() + .asString() + .contains("BCSD"); + verify(webClubRepository, org.mockito.Mockito.never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + verifyNoInteractions(googleSheetsService); + } +} From b9d8bdc84a33b83d21a8e31640d75192bb79c525 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, 1 Jun 2026 16:18:48 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=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=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EC=96=91=EC=8B=9D=20=ED=8C=8C=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminWebsiteClubSheetImportApi.java | 4 +- .../AdminWebsiteClubSheetImportService.java | 263 ++++++------------ ...dminWebsiteClubSheetImportServiceTest.java | 181 ++++-------- 3 files changed, 133 insertions(+), 315 deletions(-) diff --git a/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java index 7b14120ff..3d53a574c 100644 --- a/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java +++ b/src/main/java/gg/agit/konect/admin/website/controller/AdminWebsiteClubSheetImportApi.java @@ -19,9 +19,9 @@ public interface AdminWebsiteClubSheetImportApi { @Operation( - summary = "Google Sheets 동아리 등록 양식을 분석하고 미리보기 JSON을 반환한다.", + summary = "Google Sheets 동아리 등록 양식을 읽고 미리보기 JSON을 반환한다.", description = """ - 작성 시트의 동아리 목록을 읽고 Claude Haiku로 KONECT 웹사이트용 동아리 JSON을 생성합니다. + 고정된 작성 시트 양식의 A~F 컬럼을 읽어 KONECT 웹사이트용 동아리 JSON을 생성합니다. 이 API는 DB에 저장하지 않고, 사용자가 확인/수정할 수 있는 중간 결과만 반환합니다. """ ) 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 8b60543e5..323f15c03 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 @@ -5,17 +5,12 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestClient; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -31,18 +26,17 @@ import gg.agit.konect.domain.website.repository.WebUniversityRepository; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; -import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Service +@RequiredArgsConstructor public class AdminWebsiteClubSheetImportService { - private static final String API_URL = "https://api.anthropic.com/v1/messages"; - private static final String ANTHROPIC_VERSION = "2023-06-01"; - private static final String MODEL = "claude-haiku-4-5-20251001"; private static final String INPUT_SHEET_RANGE = "'작성 시트'!A1:F1000"; - private static final int MAX_TOKENS = 4096; + private static final String HEADER_NAME = "동아리명"; + private static final String EXAMPLE_PREFIX = "예시)"; private static final int DEFAULT_HEADER_INDEX = 3; private static final int NAME_MAX_LENGTH = 50; private static final int TOPIC_MAX_LENGTH = 20; @@ -56,40 +50,20 @@ public class AdminWebsiteClubSheetImportService { private static final int DESCRIPTION_COLUMN_INDEX = 5; private final Sheets googleSheetsService; - private final ClaudeProperties claudeProperties; - private final ObjectMapper objectMapper; - private final RestClient restClient; private final WebUniversityRepository webUniversityRepository; private final WebClubRepository webClubRepository; - public AdminWebsiteClubSheetImportService( - Sheets googleSheetsService, - ClaudeProperties claudeProperties, - ObjectMapper objectMapper, - RestClient.Builder restClientBuilder, - WebUniversityRepository webUniversityRepository, - WebClubRepository webClubRepository - ) { - this.googleSheetsService = googleSheetsService; - this.claudeProperties = claudeProperties; - this.objectMapper = objectMapper; - this.restClient = restClientBuilder.build(); - this.webUniversityRepository = webUniversityRepository; - this.webClubRepository = webClubRepository; - } - public AdminWebsiteClubSheetImportPreviewResponse previewClubs( Integer universityId, String spreadsheetUrl ) { webUniversityRepository.getById(universityId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - List rows = readClubRows(spreadsheetId); - AnalyzedClubSheet analyzedSheet = analyzeRowsWithClaude(rows); + SheetClubImportPlan importPlan = buildImportPlan(readClubRows(spreadsheetId)); return AdminWebsiteClubSheetImportPreviewResponse.of( universityId, - analyzedSheet.clubs(), - analyzedSheet.warnings() + importPlan.clubs(), + importPlan.warnings() ); } @@ -164,6 +138,71 @@ public AdminWebsiteClubSheetImportResponse confirmImport( ); } + private SheetClubImportPlan buildImportPlan(List rows) { + List clubs = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (RawClubRow row : rows) { + String name = limit(row.name(), NAME_MAX_LENGTH); + if (name.isBlank() || name.startsWith(EXAMPLE_PREFIX)) { + continue; + } + + ClubCategory category = resolveCategory(row.category(), row.customCategory()); + String topic = limit(requiredText(row.topic(), "기타"), TOPIC_MAX_LENGTH); + String categoryEmoji = limit( + requiredText(row.categoryEmoji(), emojiOf(category)), + CATEGORY_EMOJI_MAX_LENGTH + ); + String description = limit( + requiredText(row.description(), name + " 동아리입니다."), + DESCRIPTION_MAX_LENGTH + ); + + addWarnings(row, category, topic, categoryEmoji, description, warnings); + clubs.add(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub( + row.rowNumber(), + name, + category, + topic, + description, + description, + categoryEmoji, + true + )); + } + + if (clubs.isEmpty()) { + warnings.add("가져올 동아리 행이 없습니다."); + } + return new SheetClubImportPlan(clubs, warnings); + } + + private void addWarnings( + RawClubRow row, + ClubCategory category, + String topic, + String categoryEmoji, + String description, + List warnings + ) { + if (row.category().isBlank()) { + warnings.add(String.format("%d행: 동아리 분과가 비어 있어 기타로 처리했습니다.", row.rowNumber())); + } + if (category == ClubCategory.ETC && !row.category().isBlank() && row.customCategory().isBlank()) { + warnings.add(String.format("%d행: 기타 분과 상세 내용이 비어 있습니다.", row.rowNumber())); + } + if (row.topic().isBlank()) { + warnings.add(String.format("%d행: 동아리 주제가 비어 있어 '%s'(으)로 처리했습니다.", row.rowNumber(), topic)); + } + if (row.categoryEmoji().isBlank()) { + warnings.add(String.format("%d행: 대표 이모지가 비어 있어 '%s'(으)로 처리했습니다.", row.rowNumber(), categoryEmoji)); + } + if (row.description().isBlank()) { + warnings.add(String.format("%d행: 한 줄 소개가 비어 있어 '%s'(으)로 처리했습니다.", row.rowNumber(), description)); + } + } + private List readClubRows(String spreadsheetId) { try { ValueRange response = googleSheetsService.spreadsheets().values() @@ -205,160 +244,24 @@ private List readClubRows(String spreadsheetId) { private int findHeaderIndex(List> values) { for (int i = 0; i < values.size(); i++) { - if ("동아리명".equals(cell(values.get(i), 0))) { + if (HEADER_NAME.equals(cell(values.get(i), NAME_COLUMN_INDEX))) { return i; } } return DEFAULT_HEADER_INDEX; } - private AnalyzedClubSheet analyzeRowsWithClaude(List rows) { - if (rows.isEmpty()) { - return new AnalyzedClubSheet(List.of(), List.of("분석할 동아리 행이 없습니다.")); - } - - String prompt = buildPrompt(rows); - Map request = Map.of( - "model", MODEL, - "max_tokens", MAX_TOKENS, - "messages", List.of(Map.of("role", "user", "content", prompt)) - ); - - try { - String response = restClient.post() - .uri(API_URL) - .header("x-api-key", claudeProperties.apiKey()) - .header("anthropic-version", ANTHROPIC_VERSION) - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .retrieve() - .body(String.class); - - JsonNode root = objectMapper.readTree(response); - String rawJson = extractClaudeText(root, response); - return parseClaudeResult(rawJson); - } catch (Exception e) { - log.error("Failed to analyze website club import sheet with Claude.", e); - throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); - } - } - - private String buildPrompt(List rows) { - try { - String rowsJson = objectMapper.writeValueAsString(rows); - return """ - You normalize Korean university club registration sheet rows for KONECT. - - Respond ONLY with JSON: - { - "clubs": [ - { - "rowNumber": 5, - "name": "BCSD", - "clubCategory": "ACADEMIC", - "topic": "개발", - "description": "30자 이내 한 줄 소개", - "introduce": "서비스에 표시할 상세 소개", - "categoryEmoji": "💻" - } - ], - "warnings": ["row-specific warning in Korean"] - } - - Club category enum mapping: - - 공연분과 -> PERFORMANCE - - 사회/봉사분과 -> SOCIAL_SERVICE - - 전시/창작분과 -> EXHIBITION_CREATION - - 종교분과 -> RELIGION - - 체육(운동)분과 -> SPORTS - - 취미분과 -> HOBBY - - 학술분과 -> ACADEMIC - - 기타분과, 기타, unknown -> ETC - - Rules: - - Skip example rows and blank rows. - - Keep name within 50 characters. - - Keep topic within 20 characters. - - Keep description within 30 Korean characters. If blank, create one from name/topic/category. - - introduce is required. If no detailed content is available, reuse description. - - categoryEmoji is required. If blank or unsuitable, choose one relevant emoji. - - Add warnings for missing or corrected important fields. - - Rows: - %s - """.formatted(rowsJson); - } catch (IOException e) { - throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); - } - } - - private String extractClaudeText(JsonNode root, String response) { - JsonNode content = root.path("content"); - if (!content.isArray() || content.isEmpty()) { - throw new IllegalArgumentException("Claude API returned empty content. response=" + response); - } - - String text = content.get(0).path("text").asText(); - if (text.isBlank()) { - throw new IllegalArgumentException("Claude API returned blank text. response=" + response); - } - return text; - } - - private AnalyzedClubSheet parseClaudeResult(String rawJson) throws IOException { - String cleaned = extractJsonObject(rawJson); - JsonNode root = objectMapper.readTree(cleaned); - List clubs = new ArrayList<>(); - List warnings = new ArrayList<>(); - - for (JsonNode warning : root.path("warnings")) { - warnings.add(warning.asText()); - } - - for (JsonNode clubNode : root.path("clubs")) { - String name = limit(requiredText(clubNode.path("name").asText(), ""), NAME_MAX_LENGTH); - if (name.isBlank() || name.startsWith("예시)")) { - continue; - } - - ClubCategory category = resolveCategory(clubNode.path("clubCategory").asText()); - String topic = limit(requiredText(clubNode.path("topic").asText(), "기타"), TOPIC_MAX_LENGTH); - String description = limit( - requiredText(clubNode.path("description").asText(), name + " 동아리입니다."), - DESCRIPTION_MAX_LENGTH - ); - String introduce = requiredText(clubNode.path("introduce").asText(), description); - String categoryEmoji = limit( - requiredText(clubNode.path("categoryEmoji").asText(), emojiOf(category)), - CATEGORY_EMOJI_MAX_LENGTH - ); - - clubs.add(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub( - clubNode.path("rowNumber").asInt(0), - name, - category, - topic, - description, - introduce, - categoryEmoji, - true - )); - } - - return new AnalyzedClubSheet(clubs, warnings); - } - - private ClubCategory resolveCategory(String value) { - if (value == null || value.isBlank()) { + private ClubCategory resolveCategory(String categoryText, String customCategoryText) { + String normalized = requiredText(categoryText, customCategoryText); + if (normalized.isBlank()) { return ClubCategory.ETC; } - String normalized = value.trim(); for (ClubCategory category : ClubCategory.values()) { if (category.name().equalsIgnoreCase(normalized)) { return category; } } - return switch (normalized) { + return switch (normalized.trim()) { case "공연분과" -> ClubCategory.PERFORMANCE; case "사회/봉사분과" -> ClubCategory.SOCIAL_SERVICE; case "전시/창작분과" -> ClubCategory.EXHIBITION_CREATION; @@ -388,16 +291,6 @@ private static String limit(String value, int maxLength) { return value.substring(0, maxLength); } - private static String extractJsonObject(String rawJson) { - String cleaned = rawJson == null ? "" : rawJson.trim(); - int start = cleaned.indexOf('{'); - int end = cleaned.lastIndexOf('}'); - if (start < 0 || end < start) { - throw new IllegalArgumentException("No JSON object found"); - } - return cleaned.substring(start, end + 1); - } - private static String emojiOf(ClubCategory category) { return switch (category) { case PERFORMANCE -> "🎭"; @@ -430,7 +323,7 @@ private boolean isEmpty() { } } - private record AnalyzedClubSheet( + private record SheetClubImportPlan( List clubs, List warnings ) { 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 3e156ab31..803ea24b8 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 @@ -6,9 +6,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; import java.util.List; import java.util.Set; @@ -16,12 +13,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.test.web.client.MockRestServiceServer; -import org.springframework.web.client.RestClient; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -36,7 +28,6 @@ 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.infrastructure.claude.config.ClaudeProperties; import gg.agit.konect.support.ServiceTestSupport; class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { @@ -62,34 +53,19 @@ class AdminWebsiteClubSheetImportServiceTest extends ServiceTestSupport { private WebClubRepository webClubRepository; private AdminWebsiteClubSheetImportService service; - private MockRestServiceServer mockServer; - private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { - RestClient.Builder restClientBuilder = RestClient.builder(); - mockServer = MockRestServiceServer.bindTo(restClientBuilder).build(); service = new AdminWebsiteClubSheetImportService( googleSheetsService, - new ClaudeProperties("test-api-key", "test-model"), - objectMapper, - restClientBuilder, webUniversityRepository, webClubRepository ); } @Test - void previewClubsReadsSheetAndReturnsClaudeAnalysis() throws Exception { - WebUniversity university = WebUniversity.builder() - .id(UNIVERSITY_ID) - .koreanName("Koreatech") - .campus(Campus.MAIN) - .region(UniversityRegion.CHUNGCHEONG) - .imageUrl("https://example.com/logo.png") - .build(); - - given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university); + void previewClubsReadsFixedSheetColumns() 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); @@ -99,100 +75,39 @@ void previewClubsReadsSheetAndReturnsClaudeAnalysis() throws Exception { List.of("description"), List.of(), List.of("동아리명", "동아리 분과", "기타 분과", "동아리 주제", "대표 이모지", "한 줄 소개"), - List.of("BCSD", "학술분과", "", "dev", "IT", "dev club") + List.of("예시) BCSD", "학술분과", "", "dev", "IT", "example"), + List.of("BCSD", "학술분과", "", "dev", "IT", "dev club"), + List.of("농구동아리", "체육(운동)분과", "", "", "", "") ))); - String claudeText = """ - { - "clubs": [ - { - "rowNumber": 5, - "name": "BCSD", - "clubCategory": "ACADEMIC", - "topic": "dev", - "description": "dev club", - "introduce": "dev club", - "categoryEmoji": "IT" - } - ], - "warnings": ["checked"] - } - """; - String response = """ - {"content":[{"type":"text","text":%s}]} - """.formatted(objectMapper.writeValueAsString(claudeText)); - - mockServer.expect(requestTo("https://api.anthropic.com/v1/messages")) - .andExpect(method(HttpMethod.POST)) - .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); - AdminWebsiteClubSheetImportPreviewResponse preview = service.previewClubs( UNIVERSITY_ID, "https://docs.google.com/spreadsheets/d/sheet-id/edit" ); - assertThat(preview.previewCount()).isEqualTo(1); - assertThat(preview.clubs()).singleElement() + assertThat(preview.previewCount()).isEqualTo(2); + assertThat(preview.clubs()) .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::name) - .isEqualTo("BCSD"); - assertThat(preview.warnings()).containsExactly("checked"); - mockServer.verify(); + .containsExactly("BCSD", "농구동아리"); + assertThat(preview.clubs()) + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::clubCategory) + .containsExactly(ClubCategory.ACADEMIC, ClubCategory.SPORTS); + assertThat(preview.clubs().get(1).topic()).isEqualTo("기타"); + assertThat(preview.clubs().get(1).categoryEmoji()).isEqualTo("⚽"); + assertThat(preview.clubs().get(1).description()).isEqualTo("농구동아리 동아리입니다."); + assertThat(preview.warnings()).hasSize(3); } @Test void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { - WebUniversity university = WebUniversity.builder() - .id(UNIVERSITY_ID) - .koreanName("한국기술교육대학교") - .campus(Campus.MAIN) - .region(UniversityRegion.CHUNGCHEONG) - .imageUrl("https://example.com/logo.png") - .build(); - List clubs = List.of( - new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( - 5, - "BCSD", - ClubCategory.ACADEMIC, - "개발", - "IT 동아리입니다.", - "IT 동아리입니다.", - "💻", - true - ), - new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( - 6, - "농구동아리", - ClubCategory.SPORTS, - "농구", - "농구 동아리입니다.", - "농구 동아리입니다.", - "🏀", - true - ), - new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( - 7, - "댄스동아리", - ClubCategory.PERFORMANCE, - "댄스", - "댄스 동아리입니다.", - "댄스 동아리입니다.", - "💃", - false - ), - new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( - 8, - "BCSD", - ClubCategory.ACADEMIC, - "개발", - "중복 동아리입니다.", - "중복 동아리입니다.", - "💻", - true - ) + confirmClub(5, "BCSD", ClubCategory.ACADEMIC, true), + confirmClub(6, "농구동아리", ClubCategory.SPORTS, true), + confirmClub(7, "댄스동아리", ClubCategory.PERFORMANCE, false), + confirmClub(8, "BCSD", ClubCategory.ACADEMIC, true) ); - given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university); + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())) .willReturn(Set.of("농구동아리")); given(webClubRepository.saveAll(org.mockito.ArgumentMatchers.>any())) @@ -213,32 +128,14 @@ void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { @Test void confirmImportSkipsExistingClubNameCaseInsensitively() { - WebUniversity university = WebUniversity.builder() - .id(UNIVERSITY_ID) - .koreanName("한국기술교육대학교") - .campus(Campus.MAIN) - .region(UniversityRegion.CHUNGCHEONG) - .imageUrl("https://example.com/logo.png") - .build(); - - List clubs = List.of( - new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( - 5, - "BCSD", - ClubCategory.ACADEMIC, - "개발", - "IT 동아리입니다.", - "IT 동아리입니다.", - "💻", - true - ) - ); - - given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university); + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())) .willReturn(Set.of("bcsd")); - AdminWebsiteClubSheetImportResponse response = service.confirmImport(UNIVERSITY_ID, clubs); + AdminWebsiteClubSheetImportResponse response = service.confirmImport( + UNIVERSITY_ID, + List.of(confirmClub(5, "BCSD", ClubCategory.ACADEMIC, true)) + ); assertThat(response.importedCount()).isZero(); assertThat(response.skippedCount()).isEqualTo(1); @@ -248,4 +145,32 @@ void confirmImportSkipsExistingClubNameCaseInsensitively() { verify(webClubRepository, org.mockito.Mockito.never()).saveAll(org.mockito.ArgumentMatchers.anyList()); verifyNoInteractions(googleSheetsService); } + + private WebUniversity university() { + return WebUniversity.builder() + .id(UNIVERSITY_ID) + .koreanName("한국기술교육대학교") + .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) + .imageUrl("https://example.com/logo.png") + .build(); + } + + private AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub confirmClub( + int rowNumber, + String name, + ClubCategory category, + boolean enabled + ) { + return new AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub( + rowNumber, + name, + category, + "dev", + "dev club", + "dev club", + "IT", + enabled + ); + } } From 49cbf7f7c4c9b039e92d1a84df6fc888e56b4f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:43:57 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20Redis=20TTL=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20rate=20limit=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=95=88=EC=A0=95=ED=99=94=20(#663)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Redis TTL 조회 실패 시 rate limit 응답 안정화 - 제한 초과 판단 이후 남은 시간 조회가 실패해도 servlet 예외로 전파되지 않도록 기본 window 값으로 대체한다 - 카운터 증가가 성공한 경우에는 제한 초과 상태를 유지해 Redis TTL 조회 장애가 요청 허용으로 바뀌지 않게 한다 - Redis timeout 재현 테스트를 추가해 동일한 500 전파가 재발하지 않도록 한다 * test: rate limit TTL fallback 검증 보강 - 리뷰 피드백에 따라 TTL 조회 실패 시 기본 window 값이 예외 detail에 반영되는지 확인한다 - 429 에러 코드만 검증하던 테스트를 보강해 fallback 시간이 응답 메시지에서 누락되는 회귀를 막는다 --- .../ratelimit/aspect/RateLimitAspect.java | 19 +++++++++--- .../ratelimit/aspect/RateLimitAspectTest.java | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java index 78f58e68d..067000a53 100644 --- a/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java +++ b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java @@ -71,10 +71,7 @@ public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws // 제한 초과 확인 - 초과 시에만 TTL 조회 if (currentCount > maxRequests) { - Long remainingSeconds = redisTemplate.getExpire(key); - long remaining = remainingSeconds != null && remainingSeconds > 0 - ? remainingSeconds - : timeWindowSeconds; + long remaining = resolveRemainingSeconds(key, timeWindowSeconds); throw RateLimitExceededException.withRemainingTime(remaining); } @@ -127,4 +124,18 @@ private String resolveClientIp() { HttpServletRequest request = attributes.getRequest(); return request.getRemoteAddr(); } + + private long resolveRemainingSeconds(String key, int timeWindowSeconds) { + try { + Long remainingSeconds = redisTemplate.getExpire(key); + return remainingSeconds != null && remainingSeconds > 0 + ? remainingSeconds + : timeWindowSeconds; + } catch (Exception e) { + // 제한 초과 판단은 이미 완료되었으므로 TTL 조회 실패만 기본 window 값으로 대체한다. + log.warn("Rate limit TTL lookup failed for key={}: {}. Using default remaining time.", + key, e.getMessage()); + return timeWindowSeconds; + } + } } diff --git a/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java index 23953a9c8..924ab7131 100644 --- a/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java +++ b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java @@ -19,6 +19,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.QueryTimeoutException; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; @@ -129,6 +130,36 @@ void throwsExceptionWhenLimitExceeded() { }); } + @SuppressWarnings("unchecked") + @Test + @DisplayName("제한 초과 후 TTL 조회가 실패해도 기본 시간으로 429를 반환한다") + void throwsRateLimitExceptionWhenRemainingTimeLookupFails() { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + + // 카운터 증가는 성공했으므로 제한 초과 상태는 유지하고, 남은 시간 조회 실패만 격리한다. + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(11L); + given(redisTemplate.getExpire("ratelimit:test.method:user123")) + .willThrow(new QueryTimeoutException("Redis command timed out")); + + // when & then + assertThatThrownBy(() -> rateLimitAspect.around(joinPoint, rateLimit)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException)ex; + assertThat(customEx.getErrorCode()).isEqualTo(ApiResponseCode.TOO_MANY_REQUESTS); + assertThat(customEx.getDetail()).contains("60"); + }); + } + @SuppressWarnings("unchecked") @Test @DisplayName("빈 keyExpression 시 메서드 시그니처 기본 키 사용") From 01671505d4340bbda0f77e47a0fa234c4b74d12f 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, 5 Jun 2026 10:22:33 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20URI=EC=97=90=20konect?= =?UTF-8?q?=20prefix=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubRegistrationRequestController.java | 2 +- .../club/ClubRegistrationRequestApiTest.java | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java index 91bf98b10..e88fedea5 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -19,7 +19,7 @@ @Tag(name = "Club Registration", description = "동아리 등록 요청 API") @RestController -@RequestMapping("/clubs") +@RequestMapping("/konect/clubs") @RequiredArgsConstructor public class ClubRegistrationRequestController implements ClubRegistrationRequestApi { 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 1af49bd94..aa5442499 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 @@ -34,7 +34,7 @@ void registerClubWithoutLogin() throws Exception { ); // when & then - performPost("/clubs/registration-requests", request) + performPost("/konect/clubs/registration-requests", request) .andExpect(status().isCreated()); } @@ -54,7 +54,7 @@ void registerClubWithoutImages() throws Exception { ); // when & then - performPost("/clubs/registration-requests", request) + performPost("/konect/clubs/registration-requests", request) .andExpect(status().isCreated()); } @@ -74,7 +74,7 @@ void registerClubWithMissingFields() throws Exception { ); // when & then - performPost("/clubs/registration-requests", request) + performPost("/konect/clubs/registration-requests", request) .andExpect(status().isBadRequest()); } @@ -101,7 +101,7 @@ void registerClubWithTooManyImages() throws Exception { ); // when & then - performPost("/clubs/registration-requests", request) + performPost("/konect/clubs/registration-requests", request) .andExpect(status().isBadRequest()); } @@ -122,7 +122,7 @@ void registerClubWithLongIntroduction() throws Exception { ); // when & then - performPost("/clubs/registration-requests", request) + performPost("/konect/clubs/registration-requests", request) .andExpect(status().isBadRequest()); } @@ -135,7 +135,7 @@ void requestClubInformationUpdateWithoutLogin() throws Exception { ClubInformationUpdateRequestDto request = createInformationUpdateRequest(); // when & then - performPost("/clubs/" + club.getId() + "/information-update-requests", request) + performPost("/konect/clubs/" + club.getId() + "/information-update-requests", request) .andExpect(status().isCreated()); } @@ -146,7 +146,7 @@ void requestClubInformationUpdateWithUnknownClub() throws Exception { ClubInformationUpdateRequestDto request = createInformationUpdateRequest(); // when & then - performPost("/clubs/" + Integer.MAX_VALUE + "/information-update-requests", request) + performPost("/konect/clubs/" + Integer.MAX_VALUE + "/information-update-requests", request) .andExpect(status().isNotFound()); } @@ -167,7 +167,7 @@ void requestClubInformationUpdateWithMissingFields() throws Exception { ); // when & then - performPost("/clubs/" + club.getId() + "/information-update-requests", request) + performPost("/konect/clubs/" + club.getId() + "/information-update-requests", request) .andExpect(status().isBadRequest()); } From c7c6c75867ed9753fbf73b8679b38692dba10464 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, 5 Jun 2026 10:52:46 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EC=8B=9C=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=EB=93=B1=EB=A1=9D=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=20=EB=B9=88=20=EA=B0=92=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AdminWebsiteClubSheetImportConfirmRequest.java | 2 +- .../service/AdminWebsiteClubSheetImportService.java | 9 +++++++-- .../AdminWebsiteClubSheetImportControllerTest.java | 4 ++-- .../service/AdminWebsiteClubSheetImportServiceTest.java | 9 +++++++-- 4 files changed, 17 insertions(+), 7 deletions(-) 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 0dc4d9ce0..c61d134c0 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 @@ -33,7 +33,7 @@ public record ConfirmClub( @Size(max = 30) String description, - @NotBlank + @NotNull String introduce, @NotBlank 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 323f15c03..88c36ef16 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 @@ -42,6 +42,7 @@ public class AdminWebsiteClubSheetImportService { private static final int TOPIC_MAX_LENGTH = 20; private static final int DESCRIPTION_MAX_LENGTH = 30; private static final int CATEGORY_EMOJI_MAX_LENGTH = 255; + private static final String EMPTY_INTRODUCE = ""; private static final int NAME_COLUMN_INDEX = 0; private static final int CATEGORY_COLUMN_INDEX = 1; private static final int CUSTOM_CATEGORY_COLUMN_INDEX = 2; @@ -119,7 +120,7 @@ public AdminWebsiteClubSheetImportResponse confirmImport( .name(name) .topic(limit(requiredText(club.topic(), "기타"), TOPIC_MAX_LENGTH)) .description(limit(requiredText(club.description(), name), DESCRIPTION_MAX_LENGTH)) - .introduce(requiredText(club.introduce(), club.description())) + .introduce(optionalText(club.introduce())) .categoryEmoji(limit( requiredText(club.categoryEmoji(), emojiOf(club.clubCategory())), CATEGORY_EMOJI_MAX_LENGTH @@ -166,7 +167,7 @@ private SheetClubImportPlan buildImportPlan(List rows) { category, topic, description, - description, + EMPTY_INTRODUCE, categoryEmoji, true )); @@ -284,6 +285,10 @@ private static String requiredText(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } + private static String optionalText(String value) { + return value == null ? "" : value.trim(); + } + private static String limit(String value, int maxLength) { if (value == null || value.length() <= maxLength) { return value; diff --git a/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java b/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java index d197ab7ce..7b73cd25f 100644 --- a/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java +++ b/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java @@ -41,7 +41,7 @@ void previewClubsReturnsServiceResponse() { ClubCategory.ACADEMIC, "dev", "dev club", - "dev club", + "", "IT", true )), @@ -69,7 +69,7 @@ void confirmImportReturnsServiceResponse() { ClubCategory.ACADEMIC, "dev", "dev club", - "dev club", + "", "IT", true ) 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 803ea24b8..b77532fd4 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 @@ -95,6 +95,9 @@ void previewClubsReadsFixedSheetColumns() throws Exception { assertThat(preview.clubs().get(1).topic()).isEqualTo("기타"); assertThat(preview.clubs().get(1).categoryEmoji()).isEqualTo("⚽"); assertThat(preview.clubs().get(1).description()).isEqualTo("농구동아리 동아리입니다."); + assertThat(preview.clubs()) + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::introduce) + .containsExactly("", ""); assertThat(preview.warnings()).hasSize(3); } @@ -121,7 +124,9 @@ void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { assertThat(response.warnings()).anyMatch(warning -> warning.contains("농구동아리")); assertThat(response.warnings()).anyMatch(warning -> warning.contains("BCSD")); verify(webClubRepository).saveAll(org.mockito.ArgumentMatchers.>argThat(savedClubs -> - savedClubs.size() == 1 && savedClubs.getFirst().getName().equals("BCSD") + savedClubs.size() == 1 + && savedClubs.getFirst().getName().equals("BCSD") + && savedClubs.getFirst().getIntroduce().isEmpty() )); verifyNoInteractions(googleSheetsService); } @@ -168,7 +173,7 @@ private AdminWebsiteClubSheetImportConfirmRequest.ConfirmClub confirmClub( category, "dev", "dev club", - "dev club", + "", "IT", enabled );