Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 126 additions & 26 deletions backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -66,39 +66,139 @@ public List<ClubSearchResult> searchClubsByKeyword(String keyword, String recrui
}

public List<ClubSearchResult> searchRecommendClubs(String category, String excludeClubId) {
Set<String> excludeIds = new HashSet<>();
if (excludeClubId != null) {
excludeIds.add(excludeClubId);
}

List<AggregationOperation> 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<ClubSearchResult> result = new ArrayList<>();

// 1. 같은 카테고리 모집중 + (모집마감 포함) 동아리 최대 4개 추출 (모집상태 우선)
int maxCategoryCount = 4;
List<ClubSearchResult> categoryClubs = findClubsByCategoryAndState(category, excludeIds, true, maxCategoryCount);
addClubs(result, excludeIds, categoryClubs);

int remainCount = maxCategoryCount - categoryClubs.size();

// 2. 부족하면 마감 동아리로 채우기
if (remainCount > 0) {
List<ClubSearchResult> categoryClosedClubs = findClubsByCategoryAndState(category, excludeIds, false, remainCount);
addClubs(result, excludeIds, categoryClosedClubs);
}

// 3. 나머지 전체 랜덤 2개(모집상태 우선)로 채우기
int totalNeeded = 6;
int randomNeeded = totalNeeded - result.size();

if (randomNeeded > 0) {
List<ClubSearchResult> randomPool = findRandomClubs(excludeIds, 10);

List<ClubSearchResult> selectedRandomClubs = selectClubsByStatePriority(randomPool, randomNeeded);
addClubs(result, excludeIds, selectedRandomClubs);
}

return result.isEmpty() ? Collections.emptyList() : result;
}

// 같은 카테고리 & 주어진 모집 상태별 랜덤 n개 동아리 조회
private List<ClubSearchResult> findClubsByCategoryAndState(String category, Set<String> excludeIds,
boolean onlyRecruitAvailable, int limit) {
List<AggregationOperation> ops = new ArrayList<>();

Criteria criteria = Criteria.where("category").is(category)
.and("_id").nin(excludeIds);

if (onlyRecruitAvailable) {
Comment on lines +108 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중요: 사용 불가 상태(state != AVAILABLE) 클럽이 추천에 포함될 수 있음 + id 별칭을 명시적으로 투영 권장

  • 현재 카테고리 조회에서 state == AVAILABLE 필터가 없습니다. 이렇게 되면 HIDDEN/DELETED 등 비노출 상태의 클럽이 추천에 섞일 수 있습니다. 검색(keyword) 파이프라인에서는 state == AVAILABLE를 적용하고 있어 일관성도 깨집니다.
  • 또한 dedup에 club.id()를 사용하므로 _id 필드가 DTO의 id로 매핑되지 않는 경우를 방지하기 위해 프로젝트 단계에서 명시적으로 id를 투영하는 편이 안전합니다.

다음과 같이 보완을 제안드립니다.

-        Criteria criteria = Criteria.where("category").is(category)
-                .and("_id").nin(excludeIds);
+        Criteria criteria = Criteria.where("state").is(ClubState.AVAILABLE.getName())
+                .and("category").is(category)
+                .and("_id").nin(excludeIds);
         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")
+                        .and("_id").as("id")
         );

Also applies to: 119-121, 124-131

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<ClubSearchResult> 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<ClubSearchResult> result, Set<String> excludeIds, List<ClubSearchResult> 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<ClubSearchResult> selectClubsByStatePriority(List<ClubSearchResult> pool, int maxCount) {
List<ClubSearchResult> selected = new ArrayList<>();
Set<String> 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<ClubSearchResult> findRandomClubs(Set<String> excludeIds, int sampleSize) {
List<AggregationOperation> 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")
Comment on lines +181 to +189
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중요: 랜덤 풀에서도 AVAILABLE 상태 필터 누락 + id 투영 명시

findRandomClubs 역시 state == AVAILABLE 조건이 없어 비노출 클럽이 추천될 수 있습니다. 또한 dedup 신뢰성을 위해 id 투영을 명시하는 것이 좋습니다.

-        ops.add(Aggregation.match(Criteria.where("_id").nin(excludeIds)));
+        ops.add(Aggregation.match(new Criteria().andOperator(
+                Criteria.where("state").is(ClubState.AVAILABLE.getName()),
+                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")
+                        .as("tags")
+                        .and("_id").as("id")
         );

Also applies to: 187-194

🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
around lines 181-189 (and similarly at 187-194), the aggregation for
findRandomClubs is missing a filter to include only clubs with state ==
AVAILABLE and does not explicitly project the id; add a
Criteria.where("state").is(ClubState.AVAILABLE) (or the appropriate enum/value)
to the Aggregation.match stage and add "_id" to the Aggregation.project call so
the pipeline filters out non-available clubs and returns the id for reliable
deduplication.

.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<Criteria> criteriaList = new ArrayList<>();
Expand Down
Loading