diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java index b6b33fecb..3b4d6b8fa 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java @@ -31,11 +31,6 @@ public record ClubInformationUpdateRequestDto( @Size(max = 20, message = "동아리 주제는 최대 20자입니다.") String clubTopic, - @Schema(description = "동아리 이모지", example = "💻", requiredMode = REQUIRED) - @NotBlank(message = "동아리 이모지는 필수입니다.") - @Size(max = 10, message = "동아리 이모지는 최대 10자입니다.") - String clubEmoji, - @Schema( description = "한 줄 소개 (최대 30자)", example = "코딩 동아리입니다.", diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java index b59f596e4..c0ad98553 100644 --- a/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java @@ -15,12 +15,11 @@ public record ClubInformationUpdateRequestedEvent( String requestedCategory, String currentTopic, String requestedTopic, - String requestedEmoji, String currentDescription, String requestedDescription, String currentFullIntroduction, String requestedFullIntroduction, - String currentImageUrl, + String currentCategoryEmoji, List requestedImageUrls ) { @@ -36,12 +35,11 @@ public static ClubInformationUpdateRequestedEvent from(ClubInformationUpdateRequ request.getClubCategory().getDescription(), request.getClub().getTopic(), request.getClubTopic(), - request.getClubEmoji(), request.getClub().getDescription(), request.getShortDescription(), request.getClub().getIntroduce(), request.getFullIntroduction(), - request.getClub().getImageUrl(), + request.getClub().getCategoryEmoji(), request.getImages().stream() .map(image -> image.getImageUrl()) .toList() diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java index 2f33401e9..3466a779c 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java @@ -63,10 +63,6 @@ public class ClubInformationUpdateRequest extends BaseEntity { @Column(name = "club_topic", length = 20, nullable = false) private String clubTopic; - @NotNull - @Column(name = "club_emoji", length = 10, nullable = false) - private String clubEmoji; - @NotNull @Column(name = "short_description", length = 30, nullable = false) private String shortDescription; @@ -92,7 +88,6 @@ private ClubInformationUpdateRequest( String clubName, ClubCategory clubCategory, String clubTopic, - String clubEmoji, String shortDescription, String fullIntroduction, UpdateRequestStatus status @@ -103,7 +98,6 @@ private ClubInformationUpdateRequest( this.clubName = clubName; this.clubCategory = clubCategory; this.clubTopic = clubTopic; - this.clubEmoji = clubEmoji; this.shortDescription = shortDescription; this.fullIntroduction = fullIntroduction; this.status = status != null ? status : UpdateRequestStatus.PENDING; diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java index 2b2a25203..668ce6a10 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -58,7 +58,6 @@ public void requestInformationUpdate(Integer clubId, ClubInformationUpdateReques .clubName(request.clubName()) .clubCategory(request.clubCategory()) .clubTopic(request.clubTopic()) - .clubEmoji(request.clubEmoji()) .shortDescription(request.shortDescription()) .fullIntroduction(request.fullIntroduction()) .status(ClubInformationUpdateRequest.UpdateRequestStatus.PENDING) diff --git a/src/main/java/gg/agit/konect/domain/university/controller/UniversityApi.java b/src/main/java/gg/agit/konect/domain/university/controller/UniversityApi.java index 85d8b428e..2bd6ffe4b 100644 --- a/src/main/java/gg/agit/konect/domain/university/controller/UniversityApi.java +++ b/src/main/java/gg/agit/konect/domain/university/controller/UniversityApi.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import gg.agit.konect.domain.university.dto.UniversitiesResponse; @@ -10,14 +11,17 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "(Normal) University: 대학", description = "대학 API") +@Tag(name = "(Normal) University: 대학교", description = "대학교 API") @RequestMapping("/universities") public interface UniversityApi { - @Operation(summary = "대학 리스트를 조회한다.", description = """ - - 응답값은 이름 기준 오름차순 정렬됩니다 + @Operation(summary = "대학교 리스트를 조회한다.", description = """ + - 응답값은 이름 기준 오름차순 정렬됩니다. + - query 파라미터로 대학교 이름, 초성, 약칭 검색을 지원합니다. """) @GetMapping @PublicApi - ResponseEntity getUniversities(); + ResponseEntity getUniversities( + @RequestParam(name = "query", required = false) String query + ); } diff --git a/src/main/java/gg/agit/konect/domain/university/controller/UniversityController.java b/src/main/java/gg/agit/konect/domain/university/controller/UniversityController.java index 92f1ce6b7..81b66355d 100644 --- a/src/main/java/gg/agit/konect/domain/university/controller/UniversityController.java +++ b/src/main/java/gg/agit/konect/domain/university/controller/UniversityController.java @@ -16,8 +16,8 @@ public class UniversityController implements UniversityApi { private final UniversityService universityService; @Override - public ResponseEntity getUniversities() { - UniversitiesResponse response = universityService.getUniversities(); + public ResponseEntity getUniversities(String query) { + UniversitiesResponse response = universityService.getUniversities(query); return ResponseEntity.ok(response); } } 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 new file mode 100644 index 000000000..95dd8ee4f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java @@ -0,0 +1,162 @@ +package gg.agit.konect.domain.university.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.university.model.University; + +@Component +public class UniversitySearchMatcher { + + private static final int HANGUL_SYLLABLE_START = 0xAC00; + private static final int HANGUL_SYLLABLE_END = 0xD7A3; + private static final int HANGUL_SYLLABLE_INTERVAL = 588; + private static final int UNIVERSITY_SUFFIX_LENGTH = "대학교".length(); + + private static final List CHOSEONG = List.of( + "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", + "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ" + ); + + private static final Map COMPATIBILITY_JAMO_CLUSTERS = Map.ofEntries( + Map.entry("ㄳ", "ㄱㅅ"), + Map.entry("ㄵ", "ㄴㅈ"), + Map.entry("ㄶ", "ㄴㅎ"), + Map.entry("ㄺ", "ㄹㄱ"), + Map.entry("ㄻ", "ㄹㅁ"), + Map.entry("ㄼ", "ㄹㅂ"), + Map.entry("ㄽ", "ㄹㅅ"), + Map.entry("ㄾ", "ㄹㅌ"), + Map.entry("ㄿ", "ㄹㅍ"), + Map.entry("ㅀ", "ㄹㅎ"), + 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); + } + + public boolean matches(String universityName, String query) { + if (!StringUtils.hasText(query)) { + return true; + } + + String normalizedQuery = normalize(query); + return getSearchTokens(universityName) + .anyMatch(token -> token.contains(normalizedQuery)); + } + + private Stream getSearchTokens(String universityName) { + Set aliases = getDefaultAliases(universityName); + aliases.addAll(UNIVERSITY_ALIASES.getOrDefault(universityName, List.of())); + + List normalizedAliases = aliases.stream() + .map(this::normalize) + .toList(); + + return Stream.concat( + normalizedAliases.stream(), + normalizedAliases.stream().map(this::getChoseong) + ).distinct(); + } + + private Set getDefaultAliases(String universityName) { + String withoutWhitespace = universityName.replaceAll("\\s", ""); + String withoutCampus = withoutWhitespace.replaceAll("(서울|세종|글로벌|ERICA|WISE)캠퍼스$", ""); + Set aliases = new HashSet<>(); + + aliases.add(withoutWhitespace); + aliases.add(withoutCampus); + + if (withoutCampus.endsWith("대학교")) { + aliases.add(withoutCampus.substring(0, withoutCampus.length() - UNIVERSITY_SUFFIX_LENGTH) + "대"); + } + + if (withoutCampus.startsWith("국립")) { + String withoutNational = withoutCampus.substring(2); + aliases.add(withoutNational); + + if (withoutNational.endsWith("대학교")) { + aliases.add(withoutNational.substring(0, withoutNational.length() - UNIVERSITY_SUFFIX_LENGTH) + "대"); + } + } + + return aliases; + } + + private String normalize(String value) { + return expandCompatibilityJamoClusters(value) + .replaceAll("\\s", "") + .toLowerCase(); + } + + private String expandCompatibilityJamoClusters(String value) { + StringBuilder builder = new StringBuilder(); + + value.codePoints() + .mapToObj(Character::toString) + .forEach(character -> builder.append(COMPATIBILITY_JAMO_CLUSTERS.getOrDefault(character, character))); + + return builder.toString(); + } + + private String getChoseong(String value) { + StringBuilder builder = new StringBuilder(); + + value.codePoints() + .forEach(codePoint -> builder.append(toChoseong(codePoint))); + + return builder.toString(); + } + + private String toChoseong(int codePoint) { + if (codePoint < HANGUL_SYLLABLE_START || codePoint > HANGUL_SYLLABLE_END) { + return Character.toString(codePoint); + } + + return CHOSEONG.get((codePoint - HANGUL_SYLLABLE_START) / HANGUL_SYLLABLE_INTERVAL); + } +} 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 fbbf24809..8a4f8bf24 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 @@ -17,9 +17,14 @@ public class UniversityService { private final UniversityRepository universityRepository; + private final UniversitySearchMatcher universitySearchMatcher; - public UniversitiesResponse getUniversities() { + public UniversitiesResponse getUniversities(String query) { List universities = universityRepository.findAllByOrderByKoreanNameAsc(); - return UniversitiesResponse.from(universities); + List filteredUniversities = universities.stream() + .filter(university -> universitySearchMatcher.matches(university, query)) + .toList(); + + return UniversitiesResponse.from(filteredUniversities); } } diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java index 16f25548b..664959fe6 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -10,7 +10,7 @@ import gg.agit.konect.domain.upload.dto.ImageUploadResponse; import gg.agit.konect.domain.upload.enums.UploadTarget; -import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.auth.annotation.PublicApi; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,15 +25,15 @@ public interface UploadApi { - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. ## 에러 - - MISSING_ACCESS_TOKEN (401): 액세스 토큰이 필요합니다. - INVALID_REQUEST_BODY (400): 파일이 비어있거나 요청 형식이 올바르지 않은 경우 - INVALID_FILE_CONTENT_TYPE (400): 지원하지 않는 Content-Type 인 경우 - PAYLOAD_TOO_LARGE (413): 파일 크기가 제한을 초과한 경우 + - TOO_MANY_REQUESTS (429): 업로드 요청 횟수가 제한을 초과한 경우 - FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우 """) @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PublicApi ResponseEntity uploadImage( - @UserId Integer userId, @RequestPart("file") MultipartFile file, @RequestParam(value = "target") UploadTarget target ); diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java index 8ae2cb68e..51b60db51 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java @@ -7,7 +7,6 @@ import gg.agit.konect.domain.upload.dto.ImageUploadResponse; import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.domain.upload.service.UploadService; -import gg.agit.konect.global.auth.annotation.UserId; import gg.agit.konect.global.ratelimit.annotation.RateLimit; import lombok.RequiredArgsConstructor; @@ -17,10 +16,10 @@ public class UploadController implements UploadApi { private final UploadService uploadService; - @RateLimit(maxRequests = 20, timeWindowSeconds = 60, keyExpression = "#userId") + // 비로그인 공개 업로드는 계정 식별자가 없어 프록시가 복원한 클라이언트 IP 기준으로 시간당 총량을 제한한다. + @RateLimit(maxRequests = 60, timeWindowSeconds = 3600, keyExpression = "#clientIp") @Override public ResponseEntity uploadImage( - @UserId Integer userId, MultipartFile file, UploadTarget target ) { diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java index 212fcca37..b07fd949c 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java @@ -15,8 +15,8 @@ public record WebsiteClubDetailResponse( @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) String name, - @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) - String imageUrl, + @Schema(description = "동아리 분과 이모지", requiredMode = REQUIRED) + String categoryEmoji, @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) ClubCategory category, @@ -71,7 +71,7 @@ public static WebsiteClubDetailResponse of(WebClub club, Long universityClubCoun return new WebsiteClubDetailResponse( club.getId(), club.getName(), - club.getImageUrl(), + club.getCategoryEmoji(), club.getClubCategory(), club.getClubCategory().getDescription(), club.getTopic(), diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java index d809c541f..246824466 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java @@ -99,8 +99,8 @@ public record ClubResponse( @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) String name, - @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) - String imageUrl, + @Schema(description = "동아리 분과 이모지", requiredMode = REQUIRED) + String categoryEmoji, @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) ClubCategory category, @@ -118,7 +118,7 @@ public static ClubResponse from(WebClub club) { return new ClubResponse( club.getId(), club.getName(), - club.getImageUrl(), + club.getCategoryEmoji(), club.getClubCategory(), club.getClubCategory().getDescription(), club.getTopic(), diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebClub.java b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java index b7fd9f1fa..f01eda431 100644 --- a/src/main/java/gg/agit/konect/domain/website/model/WebClub.java +++ b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java @@ -52,8 +52,8 @@ public class WebClub extends BaseEntity { @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) private String introduce; - @Column(name = "image_url", length = 255, nullable = false) - private String imageUrl; + @Column(name = "category_emoji", length = 255, nullable = false) + private String categoryEmoji; @Builder private WebClub( @@ -64,7 +64,7 @@ private WebClub( String topic, String description, String introduce, - String imageUrl + String categoryEmoji ) { this.id = id; this.clubCategory = clubCategory; @@ -73,6 +73,6 @@ private WebClub( this.topic = topic; this.description = description; this.introduce = introduce; - this.imageUrl = imageUrl; + this.categoryEmoji = categoryEmoji; } } 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 4c68e0dac..6b782f561 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.UniversitySearchMatcher; import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; @@ -30,9 +31,14 @@ public class WebsiteService { private final WebsiteQueryRepository websiteQueryRepository; + private final UniversitySearchMatcher universitySearchMatcher; public WebsiteHomeResponse getHome(String query, UniversityRegion region) { - List summaries = websiteQueryRepository.findUniversitySummaries(query, region); + // 초성/약칭 검색은 SQL로 표현하기 어려워 전체 대학을 조회한 뒤 UniversitySearchMatcher로 필터링한다. + List summaries = websiteQueryRepository.findUniversitySummaries(null, region) + .stream() + .filter(summary -> universitySearchMatcher.matches(summary.name(), query)) + .toList(); return WebsiteHomeResponse.from(summaries); } 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 2cfa24ed8..78f58e68d 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 @@ -10,9 +10,12 @@ import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import gg.agit.konect.global.ratelimit.annotation.RateLimit; import gg.agit.konect.global.ratelimit.exception.RateLimitExceededException; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import java.util.Collections; @@ -103,6 +106,7 @@ private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { for (int i = 0; i < paramNames.length; i++) { context.setVariable(paramNames[i], args[i]); } + context.setVariable("clientIp", resolveClientIp()); try { Object result = parser.parseExpression(keyExpression).getValue(context); @@ -114,4 +118,13 @@ private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { return RATE_LIMIT_KEY_PREFIX + methodKey; } } + + private String resolveClientIp() { + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) { + return "unknown"; + } + + HttpServletRequest request = attributes.getRequest(); + return request.getRemoteAddr(); + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index 1a3db1024..8715c28d4 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -68,7 +68,6 @@ public enum SlackMessageTemplate { :bookmark: *동아리명* : %s :label: *분과* : %s :dart: *주제* : %s - :art: *요청 이모지* : *`%s`* :memo: *한 줄 소개* %s diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java index 5f853b9c7..99ea094cd 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -54,12 +54,11 @@ public void handleClubInformationUpdateRequested(ClubInformationUpdateRequestedE event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), event.requestedFullIntroduction(), - event.currentImageUrl(), + event.currentCategoryEmoji(), event.requestedImageUrls() ); } catch (RuntimeException e) { diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java index 1c435bf54..66b79fbbe 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -92,12 +92,11 @@ public void notifyClubInformationUpdateRequest( String requestedCategory, String currentTopic, String requestedTopic, - String requestedEmoji, String currentDescription, String requestedDescription, String currentFullIntroduction, String requestedFullIntroduction, - String currentImageUrl, + String currentCategoryEmoji, List requestedImageUrls ) { String message = CLUB_INFORMATION_UPDATE_REQUEST.format( @@ -107,10 +106,9 @@ public void notifyClubInformationUpdateRequest( formatInlineChange(currentClubName, requestedClubName), formatInlineChange(currentCategory, requestedCategory), formatInlineChange(currentTopic, requestedTopic), - requestedEmoji, formatBlockChange(currentDescription, requestedDescription), formatBlockChange(currentFullIntroduction, requestedFullIntroduction), - formatBlockChange(currentImageUrl, formatImageUrls(requestedImageUrls)) + formatBlockChange(currentCategoryEmoji, formatImageUrls(requestedImageUrls)) ); slackClient.sendMessage(message, slackProperties.webhooks().event()); } diff --git a/src/main/resources/db/migration/V84__drop_club_information_update_request_emoji.sql b/src/main/resources/db/migration/V84__drop_club_information_update_request_emoji.sql new file mode 100644 index 000000000..b3c667686 --- /dev/null +++ b/src/main/resources/db/migration/V84__drop_club_information_update_request_emoji.sql @@ -0,0 +1,2 @@ +ALTER TABLE club_information_update_request + DROP COLUMN club_emoji; diff --git a/src/main/resources/db/migration/V85__rename_web_club_image_url_to_category_emoji.sql b/src/main/resources/db/migration/V85__rename_web_club_image_url_to_category_emoji.sql new file mode 100644 index 000000000..d7c405abd --- /dev/null +++ b/src/main/resources/db/migration/V85__rename_web_club_image_url_to_category_emoji.sql @@ -0,0 +1,2 @@ +ALTER TABLE web_club + CHANGE COLUMN image_url category_emoji VARCHAR(255) NOT NULL; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java index 6110b3d4b..1af49bd94 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 @@ -161,7 +161,6 @@ void requestClubInformationUpdateWithMissingFields() throws Exception { "BCSD Lab", ClubCategory.ACADEMIC, "코딩", - "💻", "수정 소개", "수정 상세 소개입니다.", List.of() @@ -178,7 +177,6 @@ private ClubInformationUpdateRequestDto createInformationUpdateRequest() { "BCSD Lab", ClubCategory.ACADEMIC, "코딩", - "💻", "수정 소개", "수정 상세 소개입니다.", List.of("https://example.com/image1.jpg") 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 05a934bf3..dc778047a 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 @@ -48,7 +48,7 @@ void getUniversitiesWhenEmpty() throws Exception { } @Test - @DisplayName("동일한 이름이라도 캠퍼스가 다르면 각각 조회된다") + @DisplayName("동일한 이름이라도 캠퍼스가 다르면 각각 조회한다") void getUniversitiesWithSameNameDifferentCampus() throws Exception { // given persist(UniversityFixture.create("한국기술교육대학교", Campus.SECOND)); @@ -62,5 +62,57 @@ void getUniversitiesWithSameNameDifferentCampus() throws Exception { .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) .andExpect(jsonPath("$.universities[1].name").value("한국기술교육대학교")); } + + @Test + @DisplayName("query가 대학교 이름 초성이면 일치하는 대학 목록을 조회한다") + void getUniversitiesByChoseongQuery() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT + "?query=ㅎㄱㄱㅅㄱㅇㄷㅎㄱ") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(1))) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")); + } + + @Test + @DisplayName("query 초성에 겹자음이 포함되어도 풀어서 검색한다") + void getUniversitiesByChoseongQueryWithCompatibilityJamoCluster() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT + "?query=ㅎㄱㄳㄱㅇㄷㅎㄱ") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(1))) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")); + } + + @Test + @DisplayName("query가 대학교 약칭이면 일치하는 대학 목록을 조회한다") + void getUniversitiesByAliasQuery() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울과학기술대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT + "?query=한기대") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(1))) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")); + + performGet(UNIVERSITIES_ENDPOINT + "?query=과기대") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(1))) + .andExpect(jsonPath("$.universities[0].name").value("서울과학기술대학교")); + } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index dd5e6b5fe..ae3cc62ed 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -14,7 +15,6 @@ import javax.imageio.ImageIO; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -36,24 +36,18 @@ class UploadApiTest extends IntegrationTestSupport { - private static final int LOGIN_USER_ID = 2024001001; private static final int MAX_UPLOAD_BYTES = 20 * 1024 * 1024; @MockitoBean private S3Client s3Client; - @BeforeEach - void setUp() throws Exception { - mockLoginUser(LOGIN_USER_ID); - } - @Nested @DisplayName("POST /upload/image - 이미지 업로드") class UploadImage { @Test - @DisplayName("지원하는 이미지를 업로드하면 원본 확장자로 key와 CDN URL을 반환한다") - void uploadImageSuccess() throws Exception { + @DisplayName("로그인 없이 지원하는 이미지를 업로드하면 원본 확장자로 key와 CDN URL을 반환한다") + void uploadImageWithoutLoginSuccess() throws Exception { // given byte[] pngBytes = createPngBytes(8, 8); MockMultipartFile file = imageFile("club.png", "image/png", pngBytes); @@ -327,6 +321,27 @@ void uploadImageWhenS3ClientFailsReturnsInternalServerError() throws Exception { .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.code").value("FAILED_UPLOAD_FILE")); } + + @Test + @DisplayName("같은 IP에서 1시간에 60개를 초과해 업로드하면 429를 반환한다") + void uploadImageRateLimitExceededFails() throws Exception { + // given + String clientIp = "203.0.113.10"; + byte[] pngBytes = createPngBytes(8, 8); + + // when + for (int i = 0; i < 60; i++) { + uploadImage(imageFile("club.png", "image/png", pngBytes), UploadTarget.CLUB, clientIp) + .andExpect(status().isOk()); + } + + // then + uploadImage(imageFile("club.png", "image/png", pngBytes), UploadTarget.CLUB, clientIp) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("TOO_MANY_REQUESTS")); + + verify(s3Client, times(60)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } } private ResultActions uploadImage(MockMultipartFile file, UploadTarget target) throws Exception { @@ -341,6 +356,18 @@ private ResultActions uploadImage(MockMultipartFile file, String target) throws ); } + private ResultActions uploadImage(MockMultipartFile file, UploadTarget target, String remoteAddr) throws Exception { + return mockMvc.perform( + multipart("/upload/image") + .file(file) + .param("target", target.name()) + .with(request -> { + request.setRemoteAddr(remoteAddr); + return request; + }) + ); + } + private ResultActions uploadImageWithoutTarget(MockMultipartFile file) throws Exception { return mockMvc.perform( multipart("/upload/image") 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 c4ebbfdaa..8ea13d278 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 @@ -73,6 +73,40 @@ void getHomeWithoutLogin() throws Exception { verify(loginCheckInterceptor, never()).preHandle(any(), any(), any()); verify(authorizationInterceptor, never()).preHandle(any(), any(), any()); } + + @Test + @DisplayName("대학교 이름 초성과 약칭으로 웹사이트 대학 목록을 검색한다") + void getHomeSearchesUniversitiesByChoseongAndAlias() throws Exception { + // given + persist(WebUniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" + )); + persist(WebUniversityFixture.create( + "서울과학기술대학교", + Campus.MAIN, + UniversityRegion.SEOUL + )); + clearPersistenceContext(); + + // when & then + performGet("/konect/home?query=ㅎㄱㄳㄱㅇㄷㅎㄱ") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")); + + performGet("/konect/home?query=한기대") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")); + + performGet("/konect/home?query=과기대") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.universities[0].name").value("서울과학기술대학교")); + } } @Nested @@ -104,6 +138,7 @@ void getUniversityClubsWithFilters() throws Exception { .andExpect(jsonPath("$.totalCount").value(1)) .andExpect(jsonPath("$.clubs", hasSize(1))) .andExpect(jsonPath("$.clubs[0].name").value("BCSD Lab")) + .andExpect(jsonPath("$.clubs[0].categoryEmoji").value("📚")) .andExpect(jsonPath("$.categories[0].category").value("PERFORMANCE")) .andExpect(jsonPath("$.categories[1].category").value("SOCIAL_SERVICE")) .andExpect(jsonPath("$.categories[2].category").value("EXHIBITION_CREATION")) @@ -147,6 +182,7 @@ void getClubDetailSuccess() throws Exception { performGet("/konect/clubs/" + club.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("ZEST")) + .andExpect(jsonPath("$.categoryEmoji").value("📚")) .andExpect(jsonPath("$.categoryName").value("공연")) .andExpect(jsonPath("$.topic").value("코딩")) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) @@ -178,6 +214,7 @@ void getRecentClubsKeepsRequestOrder() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.clubs", hasSize(2))) .andExpect(jsonPath("$.clubs[0].name").value("두 번째")) + .andExpect(jsonPath("$.clubs[0].categoryEmoji").value("📚")) .andExpect(jsonPath("$.clubs[1].name").value("첫 번째")); } diff --git a/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java index ca67d355a..f44f7edab 100644 --- a/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java @@ -18,7 +18,7 @@ public static WebClub create(WebUniversity university, String name, ClubCategory .name(name) .description("한 줄 소개") .introduce("상세 소개입니다.") - .imageUrl("https://example.com/" + name + ".png") + .categoryEmoji("📚") .clubCategory(category) .topic("코딩") .build(); diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java index 1c33a978a..0f082c849 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -112,7 +112,6 @@ void requestInformationUpdatePublishesClubInformationUpdateRequestedEvent() { "요청 동아리명", ClubCategory.ACADEMIC, "AI", - "🤖", "수정 소개", "수정 상세 소개입니다.", List.of("https://example.com/image1.jpg") @@ -124,7 +123,6 @@ void requestInformationUpdatePublishesClubInformationUpdateRequestedEvent() { .clubName(request.clubName()) .clubCategory(request.clubCategory()) .clubTopic(request.clubTopic()) - .clubEmoji(request.clubEmoji()) .shortDescription(request.shortDescription()) .fullIntroduction(request.fullIntroduction()) .build(); @@ -152,12 +150,11 @@ void requestInformationUpdatePublishesClubInformationUpdateRequestedEvent() { assertThat(event.requestedCategory()).isEqualTo(request.clubCategory().getDescription()); assertThat(event.currentTopic()).isEqualTo(club.getTopic()); assertThat(event.requestedTopic()).isEqualTo(request.clubTopic()); - assertThat(event.requestedEmoji()).isEqualTo(request.clubEmoji()); assertThat(event.currentDescription()).isEqualTo(club.getDescription()); assertThat(event.requestedDescription()).isEqualTo(request.shortDescription()); assertThat(event.currentFullIntroduction()).isEqualTo(club.getIntroduce()); assertThat(event.requestedFullIntroduction()).isEqualTo(request.fullIntroduction()); - assertThat(event.currentImageUrl()).isEqualTo(club.getImageUrl()); + assertThat(event.currentCategoryEmoji()).isEqualTo(club.getCategoryEmoji()); assertThat(event.requestedImageUrls()).containsExactlyElementsOf(request.imageUrls()); } } 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 e368d8522..23953a9c8 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,8 +19,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; @@ -153,4 +156,41 @@ void usesDefaultKeyWhenExpressionIsEmpty() throws Throwable { ); } + @SuppressWarnings("unchecked") + @Test + @DisplayName("clientIp 표현식 사용 시 현재 요청 IP로 키를 생성한다") + void usesCurrentRequestRemoteAddrForClientIpExpression() throws Throwable { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("203.0.113.10"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#clientIp"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[0]); + given(joinPoint.getArgs()).willReturn(new Object[0]); + given(joinPoint.proceed()).willReturn(null); + + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(1L); + + try { + // when + rateLimitAspect.around(joinPoint, rateLimit); + + // then + verify(redisTemplate).execute( + any(DefaultRedisScript.class), + eq(Collections.singletonList("ratelimit:test.method:203.0.113.10")), + any(String.class) + ); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + } diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java index 1ed28914f..71f26bea5 100644 --- a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -93,12 +93,11 @@ void handleClubInformationUpdateRequestedDelegatesToSlackService() { event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), event.requestedFullIntroduction(), - event.currentImageUrl(), + event.currentCategoryEmoji(), event.requestedImageUrls() ); } @@ -121,12 +120,11 @@ void handleClubInformationUpdateRequestedSwallowsExceptions() { event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), event.requestedFullIntroduction(), - event.currentImageUrl(), + event.currentCategoryEmoji(), event.requestedImageUrls() ); @@ -161,7 +159,6 @@ private ClubInformationUpdateRequestedEvent createInformationUpdateEvent() { "학술", "코딩", "AI", - "🤖", "현재 소개", "수정 소개", "현재 상세 소개 내용입니다.", diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java index 26727677c..e5dd58155 100644 --- a/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java @@ -94,7 +94,6 @@ void notifyClubInformationUpdateRequestFormatsSlackMessageWithMarkdownAndEmoji() "학술", "코딩", "AI", - "🤖", "현재 소개", "수정 소개", "현재 상세 소개 내용입니다.", @@ -116,7 +115,6 @@ void notifyClubInformationUpdateRequestFormatsSlackMessageWithMarkdownAndEmoji() :bookmark: *동아리명* : *`현재 동아리명`* → *`요청 동아리명`* :label: *분과* : *`문화`* → *`학술`* :dart: *주제* : *`코딩`* → *`AI`* - :art: *요청 이모지* : *`🤖`* :memo: *한 줄 소개* ```현재 소개```