diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java index 453090421..5d6ed8526 100644 --- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java @@ -1,7 +1,7 @@ package moadong.club.repository; -import java.util.ArrayList; -import java.util.List; +import java.util.*; + import lombok.AllArgsConstructor; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; @@ -66,39 +66,139 @@ public List searchClubsByKeyword(String keyword, String recrui } public List searchRecommendClubs(String category, String excludeClubId) { + Set excludeIds = new HashSet<>(); + if (excludeClubId != null) { + excludeIds.add(excludeClubId); + } - List operations = new ArrayList<>(); - // 모집 상태 & 같은 category & 제외할 club _id 필터 - operations.add(Aggregation.match( - new Criteria() - .and("state").is(ClubState.AVAILABLE.getName()) - .and("category").is(category) - .and("_id").ne(excludeClubId) - .and("recruitmentInformation.clubRecruitmentStatus") - .in( - ClubRecruitmentStatus.ALWAYS.toString(), - ClubRecruitmentStatus.OPEN.toString(), - ClubRecruitmentStatus.UPCOMING.toString() - ) - )); - // 랜덤 추출 (5개) - operations.add(Aggregation.sample(5L)); - // 필요한 필드만 매핑 - operations.add( + List result = new ArrayList<>(); + + // 1. 같은 카테고리 모집중 + (모집마감 포함) 동아리 최대 4개 추출 (모집상태 우선) + int maxCategoryCount = 4; + List categoryClubs = findClubsByCategoryAndState(category, excludeIds, true, maxCategoryCount); + addClubs(result, excludeIds, categoryClubs); + + int remainCount = maxCategoryCount - categoryClubs.size(); + + // 2. 부족하면 마감 동아리로 채우기 + if (remainCount > 0) { + List categoryClosedClubs = findClubsByCategoryAndState(category, excludeIds, false, remainCount); + addClubs(result, excludeIds, categoryClosedClubs); + } + + // 3. 나머지 전체 랜덤 2개(모집상태 우선)로 채우기 + int totalNeeded = 6; + int randomNeeded = totalNeeded - result.size(); + + if (randomNeeded > 0) { + List randomPool = findRandomClubs(excludeIds, 10); + + List selectedRandomClubs = selectClubsByStatePriority(randomPool, randomNeeded); + addClubs(result, excludeIds, selectedRandomClubs); + } + + return result.isEmpty() ? Collections.emptyList() : result; + } + + // 같은 카테고리 & 주어진 모집 상태별 랜덤 n개 동아리 조회 + private List findClubsByCategoryAndState(String category, Set excludeIds, + boolean onlyRecruitAvailable, int limit) { + List ops = new ArrayList<>(); + + Criteria criteria = Criteria.where("category").is(category) + .and("_id").nin(excludeIds); + + if (onlyRecruitAvailable) { + criteria = criteria.and("recruitmentInformation.clubRecruitmentStatus") + .in( + ClubRecruitmentStatus.ALWAYS.toString(), + ClubRecruitmentStatus.OPEN.toString() + ); + } + + ops.add(Aggregation.match(criteria)); + ops.add(Aggregation.sample((long) limit)); + + // searchClubsByKeyword 와 동일한 project 단계 적용 + ops.add( Aggregation.project("name", "state", "category", "division") .and("recruitmentInformation.introduction").as("introduction") .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus") - .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")).as("logo") - .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then("")).as("tags") + .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")) + .as("logo") + .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList())) + .as("tags") ); - Aggregation aggregation = Aggregation.newAggregation(operations); - AggregationResults results = mongoTemplate.aggregate(aggregation, "clubs", - ClubSearchResult.class); + return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class) + .getMappedResults(); + } - return results.getMappedResults(); + // 중복 ID 추적하며 클럽 리스트에 추가 + private void addClubs(List result, Set excludeIds, List clubs) { + for (ClubSearchResult club : clubs) { + if (!excludeIds.contains(club.id())) { + result.add(club); + excludeIds.add(club.id()); + } + } + } + + // 전체 랜덤 풀에서 모집중 우선으로 n개, 부족하면 마감 동아리로 채움 + private boolean isRecruiting(ClubSearchResult club) { + String status = club.recruitmentStatus(); + return ClubRecruitmentStatus.ALWAYS.toString().equals(status) || ClubRecruitmentStatus.OPEN.toString().equals(status); + } + + private List selectClubsByStatePriority(List pool, int maxCount) { + List selected = new ArrayList<>(); + Set ids = new HashSet<>(); + + // 모집중 우선 선택 + for (ClubSearchResult club : pool) { + if (selected.size() >= maxCount) break; + if (isRecruiting(club) && !ids.contains(club.id())) { + selected.add(club); + ids.add(club.id()); + } + } + + // 부족하면 모집 마감 동아리 추가 + if (selected.size() < maxCount) { + for (ClubSearchResult club : pool) { + if (selected.size() >= maxCount) break; + if (!isRecruiting(club) && !ids.contains(club.id())) { + selected.add(club); + ids.add(club.id()); + } + } + } + + return selected; + } + + // 전체 클럽에서 랜덤 n개 뽑기 (중복 제거용 excludeIds는 외부에서 처리) + private List findRandomClubs(Set excludeIds, int sampleSize) { + List ops = new ArrayList<>(); + ops.add(Aggregation.match(Criteria.where("_id").nin(excludeIds))); + ops.add(Aggregation.sample((long) sampleSize)); + + ops.add( + Aggregation.project("name", "state", "category", "division") + .and("recruitmentInformation.introduction").as("introduction") + .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus") + .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")) + .as("logo") + .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList())) + .as("tags") + ); + + return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class) + .getMappedResults(); } + + private Criteria getMatchedCriteria(String recruitmentStatus, String division, String category) { List criteriaList = new ArrayList<>();