Skip to content

Commit 33a6f75

Browse files
yongseong123claude
andcommitted
feat: 과정 목록 조회 시 완전한 데이터 보유 과정 우선 표시 (#112)
prioritizeFull=true 파라미터 전달 시 기관 이미지·만족도·취업률·별점이 모두 있는 과정을 기존 정렬(최신순·만족도순·취업률순·마감임박순·인기순) 내에서 우선 노출합니다. - CourseListRequest에 prioritizeFull 필드 추가 - CourseSessionSpecification에 orderByCompleteDataFirst() 및 orderByLatest/Satisfaction/EmploymentRate Criteria 정렬 메서드 추가 - CourseService에서 prioritizeFull=true 시 Criteria ORDER BY로 전환하여 완전성 우선순위를 기존 정렬 앞에 삽입 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8d89627 commit 33a6f75

3 files changed

Lines changed: 138 additions & 2 deletions

File tree

src/main/java/com/bootsignal/domain/course/dto/CourseListRequest.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ public record CourseListRequest(
4444

4545
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
4646
@Max(value = 100, message = "페이지 크기는 100 이하이어야 합니다.")
47-
Integer size
47+
Integer size,
48+
49+
/**
50+
* true 전달 시 기관 이미지·만족도·취업률·별점이 모두 있는 과정을 기존 정렬 내에서 우선 노출합니다.
51+
* null 또는 false이면 기존 정렬 동작을 그대로 유지합니다.
52+
*/
53+
Boolean prioritizeFull
4854
) {
4955
// page와 size가 null이거나 기본값이 누락되었을 때 방어 코드 적용
5056
public CourseListRequest {

src/main/java/com/bootsignal/domain/course/service/CourseService.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ public class CourseService {
5555
public PageResponse<CourseListResponse> getCourses(CourseListRequest request) {
5656
CourseSort courseSort = CourseSort.from(request.sort());
5757
LocalDate today = LocalDate.now(SERVICE_ZONE);
58+
boolean shouldPrioritize = Boolean.TRUE.equals(request.prioritizeFull());
59+
5860
Specification<CourseSession> spec = Specification.allOf(
5961
CourseSessionSpecification.withKeyword(request.keyword()),
6062
CourseSessionSpecification.withTrngAreaCd(request.trngAreaCd()),
@@ -71,9 +73,28 @@ public PageResponse<CourseListResponse> getCourses(CourseListRequest request) {
7173
} else if (courseSort == CourseSort.DEADLINE) {
7274
// 모집 마감 임박순은 이미 시작한 과정보다 시작 예정 과정을 우선 노출합니다.
7375
spec = spec.and(CourseSessionSpecification.orderByDeadlineSoon(today));
76+
} else if (shouldPrioritize) {
77+
// prioritizeFull=true 시 Pageable Sort 대신 Criteria ORDER BY 로 전환하여 우선순위와 결합합니다.
78+
spec = switch (courseSort) {
79+
case LATEST -> spec.and(CourseSessionSpecification.orderByLatest());
80+
case SATISFACTION -> spec.and(CourseSessionSpecification.orderBySatisfaction());
81+
case EMPLOYMENT_RATE -> spec.and(CourseSessionSpecification.orderByEmploymentRate());
82+
default -> spec;
83+
};
84+
}
85+
86+
if (shouldPrioritize) {
87+
// 기본 정렬 Specification 뒤에 완전한 데이터 우선순위를 삽입합니다.
88+
spec = spec.and(CourseSessionSpecification.orderByCompleteDataFirst());
7489
}
7590

76-
Pageable pageable = PageRequest.of(request.page(), request.size(), toSort(courseSort));
91+
// prioritizeFull=true 이고 LATEST/SATISFACTION/EMPLOYMENT_RATE 정렬인 경우
92+
// Criteria ORDER BY 를 사용하므로 Pageable Sort 는 비워둡니다.
93+
boolean useCriteriaSort = shouldPrioritize
94+
&& courseSort != CourseSort.POPULAR
95+
&& courseSort != CourseSort.DEADLINE;
96+
Pageable pageable = PageRequest.of(request.page(), request.size(),
97+
useCriteriaSort ? Sort.unsorted() : toSort(courseSort));
7798

7899
// N+1 방지를 위해 course와 institution을 fetch join
79100
Specification<CourseSession> withFetch = spec.and((root, query, cb) -> {

src/main/java/com/bootsignal/domain/course_session/repository/CourseSessionSpecification.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
import com.bootsignal.domain.course.dto.PriceRange;
77
import com.bootsignal.domain.course.dto.FieldCategory;
88
import com.bootsignal.domain.course_session.entity.CourseSession;
9+
import com.bootsignal.domain.crawled_review.entity.CrawledReview;
10+
import com.bootsignal.domain.review.entity.Review;
911
import jakarta.persistence.criteria.JoinType;
12+
import jakarta.persistence.criteria.Order;
1013
import jakarta.persistence.criteria.Path;
1114
import jakarta.persistence.criteria.Root;
1215
import jakarta.persistence.criteria.Subquery;
1316
import org.springframework.data.jpa.domain.Specification;
1417
import org.springframework.util.StringUtils;
1518

1619
import java.time.LocalDate;
20+
import java.util.ArrayList;
21+
import java.util.List;
1722
import java.util.Set;
1823

1924
/**
@@ -162,6 +167,110 @@ public static Specification<CourseSession> orderByBookmarkCountDesc() {
162167
};
163168
}
164169

170+
/**
171+
* 최신순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다.
172+
*/
173+
public static Specification<CourseSession> orderByLatest() {
174+
return (root, query, cb) -> {
175+
if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) {
176+
query.orderBy(cb.desc(root.get("id")));
177+
}
178+
return cb.conjunction();
179+
};
180+
}
181+
182+
/**
183+
* 만족도순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다.
184+
*/
185+
public static Specification<CourseSession> orderBySatisfaction() {
186+
return (root, query, cb) -> {
187+
if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) {
188+
var courseJoin = root.join("course", JoinType.LEFT);
189+
query.orderBy(
190+
cb.desc(courseJoin.get("stdgScor")),
191+
cb.desc(root.get("id"))
192+
);
193+
}
194+
return cb.conjunction();
195+
};
196+
}
197+
198+
/**
199+
* 취업률순 Criteria 정렬 — prioritizeFull과 함께 사용할 때 Pageable Sort 대신 적용합니다.
200+
*/
201+
public static Specification<CourseSession> orderByEmploymentRate() {
202+
return (root, query, cb) -> {
203+
if (query != null && Long.class != query.getResultType() && long.class != query.getResultType()) {
204+
query.orderBy(
205+
cb.desc(root.get("employmentRate")),
206+
cb.desc(root.get("id"))
207+
);
208+
}
209+
return cb.conjunction();
210+
};
211+
}
212+
213+
/**
214+
* 기관 이미지·만족도·취업률·별점이 모두 있는 과정을 기존 정렬 기준 내에서 우선 노출합니다.
215+
* 기존 ORDER BY 앞에 완전성 우선순위(0=완전, 1=불완전)를 삽입합니다.
216+
* 반드시 기본 정렬 Specification을 체이닝한 뒤 마지막에 추가해야 합니다.
217+
*/
218+
public static Specification<CourseSession> orderByCompleteDataFirst() {
219+
return (root, query, cb) -> {
220+
if (query == null || Long.class == query.getResultType() || long.class == query.getResultType()) {
221+
return cb.conjunction();
222+
}
223+
224+
var courseJoin = root.join("course", JoinType.LEFT);
225+
var institutionJoin = courseJoin.join("institution", JoinType.LEFT);
226+
227+
// 사용자 리뷰 존재 여부 서브쿼리
228+
Subquery<Long> reviewCount = query.subquery(Long.class);
229+
Root<Review> review = reviewCount.from(Review.class);
230+
reviewCount.select(cb.count(review.get("id")));
231+
reviewCount.where(
232+
cb.and(
233+
cb.equal(review.get("course").get("id"), courseJoin.get("id")),
234+
cb.isNull(review.get("deletedAt"))
235+
)
236+
);
237+
238+
// 크롤링 리뷰 존재 여부 서브쿼리
239+
Subquery<Long> crawledReviewCount = query.subquery(Long.class);
240+
Root<CrawledReview> crawled = crawledReviewCount.from(CrawledReview.class);
241+
crawledReviewCount.select(cb.count(crawled.get("id")));
242+
crawledReviewCount.where(
243+
cb.and(
244+
cb.equal(crawled.get("course").get("id"), courseJoin.get("id")),
245+
cb.isNotNull(crawled.get("rating"))
246+
)
247+
);
248+
249+
// 4가지 조건 모두 충족 시 우선순위 0, 아닐 경우 1
250+
var completePriority = cb.selectCase()
251+
.when(
252+
cb.and(
253+
cb.isNotNull(institutionJoin.get("profileImageUrl")),
254+
cb.isNotNull(courseJoin.get("stdgScor")),
255+
cb.isNotNull(root.get("employmentRate")),
256+
cb.or(
257+
cb.greaterThan(reviewCount, 0L),
258+
cb.greaterThan(crawledReviewCount, 0L)
259+
)
260+
),
261+
0
262+
)
263+
.otherwise(1);
264+
265+
List<Order> orders = new ArrayList<>();
266+
orders.add(cb.asc(completePriority));
267+
orders.addAll(query.getOrderList());
268+
query.orderBy(orders);
269+
270+
return cb.conjunction();
271+
};
272+
}
273+
165274
/**
166275
* 모집 마감 임박순 정렬입니다.
167276
* 시작 예정 과정은 시작일 오름차순으로 먼저 노출하고, 이미 시작했거나 시작일이 없는 과정은 뒤로 보냅니다.

0 commit comments

Comments
 (0)