Skip to content

Commit e4e1b01

Browse files
Merge pull request #272 from DevKor-github/develop
#271 반영해 운영 배포
2 parents 03944d0 + 165dc6e commit e4e1b01

14 files changed

Lines changed: 366 additions & 31 deletions

src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandService.java

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionLike;
77
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionLikeRepository;
88
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository;
9-
import org.devkor.apu.saerok_server.domain.community.core.entity.PopularCollection;
10-
import org.devkor.apu.saerok_server.domain.community.core.repository.PopularCollectionRepository;
119
import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind;
1210
import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor;
1311
import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl;
@@ -24,12 +22,9 @@
2422
@RequiredArgsConstructor
2523
public class CollectionLikeCommandService {
2624

27-
private static final int POPULAR_COLLECTION_THRESHOLD = 5;
28-
2925
private final CollectionLikeRepository collectionLikeRepository;
3026
private final CollectionRepository collectionRepository;
3127
private final UserRepository userRepository;
32-
private final PopularCollectionRepository popularCollectionRepository;
3328
private final NotifyActionDsl notifyAction;
3429

3530
/**
@@ -55,17 +50,7 @@ public LikeStatusResponse toggleLikeResponse(Long userId, Long collectionId) {
5550
// 좋아요가 없으면 추가
5651
UserBirdCollectionLike like = new UserBirdCollectionLike(user, collection);
5752
collectionLikeRepository.save(like);
58-
59-
// 좋아요 수 확인 후 인기 컬렉션 등록
60-
long likeCount = collectionLikeRepository.countByCollectionId(collectionId);
61-
if (likeCount == POPULAR_COLLECTION_THRESHOLD) {
62-
boolean alreadyPopular = popularCollectionRepository.existsByCollectionId(collectionId);
63-
if (!alreadyPopular) {
64-
PopularCollection popularCollection = new PopularCollection(collection);
65-
popularCollectionRepository.save(popularCollection);
66-
}
67-
}
68-
53+
6954
// 자신의 컬렉션이 아닌 경우에만 푸시 알림 발송
7055
if (!collection.getUser().getId().equals(userId)) {
7156
notifyAction

src/main/java/org/devkor/apu/saerok_server/domain/community/api/CommunityController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.devkor.apu.saerok_server.domain.community.api;
22

3+
import io.swagger.v3.oas.annotations.Hidden;
34
import io.swagger.v3.oas.annotations.Operation;
45
import io.swagger.v3.oas.annotations.Parameter;
56
import io.swagger.v3.oas.annotations.media.Content;
@@ -13,6 +14,7 @@
1314
import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunitySearchResponse;
1415
import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunitySearchUsersResponse;
1516
import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService;
17+
import org.devkor.apu.saerok_server.domain.community.application.PopularCollectionBatchService;
1618
import org.devkor.apu.saerok_server.domain.community.application.dto.CommunityQueryCommand;
1719
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
1820
import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException;
@@ -27,6 +29,7 @@
2729
public class CommunityController {
2830

2931
private final CommunityQueryService communityQueryService;
32+
private final PopularCollectionBatchService popularCollectionBatchService;
3033

3134
// 1) 메인 화면
3235
@GetMapping("/main")
@@ -121,6 +124,15 @@ public GetCommunityCollectionsResponse getPopularCollections(
121124
return communityQueryService.getPopularCollections(userId, command);
122125
}
123126

127+
// TODO: 인기글 업데이트 임시 테스트용. 추후 삭제 예정
128+
@PostMapping("/popular")
129+
@PermitAll
130+
@Hidden
131+
public void refreshPopularCollections(
132+
) {
133+
popularCollectionBatchService.refreshPopularCollections();
134+
}
135+
124136
// 4) 이 새 이름이 뭔가요?
125137
@GetMapping("/pending-bird-id")
126138
@PermitAll
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.devkor.apu.saerok_server.domain.community.application;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
@Slf4j
9+
@Component
10+
@RequiredArgsConstructor
11+
public class PopularCollectionBatchScheduler {
12+
13+
private final PopularCollectionBatchService popularCollectionBatchService;
14+
15+
@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
16+
public void refreshPopularCollections() {
17+
try {
18+
popularCollectionBatchService.refreshPopularCollections();
19+
} catch (Exception e) {
20+
log.error("[popular] failed to refresh popular collections", e);
21+
}
22+
}
23+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package org.devkor.apu.saerok_server.domain.community.application;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
6+
import org.devkor.apu.saerok_server.domain.community.core.entity.PopularCollection;
7+
import org.devkor.apu.saerok_server.domain.community.core.repository.PopularCollectionRepository;
8+
import org.devkor.apu.saerok_server.domain.community.core.repository.TrendingCollectionRepository;
9+
import org.devkor.apu.saerok_server.domain.community.core.repository.dto.TrendingCollectionCandidate;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.time.Duration;
14+
import java.time.OffsetDateTime;
15+
import java.time.ZoneId;
16+
import java.util.*;
17+
import java.util.stream.IntStream;
18+
19+
@Slf4j
20+
@Service
21+
@Transactional
22+
@RequiredArgsConstructor
23+
public class PopularCollectionBatchService {
24+
25+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
26+
27+
private static final double POPULARITY_LIKE_WEIGHT = 1.0;
28+
private static final double POPULARITY_COMMENT_WEIGHT = 1.2;
29+
private static final double FRESHNESS_LIKE_RATIO = 0.7;
30+
private static final double FRESHNESS_COMMENT_RATIO = 0.3;
31+
private static final double LAST_ACTIVITY_WINDOW_DAYS = 7.0;
32+
private static final double HALF_LIFE_DAYS = 3.0;
33+
private static final int CANDIDATE_DAYS = 120;
34+
private static final int POPULAR_LIMIT = 5;
35+
36+
private final PopularCollectionRepository popularCollectionRepository;
37+
private final TrendingCollectionRepository trendingCollectionRepository;
38+
private final jakarta.persistence.EntityManager em;
39+
40+
public void refreshPopularCollections() {
41+
OffsetDateTime now = OffsetDateTime.now(KST);
42+
log.info("[popular] refreshing popular collections at {}", now);
43+
44+
List<TrendingCollectionCandidate> candidates = trendingCollectionRepository
45+
.findRecentPublicCollections(now.minusDays(CANDIDATE_DAYS));
46+
if (candidates.isEmpty()) {
47+
popularCollectionRepository.deleteAll();
48+
log.info("[popular] no candidates found; table truncated");
49+
return;
50+
}
51+
52+
List<Long> candidateIds = candidates.stream()
53+
.map(TrendingCollectionCandidate::collectionId)
54+
.toList();
55+
56+
Map<Long, List<OffsetDateTime>> likeCreatedAts = trendingCollectionRepository
57+
.findLikeCreatedAtByCollectionIds(candidateIds);
58+
Map<Long, Map<Long, OffsetDateTime>> lastCommentAtByUser = trendingCollectionRepository
59+
.findLastCommentAtByCollectionIds(candidateIds);
60+
61+
List<PopularCandidateSnapshot> ranked = candidates.stream()
62+
.map(candidate -> buildSnapshot(candidate, now, likeCreatedAts, lastCommentAtByUser))
63+
.filter(Objects::nonNull)
64+
.sorted(Comparator.comparingDouble(PopularCandidateSnapshot::trendingScore).reversed()
65+
.thenComparing(snapshot -> snapshot.collection().getId()))
66+
.limit(POPULAR_LIMIT)
67+
.toList();
68+
69+
List<PopularCandidateSnapshot> shuffled = new ArrayList<>(ranked);
70+
Collections.shuffle(shuffled);
71+
72+
List<PopularCollection> rankedAndShuffled = IntStream.range(0, shuffled.size())
73+
.mapToObj(idx -> toPopularCollection(shuffled.get(idx), now, idx))
74+
.toList();
75+
76+
popularCollectionRepository.deleteAll();
77+
popularCollectionRepository.saveAll(rankedAndShuffled);
78+
79+
log.info("[popular] refreshed {} rows", rankedAndShuffled.size());
80+
}
81+
82+
private PopularCandidateSnapshot buildSnapshot(
83+
TrendingCollectionCandidate candidate,
84+
OffsetDateTime now,
85+
Map<Long, List<OffsetDateTime>> likeCreatedAts,
86+
Map<Long, Map<Long, OffsetDateTime>> lastCommentAtByUser
87+
) {
88+
List<OffsetDateTime> likes = likeCreatedAts.getOrDefault(candidate.collectionId(), List.of());
89+
Map<Long, OffsetDateTime> commentsByUser = lastCommentAtByUser.getOrDefault(candidate.collectionId(), Map.of());
90+
91+
long likeCount = likes.size();
92+
long commentUserCount = commentsByUser.size();
93+
if (likeCount == 0 && commentUserCount == 0) {
94+
return null;
95+
}
96+
97+
double popularityScore = POPULARITY_LIKE_WEIGHT * Math.log(1 + likeCount)
98+
+ POPULARITY_COMMENT_WEIGHT * Math.log(1 + commentUserCount);
99+
100+
double decayedLikes = likes.stream()
101+
.mapToDouble(ts -> decay(ts, now))
102+
.sum();
103+
double decayedCommentUsers = commentsByUser.values().stream()
104+
.mapToDouble(ts -> decay(ts, now))
105+
.sum();
106+
107+
double freshnessCore = FRESHNESS_LIKE_RATIO * decayedLikes
108+
+ FRESHNESS_COMMENT_RATIO * decayedCommentUsers;
109+
110+
OffsetDateTime lastCommentAt = commentsByUser.values().stream()
111+
.max(Comparator.naturalOrder())
112+
.orElse(null);
113+
double lastActivityBonus = 0.0;
114+
if (lastCommentAt != null) {
115+
double ageDays = daysBetween(lastCommentAt, now);
116+
lastActivityBonus = Math.max(0.0, 1 - ageDays / LAST_ACTIVITY_WINDOW_DAYS);
117+
}
118+
119+
double freshnessScore = freshnessCore * (0.7 + 0.3 * lastActivityBonus);
120+
double trendingScore = popularityScore * freshnessScore;
121+
if (trendingScore <= 0) {
122+
return null;
123+
}
124+
125+
UserBirdCollection ref = em.getReference(UserBirdCollection.class, candidate.collectionId());
126+
return new PopularCandidateSnapshot(ref, popularityScore, freshnessScore, trendingScore);
127+
}
128+
129+
private double decay(OffsetDateTime eventTime, OffsetDateTime now) {
130+
double ageDays = daysBetween(eventTime, now);
131+
return Math.pow(0.5, ageDays / HALF_LIFE_DAYS);
132+
}
133+
134+
private double daysBetween(OffsetDateTime earlier, OffsetDateTime later) {
135+
double seconds = Duration.between(earlier, later).getSeconds();
136+
double days = seconds / 86_400d;
137+
return Math.max(0d, days);
138+
}
139+
140+
private PopularCollection toPopularCollection(PopularCandidateSnapshot snapshot, OffsetDateTime calculatedAt, int displayOrder) {
141+
return new PopularCollection(
142+
snapshot.collection(),
143+
snapshot.popularityScore(),
144+
snapshot.freshnessScore(),
145+
snapshot.trendingScore(),
146+
calculatedAt,
147+
displayOrder
148+
);
149+
}
150+
151+
private record PopularCandidateSnapshot(
152+
UserBirdCollection collection,
153+
double popularityScore,
154+
double freshnessScore,
155+
double trendingScore
156+
) {
157+
}
158+
}

src/main/java/org/devkor/apu/saerok_server/domain/community/core/entity/PopularCollection.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
88
import org.devkor.apu.saerok_server.global.shared.entity.CreatedAtOnly;
99

10+
import java.time.OffsetDateTime;
11+
1012
@Entity
1113
@Table(
1214
uniqueConstraints = @UniqueConstraint(columnNames = {"user_bird_collection_id"})
@@ -23,7 +25,34 @@ public class PopularCollection extends CreatedAtOnly {
2325
@JoinColumn(name = "user_bird_collection_id", nullable = false)
2426
private UserBirdCollection collection;
2527

26-
public PopularCollection(UserBirdCollection collection) {
28+
@Column(name = "popularity_score", nullable = false)
29+
private double popularityScore;
30+
31+
@Column(name = "freshness_score", nullable = false)
32+
private double freshnessScore;
33+
34+
@Column(name = "trending_score", nullable = false)
35+
private double trendingScore;
36+
37+
@Column(name = "calculated_at", nullable = false)
38+
private OffsetDateTime calculatedAt;
39+
40+
@Column(name = "display_order", nullable = false)
41+
private int displayOrder;
42+
43+
public PopularCollection(
44+
UserBirdCollection collection,
45+
double popularityScore,
46+
double freshnessScore,
47+
double trendingScore,
48+
OffsetDateTime calculatedAt,
49+
int displayOrder
50+
) {
2751
this.collection = collection;
52+
this.popularityScore = popularityScore;
53+
this.freshnessScore = freshnessScore;
54+
this.trendingScore = trendingScore;
55+
this.calculatedAt = calculatedAt;
56+
this.displayOrder = displayOrder;
2857
}
2958
}

src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public List<UserBirdCollection> findPopularCollections(CommunityQueryCommand com
4040
LEFT JOIN FETCH c.bird b
4141
JOIN PopularCollection pc ON pc.collection.id = c.id
4242
WHERE c.accessLevel = :public
43-
ORDER BY pc.createdAt DESC
43+
ORDER BY pc.displayOrder ASC
4444
""", UserBirdCollection.class)
4545
.setParameter("public", AccessLevelType.PUBLIC);
4646

src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/PopularCollectionRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ public void save(PopularCollection popularCollection) {
1919
em.persist(popularCollection);
2020
}
2121

22+
public void saveAll(Iterable<PopularCollection> popularCollections) {
23+
popularCollections.forEach(em::persist);
24+
}
25+
26+
public void deleteAll() {
27+
em.createQuery("DELETE FROM PopularCollection").executeUpdate();
28+
}
29+
2230
public boolean existsByCollectionId(Long collectionId) {
2331
return !em.createQuery(
2432
"SELECT 1 FROM PopularCollection pc " +

0 commit comments

Comments
 (0)