From 976621fcbfe8cb8cea55f064712033a4badfb243 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Sun, 12 Apr 2026 21:56:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?DP-311:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EB=AF=B8=EB=B0=98=EC=98=81=20=EB=B0=8F=20?= =?UTF-8?q?STREAK=5F7=20=EB=B0=B0=EC=A7=80=20unlock=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - earn()에 userRepository.save(user) 추가: detached/proxy 엔티티 상태와 무관하게 totalPoints를 DB에 즉시 반영 - checkAndUnlock()에 justEarned 파라미터 추가: REQUIRES_NEW 트랜잭션에서 미커밋 DAILY_LOGIN 로그를 볼 수 없는 문제 해결 - calculateStreak()에서 DAILY_LOGIN 적립 시 오늘 날짜를 직접 포함해 STREAK_7 배지 즉시 unlock --- .../domain/point/service/BadgeService.java | 27 ++++++++++++------- .../domain/point/service/PointService.java | 3 ++- .../point/service/BadgeServiceTest.java | 14 +++++----- .../point/service/PointServiceTest.java | 2 +- 4 files changed, 27 insertions(+), 19 deletions(-) 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..40b40b0 100644 --- a/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java +++ b/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java @@ -75,7 +75,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 +89,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 +107,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 +125,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 +138,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 +157,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 +171,7 @@ 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()); } 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); From 1fd07baf4282568666f841d752be8d4fb7c2fdb3 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Sun, 12 Apr 2026 22:04:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?DP-311:=20STREAK=5F7=20=EB=B0=B0=EC=A7=80?= =?UTF-8?q?=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?(SonarCloud=2080%=20=EC=B6=A9=EC=A1=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DAILY_LOGIN 7일 연속 시 STREAK_7 unlock 테스트 추가 - DAILY_LOGIN 연속 일수 부족 시 unlock 안 함 테스트 추가 --- .../point/service/BadgeServiceTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 40b40b0..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; @@ -176,6 +177,50 @@ void checkAndUnlock_point99_doesNotUnlock() { 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()); + } + // ── getBadges ────────────────────────────────────────────────── @Test