From f53b90abda7168b44a9fdd2eada77db2e43e5070 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, 22 May 2026 11:46:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EB=8C=80=ED=95=99=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80=20(#632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 동아리 상세에 대학 요약 정보 추가 - 웹사이트 동아리 상세에서 대학 이미지와 대학 전체 동아리 수를 함께 내려주도록 응답 계약을 확장 - 현재 동아리 회원 수와 대학 단위 동아리 수를 분리해 클라이언트가 각각의 의미를 명확히 사용할 수 있도록 유지 - 상세 API 통합 테스트로 대학 이미지 URL과 동아리 수 응답을 검증 * fix: 동아리 상세 대학 수 집계 조건 정렬 - 대학별 동아리 수 계산이 웹사이트 동아리 조회 조건과 같은 기준을 재사용하도록 정리 - 추후 노출 조건이 공통 조건에 추가될 때 상세 응답의 university.clubCount만 다른 기준으로 계산되는 일을 방지 - 리뷰에서 지적된 집계 기준 드리프트 가능성을 최소 변경으로 해소 --- .../dto/WebsiteClubDetailResponse.java | 19 ++++++++++++++++--- .../repository/WebsiteQueryRepository.java | 10 ++++++++++ .../website/service/WebsiteService.java | 3 ++- .../domain/website/WebsiteApiTest.java | 6 +++++- 4 files changed, 33 insertions(+), 5 deletions(-) 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 75783697..0b30a269 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 @@ -45,6 +45,7 @@ public record WebsiteClubDetailResponse( Recruitment recruitment ) { + @Schema(name = "WebsiteClubDetailUniversityResponse") public record University( @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) Integer id, @@ -59,7 +60,17 @@ public record University( UniversityRegion region, @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) - String regionName + String regionName, + + @Schema( + description = "대학 로고 이미지 URL", + example = "https://example.com/koreatech-logo.png", + requiredMode = REQUIRED + ) + String imageUrl, + + @Schema(description = "대학에 등록된 동아리 수", example = "28", requiredMode = REQUIRED) + Long clubCount ) { } @@ -95,7 +106,7 @@ private static Recruitment from(Club club) { } } - public static WebsiteClubDetailResponse of(Club club, Long memberCount) { + public static WebsiteClubDetailResponse of(Club club, Long memberCount, Long universityClubCount) { return new WebsiteClubDetailResponse( club.getId(), club.getName(), @@ -111,7 +122,9 @@ public static WebsiteClubDetailResponse of(Club club, Long memberCount) { club.getUniversity().getKoreanName(), club.getUniversity().getCampus().getDisplayName(), club.getUniversity().getRegion(), - club.getUniversity().getRegion().getDisplayName() + club.getUniversity().getRegion().getDisplayName(), + club.getUniversity().getImageUrl(), + universityClubCount ), Recruitment.from(club) ); diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java index ea142261..d4f00ee9 100644 --- a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -125,6 +125,16 @@ public Map countClubCategories(Integer universityId, String return categoryCounts; } + public Long countClubsByUniversityId(Integer universityId) { + Long count = jpaQueryFactory + .select(club.count()) + .from(club) + .where(createClubCondition(universityId, null, null)) + .fetchOne(); + + return count == null ? 0 : count; + } + public Optional findClub(Integer clubId) { return Optional.ofNullable(jpaQueryFactory .selectFrom(club) 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 69b38465..5902e611 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 @@ -63,8 +63,9 @@ public WebsiteClubDetailResponse getClubDetail(Integer clubId) { Club club = websiteQueryRepository.findClub(clubId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB)); Long memberCount = websiteQueryRepository.countMembersByClubIds(List.of(clubId)).getOrDefault(clubId, 0L); + Long universityClubCount = websiteQueryRepository.countClubsByUniversityId(club.getUniversity().getId()); - return WebsiteClubDetailResponse.of(club, memberCount); + return WebsiteClubDetailResponse.of(club, memberCount, universityClubCount); } public WebsiteClubsResponse getRecentClubs(List clubIds) { 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 ab7a54b5..e357d5dd 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 @@ -126,9 +126,11 @@ void getClubDetailSuccess() throws Exception { University university = persist(UniversityFixture.create( "한국기술교육대학교", Campus.MAIN, - UniversityRegion.CHUNGCHEONG + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" )); Club club = persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); + persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); persistMember(club, "회장", "2024000004"); clearPersistenceContext(); @@ -140,6 +142,8 @@ void getClubDetailSuccess() throws Exception { .andExpect(jsonPath("$.categoryName").value("공연")) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) .andExpect(jsonPath("$.university.region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.university.imageUrl").value("https://example.com/koreatech-logo.png")) + .andExpect(jsonPath("$.university.clubCount").value(2)) .andExpect(jsonPath("$.memberCount").value(1)) .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) .andExpect(jsonPath("$.recruitment.content").value("상시 모집 공고 내용입니다."));