-
Notifications
You must be signed in to change notification settings - Fork 3
[feature] 동아리 추천 로직 개선 #660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Zepelown
merged 1 commit into
develop/be
from
feature/#655-recommend-club-server-MOA-161
Aug 16, 2025
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
@@ -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) { | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 중요: 랜덤 풀에서도 AVAILABLE 상태 필터 누락 + 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 |
||
| .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<>(); | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
중요: 사용 불가 상태(state != AVAILABLE) 클럽이 추천에 포함될 수 있음 + id 별칭을 명시적으로 투영 권장
state == AVAILABLE필터가 없습니다. 이렇게 되면 HIDDEN/DELETED 등 비노출 상태의 클럽이 추천에 섞일 수 있습니다. 검색(keyword) 파이프라인에서는state == AVAILABLE를 적용하고 있어 일관성도 깨집니다.club.id()를 사용하므로_id필드가 DTO의id로 매핑되지 않는 경우를 방지하기 위해 프로젝트 단계에서 명시적으로id를 투영하는 편이 안전합니다.다음과 같이 보완을 제안드립니다.
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