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..3d53a574c --- /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 = """ + 고정된 작성 시트 양식의 A~F 컬럼을 읽어 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..c61d134c0 --- /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, + + @NotNull + 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..88c36ef16 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/website/service/AdminWebsiteClubSheetImportService.java @@ -0,0 +1,336 @@ +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.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminWebsiteClubSheetImportService { + + private static final String INPUT_SHEET_RANGE = "'작성 시트'!A1:F1000"; + 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; + 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; + 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 WebUniversityRepository webUniversityRepository; + private final WebClubRepository webClubRepository; + + public AdminWebsiteClubSheetImportPreviewResponse previewClubs( + Integer universityId, + String spreadsheetUrl + ) { + webUniversityRepository.getById(universityId); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + SheetClubImportPlan importPlan = buildImportPlan(readClubRows(spreadsheetId)); + return AdminWebsiteClubSheetImportPreviewResponse.of( + universityId, + importPlan.clubs(), + importPlan.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(optionalText(club.introduce())) + .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 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, + EMPTY_INTRODUCE, + 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() + .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 (HEADER_NAME.equals(cell(values.get(i), NAME_COLUMN_INDEX))) { + return i; + } + } + return DEFAULT_HEADER_INDEX; + } + + private ClubCategory resolveCategory(String categoryText, String customCategoryText) { + String normalized = requiredText(categoryText, customCategoryText); + if (normalized.isBlank()) { + return ClubCategory.ETC; + } + for (ClubCategory category : ClubCategory.values()) { + if (category.name().equalsIgnoreCase(normalized)) { + return category; + } + } + return switch (normalized.trim()) { + 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 optionalText(String value) { + return value == null ? "" : 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 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 SheetClubImportPlan( + List clubs, + List warnings + ) { + } +} 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/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..c799f27b5 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/model/UniversitySearchKeyword.java @@ -0,0 +1,77 @@ +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.domain.website.model.WebUniversity; +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 WebUniversity 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, + WebUniversity 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/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/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/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..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) { @@ -51,6 +64,7 @@ public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClub universityId, condition.query(), condition.category(), + condition.sortBy(), pageable ); 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/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..d4aa1f267 --- /dev/null +++ b/src/main/resources/db/migration/V87__seed_university_search_keywords.sql @@ -0,0 +1,68 @@ +-- 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 ( + 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/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/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()); } 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..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 @@ -9,8 +9,12 @@ 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.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 { @@ -98,9 +102,13 @@ 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)); + 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 8ea13d278..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 @@ -21,6 +21,7 @@ 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.UniversitySearchKeywordFixture; import gg.agit.konect.support.fixture.WebClubFixture; import gg.agit.konect.support.fixture.WebUniversityFixture; @@ -78,17 +79,19 @@ void getHomeWithoutLogin() throws Exception { @DisplayName("대학교 이름 초성과 약칭으로 웹사이트 대학 목록을 검색한다") void getHomeSearchesUniversitiesByChoseongAndAlias() throws Exception { // given - 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 )); + persist(UniversitySearchKeywordFixture.createAlias(koreatech, "한기대")); + persist(UniversitySearchKeywordFixture.createAlias(seoulTech, "과기대")); clearPersistenceContext(); // when & then @@ -151,6 +154,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 { 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..0a755f4ab --- /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.UniversitySearchKeyword; +import gg.agit.konect.domain.website.model.WebUniversity; + +public class UniversitySearchKeywordFixture { + + public static UniversitySearchKeyword createAlias(WebUniversity 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/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java b/src/test/java/gg/agit/konect/unit/admin/website/controller/AdminWebsiteClubSheetImportControllerTest.java new file mode 100644 index 000000000..7b73cd25f --- /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", + "", + "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", + "", + "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..b77532fd4 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/admin/website/service/AdminWebsiteClubSheetImportServiceTest.java @@ -0,0 +1,181 @@ +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 java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +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.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; + + @BeforeEach + void setUp() { + service = new AdminWebsiteClubSheetImportService( + googleSheetsService, + webUniversityRepository, + webClubRepository + ); + } + + @Test + 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); + 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", "example"), + List.of("BCSD", "학술분과", "", "dev", "IT", "dev club"), + List.of("농구동아리", "체육(운동)분과", "", "", "", "") + ))); + + AdminWebsiteClubSheetImportPreviewResponse preview = service.previewClubs( + UNIVERSITY_ID, + "https://docs.google.com/spreadsheets/d/sheet-id/edit" + ); + + assertThat(preview.previewCount()).isEqualTo(2); + assertThat(preview.clubs()) + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::name) + .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.clubs()) + .extracting(AdminWebsiteClubSheetImportPreviewResponse.PreviewClub::introduce) + .containsExactly("", ""); + assertThat(preview.warnings()).hasSize(3); + } + + @Test + void confirmImportSavesEnabledAndNonDuplicateClubsOnly() { + List clubs = List.of( + 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(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") + && savedClubs.getFirst().getIntroduce().isEmpty() + )); + verifyNoInteractions(googleSheetsService); + } + + @Test + void confirmImportSkipsExistingClubNameCaseInsensitively() { + given(webUniversityRepository.getById(UNIVERSITY_ID)).willReturn(university()); + given(webClubRepository.findExistingNamesByUniversityId(eq(UNIVERSITY_ID), anySet())) + .willReturn(Set.of("bcsd")); + + AdminWebsiteClubSheetImportResponse response = service.confirmImport( + UNIVERSITY_ID, + List.of(confirmClub(5, "BCSD", ClubCategory.ACADEMIC, true)) + ); + + 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); + } + + 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", + "", + "IT", + enabled + ); + } +} 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..4675dbafa --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/university/UniversitySearchKeywordMigrationTest.java @@ -0,0 +1,199 @@ +package gg.agit.konect.unit.domain.university; + +import static org.assertj.core.api.Assertions.assertThat; + +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 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/V87__seed_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"); + } + + @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"); + 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 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")); + } + } +} 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 시 메서드 시그니처 기본 키 사용")