Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions src/main/java/com/devpick/domain/point/service/BadgeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -122,17 +122,24 @@ private void unlock(User user, String badgeId) {
.build());
}

private int calculateStreak(UUID userId) {
List<LocalDate> loginDates = pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)
private int calculateStreak(UUID userId, PointAction justEarned) {
Set<LocalDate> 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<LocalDate> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand All @@ -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());
}
Expand All @@ -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));
}
Expand All @@ -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));
}
Expand All @@ -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());
}
Expand All @@ -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));
}
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading