From 64c8472e8b6684367b7af1b88a93bbfb1b0974b2 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 28 May 2026 23:50:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EB=8C=80=ED=95=99=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=B4=88=EC=84=B1=20=EB=B0=8F=20=EC=95=BD=EC=B9=AD?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/controller/UniversityApi.java | 12 +- .../controller/UniversityController.java | 4 +- .../service/UniversitySearchMatcher.java | 157 ++++++++++++++++++ .../university/service/UniversityService.java | 9 +- .../domain/university/UniversityApiTest.java | 54 +++++- 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java 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..e1226a619 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/service/UniversitySearchMatcher.java @@ -0,0 +1,157 @@ +package gg.agit.konect.domain.university.service; + +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) { + if (!StringUtils.hasText(query)) { + return true; + } + + String normalizedQuery = normalize(query); + return getSearchTokens(university.getKoreanName()) + .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 java.util.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/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("서울과학기술대학교")); + } } } From 2e50d02d7af47c990f508308e199ca6e3b61108f Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 29 May 2026 00:06:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?style:=20=EB=8C=80=ED=95=99=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=A7=A4=EC=B2=98=20import=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/university/service/UniversitySearchMatcher.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e1226a619..6eb72d47b 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 @@ -1,5 +1,6 @@ package gg.agit.konect.domain.university.service; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -101,7 +102,7 @@ private Stream getSearchTokens(String universityName) { private Set getDefaultAliases(String universityName) { String withoutWhitespace = universityName.replaceAll("\\s", ""); String withoutCampus = withoutWhitespace.replaceAll("(서울|세종|글로벌|ERICA|WISE)캠퍼스$", ""); - Set aliases = new java.util.HashSet<>(); + Set aliases = new HashSet<>(); aliases.add(withoutWhitespace); aliases.add(withoutCampus); From e06109cc41d352c8ca587ab02317e65582bc6661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 29 May 2026 11:49:27 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EB=B9=84=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=ED=97=88=EC=9A=A9=20(#6?= =?UTF-8?q?49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 웹사이트에서 로그인 전에도 이미지를 업로드할 수 있도록 업로드 엔드포인트를 공개 API로 전환 - 공개 API 처리 중 사용자 ID 인자 리졸버가 다시 토큰 누락 오류를 만들지 않도록 인증 의존 인자를 제거 - 사용자 기준 rate limit은 비로그인 요청 키 정책이 정해지기 전까지 제거해 공개 호출 전환 범위를 명확히 유지 - 로그인 mock 없이 업로드 성공을 검증해 비로그인 업로드 계약의 회귀를 방지 --- .../konect/domain/upload/controller/UploadApi.java | 5 ++--- .../domain/upload/controller/UploadController.java | 4 ---- .../integration/domain/upload/UploadApiTest.java | 11 ++--------- 3 files changed, 4 insertions(+), 16 deletions(-) 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..ad474eba5 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,14 @@ 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): 파일 크기가 제한을 초과한 경우 - 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..72d8c24eb 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,8 +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; @RestController @@ -17,10 +15,8 @@ public class UploadController implements UploadApi { private final UploadService uploadService; - @RateLimit(maxRequests = 20, timeWindowSeconds = 60, keyExpression = "#userId") @Override public ResponseEntity uploadImage( - @UserId Integer userId, MultipartFile file, UploadTarget target ) { 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..57bd4dea4 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 @@ -14,7 +14,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 +35,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); From 899db1c3b6052ab16385132096ede68ff5746356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=80=EC=9A=B0?= <103417427+JanooGwan@users.noreply.github.com> Date: Fri, 29 May 2026 13:06:04 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EC=9B=B9=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EB=8C=80=ED=95=99=20=EA=B2=80=EC=83=89=20=EC=B4=88=EC=84=B1?= =?UTF-8?q?=20=EB=B0=8F=20=EC=95=BD=EC=B9=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 웹사이트 대학 검색 초성 및 약칭 적용 * docs: 웹사이트 대학 검색 필터링 의도 주석 추가 --- .../service/UniversitySearchMatcher.java | 6 +++- .../website/service/WebsiteService.java | 8 ++++- .../domain/website/WebsiteApiTest.java | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) 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 6eb72d47b..95dd8ee4f 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 @@ -76,12 +76,16 @@ public class UniversitySearchMatcher { ); 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(university.getKoreanName()) + return getSearchTokens(universityName) .anyMatch(token -> token.contains(normalizedQuery)); } 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/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index c4ebbfdaa..428fdbd4e 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 From c6b3a3656cdff79212eee0d75aaeaa0e39185a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 29 May 2026 13:15:07 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20(#651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이모지는 기획상 수정 대상이 아니므로 수정 요청 DTO와 알림 payload에서 제외 - 수정 요청 저장 시 이모지 값을 요구하지 않도록 엔티티와 서비스 흐름을 정리 - 기존 수정 요청 테이블의 이모지 컬럼을 후속 마이그레이션으로 제거해 저장 실패를 방지 --- .../domain/club/dto/ClubInformationUpdateRequestDto.java | 5 ----- .../club/event/ClubInformationUpdateRequestedEvent.java | 2 -- .../domain/club/model/ClubInformationUpdateRequest.java | 6 ------ .../domain/club/service/ClubRegistrationRequestService.java | 1 - .../infrastructure/slack/enums/SlackMessageTemplate.java | 1 - .../listener/ClubRegistrationRequestSlackListener.java | 1 - .../slack/service/SlackNotificationService.java | 2 -- .../V84__drop_club_information_update_request_emoji.sql | 2 ++ .../domain/club/ClubRegistrationRequestApiTest.java | 2 -- .../club/service/ClubRegistrationRequestServiceTest.java | 3 --- .../listener/ClubRegistrationRequestSlackListenerTest.java | 3 --- .../slack/service/SlackNotificationServiceTest.java | 2 -- 12 files changed, 2 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/db/migration/V84__drop_club_information_update_request_emoji.sql 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..74d2d0e7c 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,7 +15,6 @@ public record ClubInformationUpdateRequestedEvent( String requestedCategory, String currentTopic, String requestedTopic, - String requestedEmoji, String currentDescription, String requestedDescription, String currentFullIntroduction, @@ -36,7 +35,6 @@ public static ClubInformationUpdateRequestedEvent from(ClubInformationUpdateRequ request.getClubCategory().getDescription(), request.getClub().getTopic(), request.getClubTopic(), - request.getClubEmoji(), request.getClub().getDescription(), request.getShortDescription(), request.getClub().getIntroduce(), 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/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..95eb22ea3 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,7 +54,6 @@ public void handleClubInformationUpdateRequested(ClubInformationUpdateRequestedE event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), 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..08a4cfb9b 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,7 +92,6 @@ public void notifyClubInformationUpdateRequest( String requestedCategory, String currentTopic, String requestedTopic, - String requestedEmoji, String currentDescription, String requestedDescription, String currentFullIntroduction, @@ -107,7 +106,6 @@ public void notifyClubInformationUpdateRequest( formatInlineChange(currentClubName, requestedClubName), formatInlineChange(currentCategory, requestedCategory), formatInlineChange(currentTopic, requestedTopic), - requestedEmoji, formatBlockChange(currentDescription, requestedDescription), formatBlockChange(currentFullIntroduction, requestedFullIntroduction), formatBlockChange(currentImageUrl, formatImageUrls(requestedImageUrls)) 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/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/unit/domain/club/service/ClubRegistrationRequestServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java index 1c33a978a..2aa7eabf6 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,7 +150,6 @@ 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()); 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..e877d5a37 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,7 +93,6 @@ void handleClubInformationUpdateRequestedDelegatesToSlackService() { event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), @@ -121,7 +120,6 @@ void handleClubInformationUpdateRequestedSwallowsExceptions() { event.requestedCategory(), event.currentTopic(), event.requestedTopic(), - event.requestedEmoji(), event.currentDescription(), event.requestedDescription(), event.currentFullIntroduction(), @@ -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: *한 줄 소개* ```현재 소개``` From 1464f71b11fffd8f9d9f3e364ed633977faa373e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 29 May 2026 14:06:20 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=B4=9D=EB=9F=89=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=81=EC=9A=A9=20(#652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 업로드 시간당 총량 제한 적용 - 비로그인 공개 업로드가 특정 속도로 무제한 지속되지 않도록 IP 기준 시간당 60회 제한을 적용 - 기존 Redis 기반 rate limit을 재사용해 별도 인프라 정책 없이 API 계약에서 429 응답을 보장 - 프록시가 복원한 클라이언트 IP를 기준으로 제한 키를 만들도록 요청 객체를 컨트롤러 인자로 전달 - 같은 IP의 61번째 업로드가 차단되는 통합 테스트로 시간당 총량 제한 회귀를 방지 * fix: 업로드 제한 키를 AOP 요청 컨텍스트로 분리 - 공개 업로드 API 계약에 사용하지 않는 request 인자가 노출되지 않도록 컨트롤러 시그니처를 정리 - rate limit SpEL에서 현재 요청 IP를 직접 참조할 수 있도록 clientIp 변수를 AOP 컨텍스트에 제공 - 업로드 시간당 제한은 유지하면서 API 입력은 실제 요청 파트와 target만 드러나도록 선택 - clientIp 표현식이 현재 요청의 remoteAddr로 Redis 키를 만드는 단위 테스트를 추가해 회귀를 방지 --- .../domain/upload/controller/UploadApi.java | 1 + .../upload/controller/UploadController.java | 3 ++ .../ratelimit/aspect/RateLimitAspect.java | 13 ++++++ .../domain/upload/UploadApiTest.java | 34 ++++++++++++++++ .../ratelimit/aspect/RateLimitAspectTest.java | 40 +++++++++++++++++++ 5 files changed, 91 insertions(+) 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 ad474eba5..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 @@ -28,6 +28,7 @@ public interface UploadApi { - 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) 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 72d8c24eb..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,6 +7,7 @@ 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.ratelimit.annotation.RateLimit; import lombok.RequiredArgsConstructor; @RestController @@ -15,6 +16,8 @@ public class UploadController implements UploadApi { private final UploadService uploadService; + // 비로그인 공개 업로드는 계정 식별자가 없어 프록시가 복원한 클라이언트 IP 기준으로 시간당 총량을 제한한다. + @RateLimit(maxRequests = 60, timeWindowSeconds = 3600, keyExpression = "#clientIp") @Override public ResponseEntity uploadImage( MultipartFile file, 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/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 57bd4dea4..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; @@ -320,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 { @@ -334,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/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(); + } + } + } From a2e98d9ba61c721e95881a2e128f52f868f31413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=80=EC=9A=B0?= <103417427+JanooGwan@users.noreply.github.com> Date: Fri, 29 May 2026 17:38:55 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EC=9B=B9=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B6=84=EA=B3=BC=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/event/ClubInformationUpdateRequestedEvent.java | 4 ++-- .../domain/website/dto/WebsiteClubDetailResponse.java | 6 +++--- .../konect/domain/website/dto/WebsiteClubsResponse.java | 6 +++--- .../java/gg/agit/konect/domain/website/model/WebClub.java | 8 ++++---- .../listener/ClubRegistrationRequestSlackListener.java | 2 +- .../slack/service/SlackNotificationService.java | 4 ++-- .../V85__rename_web_club_image_url_to_category_emoji.sql | 2 ++ .../konect/integration/domain/website/WebsiteApiTest.java | 3 +++ .../gg/agit/konect/support/fixture/WebClubFixture.java | 2 +- .../club/service/ClubRegistrationRequestServiceTest.java | 2 +- .../ClubRegistrationRequestSlackListenerTest.java | 4 ++-- 11 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 src/main/resources/db/migration/V85__rename_web_club_image_url_to_category_emoji.sql 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 74d2d0e7c..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 @@ -19,7 +19,7 @@ public record ClubInformationUpdateRequestedEvent( String requestedDescription, String currentFullIntroduction, String requestedFullIntroduction, - String currentImageUrl, + String currentCategoryEmoji, List requestedImageUrls ) { @@ -39,7 +39,7 @@ public static ClubInformationUpdateRequestedEvent from(ClubInformationUpdateRequ 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/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/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java index 95eb22ea3..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 @@ -58,7 +58,7 @@ public void handleClubInformationUpdateRequested(ClubInformationUpdateRequestedE 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 08a4cfb9b..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 @@ -96,7 +96,7 @@ public void notifyClubInformationUpdateRequest( String requestedDescription, String currentFullIntroduction, String requestedFullIntroduction, - String currentImageUrl, + String currentCategoryEmoji, List requestedImageUrls ) { String message = CLUB_INFORMATION_UPDATE_REQUEST.format( @@ -108,7 +108,7 @@ public void notifyClubInformationUpdateRequest( formatInlineChange(currentTopic, requestedTopic), 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/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/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index 428fdbd4e..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 @@ -138,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")) @@ -181,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("한국기술교육대학교")) @@ -212,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 2aa7eabf6..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 @@ -154,7 +154,7 @@ void requestInformationUpdatePublishesClubInformationUpdateRequestedEvent() { 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/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java index e877d5a37..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 @@ -97,7 +97,7 @@ void handleClubInformationUpdateRequestedDelegatesToSlackService() { event.requestedDescription(), event.currentFullIntroduction(), event.requestedFullIntroduction(), - event.currentImageUrl(), + event.currentCategoryEmoji(), event.requestedImageUrls() ); } @@ -124,7 +124,7 @@ void handleClubInformationUpdateRequestedSwallowsExceptions() { event.requestedDescription(), event.currentFullIntroduction(), event.requestedFullIntroduction(), - event.currentImageUrl(), + event.currentCategoryEmoji(), event.requestedImageUrls() );