Skip to content

Commit 226739d

Browse files
authored
Merge pull request #84 from NOVA-9th/feat/#40-feed-relevance-sort
[FEAT] 피드 조회 API 관련도순 정렬 구현
2 parents af678ed + a7df564 commit 226739d

8 files changed

Lines changed: 245 additions & 53 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.nova.nova_server.domain.cardNews.dto;
2+
3+
import com.querydsl.core.annotations.QueryProjection;
4+
5+
/**
6+
* CardNewsRepositoryImpl 내부적으로 사용하는 DTO
7+
*/
8+
public record CardNewsIdScoreResult(
9+
Long cardNewsId,
10+
Integer score
11+
) {
12+
@QueryProjection
13+
public CardNewsIdScoreResult {
14+
}
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.nova.nova_server.domain.cardNews.dto;
2+
3+
import com.nova.nova_server.domain.cardNews.entity.CardNews;
4+
import lombok.Builder;
5+
6+
@Builder
7+
public record CardNewsScoreResult(
8+
CardNews cardNews,
9+
Integer score
10+
) {
11+
}

src/main/java/com/nova/nova_server/domain/cardNews/repository/CardNewsRepositoryCustom.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.nova.nova_server.domain.cardNews.repository;
22

3+
import com.nova.nova_server.domain.cardNews.dto.CardNewsScoreResult;
34
import com.nova.nova_server.domain.cardNews.dto.CardNewsSearchCondition;
45
import com.nova.nova_server.domain.cardNews.entity.CardNews;
56
import org.springframework.data.domain.Page;
67

78
public interface CardNewsRepositoryCustom {
8-
Page<CardNews> searchByCondition(CardNewsSearchCondition condition);
9+
Page<CardNewsScoreResult> searchByCondition(CardNewsSearchCondition condition);
910

1011
Page<CardNews> searchBookmarked(
1112
Long memberId,

src/main/java/com/nova/nova_server/domain/cardNews/repository/CardNewsRepositoryImpl.java

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
package com.nova.nova_server.domain.cardNews.repository;
22

3+
import com.nova.nova_server.domain.cardNews.dto.CardNewsIdScoreResult;
4+
import com.nova.nova_server.domain.cardNews.dto.CardNewsScoreResult;
35
import com.nova.nova_server.domain.cardNews.dto.CardNewsSearchCondition;
6+
import com.nova.nova_server.domain.cardNews.dto.QCardNewsIdScoreResult;
47
import com.nova.nova_server.domain.cardNews.entity.CardNews;
58
import com.nova.nova_server.domain.cardNews.entity.CardType;
9+
import com.nova.nova_server.domain.feed.enums.FeedSort;
10+
import com.nova.nova_server.global.config.FeedConfig;
11+
import com.querydsl.core.types.OrderSpecifier;
612
import com.querydsl.core.types.dsl.BooleanExpression;
13+
import com.querydsl.core.types.dsl.Expressions;
14+
import com.querydsl.core.types.dsl.NumberExpression;
715
import com.querydsl.jpa.JPAExpressions;
816
import com.querydsl.jpa.impl.JPAQuery;
917
import com.querydsl.jpa.impl.JPAQueryFactory;
@@ -14,12 +22,16 @@
1422
import org.springframework.stereotype.Repository;
1523

1624
import java.time.LocalDateTime;
25+
import java.util.ArrayList;
1726
import java.util.List;
27+
import java.util.Map;
28+
import java.util.stream.Collectors;
1829

1930
import static com.nova.nova_server.domain.cardNews.entity.QCardNews.cardNews;
2031
import static com.nova.nova_server.domain.cardNews.entity.QCardNewsBookmark.cardNewsBookmark;
2132
import static com.nova.nova_server.domain.cardNews.entity.QCardNewsKeyword.cardNewsKeyword;
2233
import static com.nova.nova_server.domain.keyword.entity.QKeyword.keyword;
34+
import static com.nova.nova_server.domain.member.entity.QMemberPreferKeyword.memberPreferKeyword;
2335

2436
@Slf4j
2537
@Repository
@@ -28,12 +40,20 @@ public class CardNewsRepositoryImpl implements CardNewsRepositoryCustom {
2840

2941
private final JPAQueryFactory queryFactory;
3042

43+
private final FeedConfig feedConfig;
44+
3145
@Override
32-
public Page<CardNews> searchByCondition(CardNewsSearchCondition condition) {
46+
public Page<CardNewsScoreResult> searchByCondition(CardNewsSearchCondition condition) {
3347

34-
// 필터링 및 정렬 수행
35-
List<Long> idList = queryFactory
36-
.select(cardNews.id)
48+
// 검색 조건에 맞는 ID와 관련도 점수 조회
49+
List<CardNewsIdScoreResult> cardNewsIdScoreList = queryFactory
50+
.select(
51+
new QCardNewsIdScoreResult(
52+
cardNews.id,
53+
// 최신순 정렬 시에도 관련도 점수는 표시되어야 함
54+
calcTotalScore(condition.memberId())
55+
)
56+
)
3757
.from(cardNews)
3858
.where(
3959
cardTypeIn(condition.type()),
@@ -42,12 +62,12 @@ public Page<CardNews> searchByCondition(CardNewsSearchCondition condition) {
4262
publishedBefore(condition.endDate()),
4363
isSaved(condition.memberId(), condition.saved())
4464
)
45-
.orderBy(cardNews.publishedAt.desc()) // TODO: 관련도순 정렬 구현 예정
65+
.orderBy(getOrderSpecifiers(condition.memberId(), condition.sort()))
4666
.offset(condition.pageable().getOffset())
4767
.limit(condition.pageable().getPageSize())
4868
.fetch();
4969

50-
// 전체 개수 조회
70+
// 검색 조건에 맞는 전체 개수 조회
5171
JPAQuery<Long> countQuery = queryFactory
5272
.select(cardNews.count())
5373
.from(cardNews)
@@ -59,9 +79,9 @@ public Page<CardNews> searchByCondition(CardNewsSearchCondition condition) {
5979
isSaved(condition.memberId(), condition.saved())
6080
);
6181

62-
log.debug("Fetched CardNews ids {} by condition {}", idList, condition);
82+
log.debug("Fetched CardNews ids {} by condition {}", cardNewsIdScoreList, condition);
6383

64-
if (idList.isEmpty()) {
84+
if (cardNewsIdScoreList.isEmpty()) {
6585
return Page.empty(condition.pageable());
6686
}
6787

@@ -71,10 +91,21 @@ public Page<CardNews> searchByCondition(CardNewsSearchCondition condition) {
7191
.from(cardNews)
7292
.leftJoin(cardNews.keywords, cardNewsKeyword).fetchJoin()
7393
.leftJoin(cardNewsKeyword.keyword, keyword).fetchJoin()
74-
.where(cardIdIn(idList))
94+
.where(cardIdIn(cardNewsIdScoreList.stream().map(CardNewsIdScoreResult::cardNewsId).toList()))
7595
.fetch();
7696

77-
return PageableExecutionUtils.getPage(cardNewsList, condition.pageable(), countQuery::fetchOne);
97+
// 카드 뉴스별 점수 매핑
98+
Map<Long, CardNews> cardNewsMap = cardNewsList.stream()
99+
.collect(Collectors.toMap(CardNews::getId, cardNews -> cardNews));
100+
101+
List<CardNewsScoreResult> cardNewsScoreList = cardNewsIdScoreList.stream()
102+
.map(result -> CardNewsScoreResult.builder()
103+
.cardNews(cardNewsMap.get(result.cardNewsId()))
104+
.score(result.score())
105+
.build())
106+
.toList();
107+
108+
return PageableExecutionUtils.getPage(cardNewsScoreList, condition.pageable(), countQuery::fetchOne);
78109
}
79110

80111
private BooleanExpression cardIdIn(List<Long> idList) {
@@ -100,7 +131,7 @@ private BooleanExpression keywordIn(List<String> keywords) {
100131

101132
return cardNews.id.in(
102133
JPAExpressions
103-
.select(cardNewsKeyword.cardNewsId).distinct()
134+
.select(cardNewsKeyword.cardNews.id).distinct()
104135
.from(cardNewsKeyword)
105136
.join(cardNewsKeyword.keyword, keyword)
106137
.where(keyword.name.in(keywords))
@@ -130,9 +161,9 @@ private BooleanExpression isSaved(Long memberId, Boolean saved) {
130161

131162
return cardNews.id.in(
132163
JPAExpressions
133-
.select(cardNewsBookmark.cardNewsId)
164+
.select(cardNewsBookmark.cardNews.id)
134165
.from(cardNewsBookmark)
135-
.where(cardNewsBookmark.memberId.eq(memberId))
166+
.where(cardNewsBookmark.member.id.eq(memberId))
136167
);
137168
}
138169

@@ -183,4 +214,85 @@ private BooleanExpression containsKeyword(String keyword) {
183214
.or(cardNews.summary.contains(keyword));
184215
}
185216

217+
/**
218+
* 정렬 기준에 따른 OrderSpecifier 배열 반환
219+
*/
220+
private OrderSpecifier<?>[] getOrderSpecifiers(Long memberId, FeedSort sort) {
221+
if (sort == null) {
222+
sort = FeedSort.LATEST;
223+
}
224+
225+
List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();
226+
if (sort == FeedSort.RELEVANCE) {
227+
orderSpecifiers.add(calcTotalScore(memberId).desc());
228+
}
229+
orderSpecifiers.add(cardNews.publishedAt.desc());
230+
231+
return orderSpecifiers.toArray(new OrderSpecifier<?>[0]);
232+
}
233+
234+
/**
235+
* 최종 관련도 점수 계산
236+
*/
237+
private NumberExpression<Integer> calcTotalScore(Long memberId) {
238+
if (memberId == null) {
239+
return Expressions.asNumber(feedConfig.getBaseScore());
240+
}
241+
242+
return Expressions
243+
.asNumber(feedConfig.getBaseScore())
244+
.add(calcKeywordScore(memberId))
245+
.add(calcBookmarkScore(memberId));
246+
}
247+
248+
/**
249+
* 키워드 기반 관련도 점수 계산<br/>
250+
* 사용자의 관심 키워드와 컨텐츠 키워드가 일치하는 개수에 가중치를 곱하여 점수 부여
251+
*/
252+
private NumberExpression<Integer> calcKeywordScore(long memberId) {
253+
return Expressions.numberTemplate(
254+
Integer.class,
255+
"({0} * {1})",
256+
JPAExpressions
257+
.select(cardNewsKeyword.count())
258+
.from(cardNewsKeyword)
259+
.where(
260+
cardNewsKeyword.cardNews.id.eq(cardNews.id),
261+
cardNewsKeyword.keyword.id.in(
262+
JPAExpressions
263+
.select(memberPreferKeyword.keyword.id)
264+
.from(memberPreferKeyword)
265+
.where(memberPreferKeyword.member.id.eq(memberId))
266+
)
267+
),
268+
feedConfig.getKeywordMatchScore()
269+
);
270+
}
271+
272+
/**
273+
* 북마크 기반 관련도 점수 계산<br/>
274+
* 사용자가 북마크한 카드뉴스와 키워드가 일치하는 개수에 가중치를 곱하여 점수 부여
275+
*/
276+
private NumberExpression<Integer> calcBookmarkScore(long memberId) {
277+
return Expressions.numberTemplate(
278+
Integer.class,
279+
"({0} * {1})",
280+
JPAExpressions
281+
.select(cardNewsKeyword.count())
282+
.from(cardNewsKeyword)
283+
.where(
284+
cardNewsKeyword.cardNews.id.eq(cardNews.id),
285+
cardNewsKeyword.keyword.id.in(
286+
JPAExpressions
287+
.select(cardNewsKeyword.keyword.id)
288+
.from(cardNewsKeyword)
289+
.leftJoin(cardNewsBookmark)
290+
.on(cardNewsBookmark.cardNews.id.eq(cardNewsKeyword.cardNews.id))
291+
.where(cardNewsBookmark.member.id.eq(memberId))
292+
)
293+
),
294+
feedConfig.getBookmarkKeywordMatchScore()
295+
);
296+
}
297+
186298
}

src/main/java/com/nova/nova_server/domain/feed/converter/FeedConverter.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,15 @@ public CardNewsSearchCondition toCondition(FeedRequest request, Long memberId) {
3737
}
3838

3939
public FeedResponse toResponse(CardNews cardNews, boolean saved, boolean hidden) {
40+
return toResponse(cardNews, null, saved, hidden);
41+
}
42+
43+
public FeedResponse toResponse(CardNews cardNews, Integer score, boolean saved, boolean hidden) {
4044
return FeedResponse.builder()
4145
.id(cardNews.getId())
4246
.title(cardNews.getTitle())
4347
.cardType(cardNews.getCardType())
48+
.score(toPercentScore(score))
4449
.author(cardNews.getAuthor())
4550
.publishedAt(cardNews.getPublishedAt().atOffset(ZoneOffset.UTC))
4651
.summary(cardNews.getSummary())
@@ -55,6 +60,13 @@ public FeedResponse toResponse(CardNews cardNews, boolean saved, boolean hidden)
5560
.build();
5661
}
5762

63+
private Integer toPercentScore(Integer score) {
64+
if (score == null) {
65+
return null;
66+
}
67+
return score * 100 / feedConfig.getMaxScore();
68+
}
69+
5870
private List<String> splitEvidence(String evidence) {
5971
if (evidence == null || evidence.isEmpty()) {
6072
return List.of();

src/main/java/com/nova/nova_server/domain/feed/dto/FeedResponse.java

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,43 @@
1010
@Builder
1111
@Schema(description = "피드 단건")
1212
public record FeedResponse(
13-
@Schema(description = "카드뉴스 ID", example = "1") long id,
13+
@Schema(description = "카드뉴스 ID", example = "1")
14+
long id,
1415

15-
@Schema(description = "카드뉴스 제목", example = "Nova, 개발자를 위한 AI 트렌드 인사이트 플랫폼") String title,
16+
@Schema(description = "카드뉴스 제목", example = "Nova, 개발자를 위한 AI 트렌드 인사이트 플랫폼")
17+
String title,
1618

17-
@Schema(description = "카드뉴스 유형", example = "COMMUNITY") CardType cardType,
19+
@Schema(description = "카드뉴스 유형", example = "COMMUNITY")
20+
CardType cardType,
1821

19-
@Schema(description = "작성자", example = "nova team") String author,
22+
@Schema(description = "카드뉴스 관련도", example = "92")
23+
Integer score,
2024

21-
@Schema(description = "발행일시 (UTC 기준)", example = "2026-01-01T15:00:00Z") OffsetDateTime publishedAt,
25+
@Schema(description = "작성자", example = "nova team")
26+
String author,
2227

23-
@Schema(description = "요약 내용", example = "Nova는 개발자를 위한 AI 트렌드 인사이트 플랫폼으로, 최신 AI 기술과 트렌드를 한눈에 파악할 수 있는 서비스를 제공합니다.") String summary,
28+
@Schema(description = "발행일시 (UTC 기준)", example = "2026-01-01T15:00:00Z")
29+
OffsetDateTime publishedAt,
2430

25-
@Schema(description = "근거 목록", example = "[\"주기적으로 최신 글을 확인하고 카드뉴스를 생성합니다.\"]") List<String> evidence,
31+
@Schema(description = "요약 내용", example = "Nova는 개발자를 위한 AI 트렌드 인사이트 플랫폼으로, 최신 AI 기술과 트렌드를 한눈에 파악할 수 있는 서비스를 제공합니다.")
32+
String summary,
2633

27-
@Schema(description = "원본 URL", example = "https://github.com/NOVA-9th/nova-be") String originalUrl,
34+
@Schema(description = "근거 목록", example = "[\"주기적으로 최신 글을 확인하고 카드뉴스를 생성합니다.\"]")
35+
List<String> evidence,
2836

29-
@Schema(description = "사이트 이름", example = "Github") String siteName,
37+
@Schema(description = "원본 URL", example = "https://github.com/NOVA-9th/nova-be")
38+
String originalUrl,
3039

31-
@Schema(description = "키워드 목록", example = "[\"Spring Boot\", \"React\", \"Next.js\", \"Java\", \"TypeScript\", \"AI\"]") List<String> keywords,
40+
@Schema(description = "사이트 이름", example = "Github")
41+
String siteName,
3242

33-
@Schema(description = "저장 여부", example = "false") boolean saved,
43+
@Schema(description = "키워드 목록", example = "[\"Spring Boot\", \"React\", \"Next.js\", \"Java\", \"TypeScript\", \"AI\"]")
44+
List<String> keywords,
3445

35-
@Schema(description = "숨김 여부", example = "false") boolean hidden) {
46+
@Schema(description = "저장 여부", example = "false")
47+
boolean saved,
48+
49+
@Schema(description = "숨김 여부", example = "false")
50+
boolean hidden
51+
) {
3652
}

0 commit comments

Comments
 (0)