ReadingProgressHistoryService.java
package qwerty.chaekit.service.statistics;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import qwerty.chaekit.domain.ebook.history.ReadingProgressHistory;
import qwerty.chaekit.domain.ebook.history.ReadingProgressHistoryRepository;
import qwerty.chaekit.domain.ebook.purchase.EbookPurchase;
import qwerty.chaekit.domain.ebook.purchase.repository.EbookPurchaseRepository;
import qwerty.chaekit.domain.group.activity.Activity;
import qwerty.chaekit.domain.group.activity.activitymember.ActivityMember;
import qwerty.chaekit.domain.group.activity.activitymember.ActivityMemberRepository;
import qwerty.chaekit.domain.group.activity.repository.ActivityRepository;
import qwerty.chaekit.domain.member.user.UserProfile;
import qwerty.chaekit.dto.statistics.ReadingProgressHistoryResponse;
import qwerty.chaekit.global.enums.ErrorCode;
import qwerty.chaekit.global.exception.ForbiddenException;
import qwerty.chaekit.global.security.resolver.UserToken;
import qwerty.chaekit.service.group.ActivityPolicy;
import qwerty.chaekit.service.util.EntityFinder;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional
public class ReadingProgressHistoryService {
private final ActivityRepository activityRepository;
private final ActivityMemberRepository activityMemberRepository;
private final EbookPurchaseRepository ebookPurchaseRepository;
private final ReadingProgressHistoryRepository historyRepository;
private final ActivityPolicy activityPolicy;
private final EntityFinder entityFinder;
@Scheduled(cron = "0 0 0 * * *")
public void snapshotDailyProgress() {
LocalDate yesterday = LocalDate.now().minusDays(1);
List<Activity> activities = activityRepository
.findByStartTimeLessThanEqualAndEndTimeGreaterThanEqual(yesterday, yesterday);
for (Activity activity : activities) {
List<ActivityMember> members = activityMemberRepository.findByActivity(activity);
for (ActivityMember member : members) {
Optional<EbookPurchase> purchaseOpt = ebookPurchaseRepository
.findByUserAndEbook(member.getUser(), activity.getBook());
long percentage = purchaseOpt.map(EbookPurchase::getPercentage).orElse(0L);
historyRepository.save(ReadingProgressHistory.builder()
.activity(activity)
.user(member.getUser())
.percentage(percentage)
.build());
}
}
}
@Transactional(readOnly = true)
public List<ReadingProgressHistoryResponse> getHistory(UserToken token, Long activityId) {
UserProfile user = entityFinder.findUser(token.userId());
Activity activity = entityFinder.findActivity(activityId);
activityPolicy.assertJoined(user, activity);
ActivityMember membership = activityMemberRepository
.findByUserAndActivity(user, activity)
.orElseThrow(() -> new ForbiddenException(ErrorCode.ACTIVITY_MEMBER_ONLY));
LocalDate joinDate = membership.getCreatedAt().toLocalDate();
LocalDate start = activity.getStartTime();
LocalDate end = activity.getEndTime();
List<LocalDate> days = start.datesUntil(end.plusDays(1)).toList();
List<ReadingProgressHistory> histories = historyRepository
.findByActivityAndCreatedAtBetween(activity,
start.atStartOfDay(), end.plusDays(1).atStartOfDay());
// 사용자별로 그룹화 후, 날짜별로 정렬
Map<Long, List<ReadingProgressHistory>> historiesByUser = histories.stream()
.collect(Collectors.groupingBy(h -> h.getUser().getId()));
// 현재 실시간 진행률
List<ActivityMember> activityMembers = activityMemberRepository.findByActivity(activity);
List<Long> userIdList = activityMembers.stream()
.map(activityMember -> activityMember.getUser().getId()).toList();
Map<Long, Long> currentPercentageByUser = ebookPurchaseRepository.findByUserIdInAndEbook(userIdList, activity.getBook())
.stream().collect(
Collectors.toMap(
ep -> ep.getUser().getId(),
EbookPurchase::getPercentage
)
);
// 사용자별 보정된 진행률 시계열 생성 (날짜별 최대 진행률 유지)
Map<Long, Map<LocalDate, Long>> fixedProgressByUser = new HashMap<>();
for (Long userId : userIdList) {
List<ReadingProgressHistory> userHistories = historiesByUser.getOrDefault(userId, Collections.emptyList());
Map<LocalDate, Long> rawByDate = userHistories.stream()
.collect(Collectors.toMap(
h -> h.getCreatedAt().toLocalDate(),
ReadingProgressHistory::getPercentage,
Math::max
));
Map<LocalDate, Long> progressMap = new LinkedHashMap<>();
long maxSoFar = 0L;
for (LocalDate day : days) {
long p;
if (day.equals(LocalDate.now())) {
p = currentPercentageByUser.getOrDefault(userId, 0L);
} else {
p = rawByDate.getOrDefault(day, 0L);
}
maxSoFar = Math.max(maxSoFar, p);
progressMap.put(day, maxSoFar);
}
fixedProgressByUser.put(userId, progressMap);
}
// 날짜별 평균 계산 + 내 진행률 보정
List<ReadingProgressHistoryResponse> responses = new ArrayList<>();
long myMax = 0L;
for (LocalDate day : days) {
List<Long> progresses = new ArrayList<>();
long myProgress = 0L;
for (Map.Entry<Long, Map<LocalDate, Long>> entry : fixedProgressByUser.entrySet()) {
Long userId = entry.getKey();
Long p = entry.getValue().getOrDefault(day, 0L);
progresses.add(p);
if (userId.equals(user.getId())) {
myProgress = day.isBefore(joinDate) ? 0L : p;
}
}
myMax = Math.max(myMax, myProgress);
double avg = progresses.stream().mapToLong(Long::longValue).average().orElse(0.0);
responses.add(ReadingProgressHistoryResponse.builder()
.date(day)
.myPercentage(myMax)
.averagePercentage(avg)
.build());
}
return responses;
}
}