diff --git a/src/main/java/com/devpick/domain/point/service/BadgeService.java b/src/main/java/com/devpick/domain/point/service/BadgeService.java index 84bdc55..4877db8 100644 --- a/src/main/java/com/devpick/domain/point/service/BadgeService.java +++ b/src/main/java/com/devpick/domain/point/service/BadgeService.java @@ -44,12 +44,12 @@ public class BadgeService { * 이미 획득한 배지는 중복 발급하지 않는다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) - public void checkAndUnlock(User user) { + public void checkAndUnlock(User user, PointAction justEarned) { checkFirstScrap(user); checkFirstQuestion(user); checkAnswerMaster(user); checkPointBadges(user); - checkStreak7(user); + checkStreak7(user, justEarned); } @Transactional(readOnly = true) @@ -103,8 +103,8 @@ private void checkPointBadges(User user) { unlockIfConditionMet(user, "POINT_1000", total >= 1000); } - private void checkStreak7(User user) { - unlockIfConditionMet(user, "STREAK_7", calculateStreak(user.getId()) >= 7); + private void checkStreak7(User user, PointAction justEarned) { + unlockIfConditionMet(user, "STREAK_7", calculateStreak(user.getId(), justEarned) >= 7); } private void unlockIfConditionMet(User user, String badgeId, boolean condition) { @@ -122,17 +122,24 @@ private void unlock(User user, String badgeId) { .build()); } - private int calculateStreak(UUID userId) { - List loginDates = pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId) + private int calculateStreak(UUID userId, PointAction justEarned) { + Set dateSet = pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId) .stream() .map(log -> log.getEarnedAt().atZone(ZoneId.systemDefault()).withZoneSameInstant(KST).toLocalDate()) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); + + // REQUIRES_NEW 트랜잭션은 외부 트랜잭션의 미커밋 PointLog를 볼 수 없으므로 + // DAILY_LOGIN을 막 적립한 경우 오늘 날짜를 직접 포함시킨다. + LocalDate today = ZonedDateTime.now(KST).toLocalDate(); + if (justEarned == PointAction.DAILY_LOGIN) { + dateSet = new java.util.HashSet<>(dateSet); + dateSet.add(today); + } - if (loginDates.isEmpty()) return 0; + if (dateSet.isEmpty()) return 0; - Set dateSet = Set.copyOf(loginDates); - LocalDate current = ZonedDateTime.now(KST).toLocalDate(); int streak = 0; + LocalDate current = today; while (dateSet.contains(current)) { streak++; current = current.minusDays(1); diff --git a/src/main/java/com/devpick/domain/point/service/PointService.java b/src/main/java/com/devpick/domain/point/service/PointService.java index db4a26b..7e43867 100644 --- a/src/main/java/com/devpick/domain/point/service/PointService.java +++ b/src/main/java/com/devpick/domain/point/service/PointService.java @@ -59,8 +59,9 @@ public boolean earn(User user, PointAction action, UUID referenceId) { .referenceId(referenceId) .build()); user.addPoints(action.getPoints()); + userRepository.save(user); try { - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, action); } catch (Exception e) { log.warn("배지 잠금 해제 중 오류 발생 (포인트 적립은 정상 처리됨): userId={}, action={}, error={}", user.getId(), action, e.getMessage()); diff --git a/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java b/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java index 426e62f..2432bac 100644 --- a/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java +++ b/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java @@ -4,6 +4,7 @@ import com.devpick.domain.point.dto.RepresentativeBadgeDto; import com.devpick.domain.point.entity.Badge; import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.entity.PointLog; import com.devpick.domain.point.entity.UserBadge; import com.devpick.domain.point.repository.BadgeRepository; import com.devpick.domain.point.repository.PointLogRepository; @@ -75,7 +76,7 @@ void checkAndUnlock_firstScrap_conditionMet_unlocks() { given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository).save(any(UserBadge.class)); } @@ -89,7 +90,7 @@ void checkAndUnlock_firstScrap_alreadyAcquired_skips() { given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository, never()).save(any()); } @@ -107,7 +108,7 @@ void checkAndUnlock_firstQuestion_conditionMet_unlocks() { given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository).save(any(UserBadge.class)); } @@ -125,7 +126,7 @@ void checkAndUnlock_answerMaster_5Adoptions_unlocks() { given(badgeRepository.findById("ANSWER_MASTER")).willReturn(Optional.of(badge)); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository).save(any(UserBadge.class)); } @@ -138,7 +139,7 @@ void checkAndUnlock_answerMaster_4Adoptions_doesNotUnlock() { given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(4L); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository, never()).save(any()); } @@ -157,7 +158,7 @@ void checkAndUnlock_point100_unlocksWhenOver100() { given(badgeRepository.findById("POINT_100")).willReturn(Optional.of(badge)); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); verify(userBadgeRepository).save(any(UserBadge.class)); } @@ -171,7 +172,51 @@ void checkAndUnlock_point99_doesNotUnlock() { given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); - badgeService.checkAndUnlock(user); + badgeService.checkAndUnlock(user, PointAction.CONTENT_SCRAP); + + verify(userBadgeRepository, never()).save(any()); + } + + // ── checkAndUnlock — STREAK_7 ────────────────────────── + + @Test + @DisplayName("checkAndUnlock — DAILY_LOGIN 적립 시 오늘 포함 7일 연속이면 STREAK_7 배지 잠금 해제") + void checkAndUnlock_streak7_dailyLogin_sevenConsecutiveDays_unlocks() { + Badge badge = Badge.builder().id("STREAK_7").name("7일 연속 로그인").sortOrder(5).build(); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.CONTENT_SCRAP)).willReturn(false); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.QUESTION_WRITE)).willReturn(false); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); + given(userBadgeRepository.existsByUser_IdAndBadge_Id(userId, "STREAK_7")).willReturn(false); + given(badgeRepository.findById("STREAK_7")).willReturn(Optional.of(badge)); + // 어제~6일 전 로그인 기록 — 오늘은 DAILY_LOGIN justEarned로 추가됨 → 7일 연속 + given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of( + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(1)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(2)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(3)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(4)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(5)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(6)).build() + )); + + badgeService.checkAndUnlock(user, PointAction.DAILY_LOGIN); + + verify(userBadgeRepository).save(any(UserBadge.class)); + } + + @Test + @DisplayName("checkAndUnlock — DAILY_LOGIN 적립 시 연속 일수 부족하면 STREAK_7 배지 잠금 해제 안 함") + void checkAndUnlock_streak7_dailyLogin_notEnoughDays_doesNotUnlock() { + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.CONTENT_SCRAP)).willReturn(false); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.QUESTION_WRITE)).willReturn(false); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); + // 3일치만 있음 — 오늘 포함 최대 4일 연속 → 7일 미달 + given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of( + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(1)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(2)).build(), + PointLog.builder().user(user).action(PointAction.DAILY_LOGIN).points(5).earnedAt(LocalDateTime.now().minusDays(3)).build() + )); + + badgeService.checkAndUnlock(user, PointAction.DAILY_LOGIN); verify(userBadgeRepository, never()).save(any()); } diff --git a/src/test/java/com/devpick/domain/point/service/PointServiceTest.java b/src/test/java/com/devpick/domain/point/service/PointServiceTest.java index 42fe3be..9104ec6 100644 --- a/src/test/java/com/devpick/domain/point/service/PointServiceTest.java +++ b/src/test/java/com/devpick/domain/point/service/PointServiceTest.java @@ -150,7 +150,7 @@ void earn_badgeCheckFails_stillSavesPoints() { eq(userId), eq(PointAction.DAILY_LOGIN), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); org.mockito.Mockito.doThrow(new RuntimeException("badge error")) - .when(badgeService).checkAndUnlock(user); + .when(badgeService).checkAndUnlock(user, PointAction.DAILY_LOGIN); pointService.earn(user, PointAction.DAILY_LOGIN);