diff --git a/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java b/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java index 32f46a55..f2532280 100644 --- a/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java +++ b/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java @@ -36,7 +36,6 @@ public class AttendanceService { private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); - private static final int STREAK_BONUS_THRESHOLD = 7; private static final int STREAK_REWARD_POINTS = CreditType.ATTENDANCE_STREAK.getDefaultAmount(); private final AttendanceRecordRepository attendanceRecordRepository; @@ -54,7 +53,11 @@ public CheckResponse check() { throw new CustomException(ErrorCode.ATTENDANCE_ALREADY_CHECKED); } - int dailyPoints = CreditType.TODAY_CREDIT.getDefaultAmount(); + // 월~토 개근 후 맞는 일요일에는 5P 대신 7P를 지급(대체), 그 외에는 매일 5P + boolean perfectWeekBonus = isSundayWithFullWeekAttendance(user.getId(), today); + CreditType creditType = perfectWeekBonus ? CreditType.ATTENDANCE_STREAK : CreditType.TODAY_CREDIT; + int dailyPoints = creditType.getDefaultAmount(); + AttendanceRecord record = AttendanceRecord.builder() .user(user) .attendedDate(today) @@ -63,19 +66,19 @@ public CheckResponse check() { attendanceRecordRepository.save(record); Long referenceId = toDateLong(today); - creditService.addCredit(user.getId(), CreditType.TODAY_CREDIT, referenceId); + creditService.addCredit(user.getId(), creditType, referenceId); AttendanceStreak streak = getOrCreateStreak(user, today); - boolean streakBonusEarned = updateStreak(streak, today, user.getId()); + updateStreak(streak, today); - int streakBonusPoints = streakBonusEarned ? STREAK_REWARD_POINTS : 0; + int streakBonusPoints = perfectWeekBonus ? dailyPoints : 0; int totalPoints = creditService.getTotalPoints(user.getId()); return new CheckResponse( user.getUserTag(), LocalDateTime.now(SEOUL_ZONE), dailyPoints, - streakBonusEarned, + perfectWeekBonus, streakBonusPoints, streak.getCurrentStreak(), totalPoints @@ -174,10 +177,10 @@ private AttendanceStreak getOrCreateStreak(User user, LocalDate today) { }); } - private boolean updateStreak(AttendanceStreak streak, LocalDate today, Long userId) { + private void updateStreak(AttendanceStreak streak, LocalDate today) { LocalDate yesterday = today.minusDays(1); boolean continuedStreak = attendanceRecordRepository - .existsByUserIdAndAttendedDate(userId, yesterday); + .existsByUserIdAndAttendedDate(streak.getUserId(), yesterday); if (continuedStreak || streak.getCurrentStreak() == 0) { streak.attend(today); @@ -185,17 +188,17 @@ private boolean updateStreak(AttendanceStreak streak, LocalDate today, Long user LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); streak.resetStreak(weekStart); } + } - boolean justAchieved = streak.getCurrentStreak() >= STREAK_BONUS_THRESHOLD - && streak.isStreakAchieved() - && !creditService.existsHistory(userId, CreditType.ATTENDANCE_STREAK, toDateLong(streak.getStreakWeekStart()) + 7L); - - if (justAchieved) { - Long streakReferenceId = toDateLong(streak.getStreakWeekStart()) + 7L; - creditService.addCredit(userId, CreditType.ATTENDANCE_STREAK, streakReferenceId); + private boolean isSundayWithFullWeekAttendance(Long userId, LocalDate today) { + if (today.getDayOfWeek() != DayOfWeek.SUNDAY) { + return false; } - - return justAchieved; + LocalDate monday = today.minusDays(6); + LocalDate saturday = today.minusDays(1); + List monToSatRecords = + attendanceRecordRepository.findByUserIdAndAttendedDateBetween(userId, monday, saturday); + return monToSatRecords.size() == 6; } private Long toDateLong(LocalDate date) { diff --git a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java index ce1ebd63..61e576e2 100644 --- a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java @@ -69,9 +69,10 @@ public String processReward(AdMobRewardRequest request) { log.info("[AdMob] 보상 이력 저장 완료: historyId={}", history.getId()); // 6. 크레딧 적립 (history.getId()를 referenceId로 사용해 unique 충돌 방지) - creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, request.reward_amount(), history.getId()); + // AdMob이 보낸 reward_amount는 콘솔 설정에 따라 달라질 수 있어 신뢰하지 않고, 정책상 고정값(FREE_CHARGE)만 지급한다 + creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, CreditType.FREE_CHARGE.getDefaultAmount(), history.getId()); log.info("[AdMob] 포인트 적립 완료: userId={}, amount={}, historyId={}", - user.getId(), request.reward_amount(), history.getId()); + user.getId(), CreditType.FREE_CHARGE.getDefaultAmount(), history.getId()); return "OK"; } diff --git a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java index 03519c34..46587299 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java @@ -5,11 +5,11 @@ @Getter public enum CreditType { TODAY_CREDIT(5), // 출석체크: 하루 1회 - ATTENDANCE_STREAK(7), // 7일 연속 출석 보너스 + ATTENDANCE_STREAK(7), // 월~토 개근 후 일요일 출석체크 (TODAY_CREDIT 대신 지급) DEFAULT_CREDIT(30), // 기본 크레딧: 회원가입 시 기본 지급 BATTLE_VOTE(5), // 배틀 참여 보상: 사후 투표 완료 시 즉시 지급 BATTLE_ENTRY(-5), // 지난 배틀 이용 비용: 사전 투표 최초 진입 시 차감 - MAJORITY_WIN(10), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상 + MAJORITY_WIN(5), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상 BEST_COMMENT(15), // 베댓 보상: 월요일 배치, 2주 전 배틀 좋아요 1위 WEEKLY_CHARGE(40), // 주간 자동 충전: 매주 월요일 00:00 활성 사용자 전체 FREE_CHARGE(20), // 광고/자유 충전: 가변 금액 diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java index e687062f..3ed65123 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java @@ -21,7 +21,7 @@ * 다수결 보상 배치. * runDate(월요일) 기준 targetDate ∈ [runDate-20, runDate-14] 범위의 배틀 중 * 최다 득표 옵션(= 다수결 승자 옵션)을 선정하고, - * 그 옵션을 사전 투표한 사용자 전원에게 +10P (CreditType.MAJORITY_WIN) 를 지급한다. + * 그 옵션을 사전 투표한 사용자 전원에게 +5P (CreditType.MAJORITY_WIN) 를 지급한다. * * referenceId = battleId. CreditHistory 유니크 제약으로 같은 배틀 재실행 시 중복 지급 없음. */ diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java index 07bbf0bb..f3c5e504 100644 --- a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java +++ b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -49,6 +50,9 @@ List findByUserIdAndPreVoteOptionDisplayOrderNotOrderByCreatedAtDesc long countByUserId(Long userId); + // 오늘의 배틀 무료 참여(일 1회) 제한 체크용: 유저가 오늘 날짜인 배틀에 이미 참여했는지 확인 + boolean existsByUserIdAndBattle_TargetDate(Long userId, LocalDate targetDate); + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.displayOrder = :displayOrder") long countByUserIdAndPreVoteOptionDisplayOrder(@Param("userId") Long userId, @Param("displayOrder") Integer displayOrder); diff --git a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java index d4461436..43837a5d 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java @@ -129,7 +129,7 @@ public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest reques vote = existingVote.get(); vote.updatePreVote(option); } else { - if (shouldChargeBattleEntryCredit(battle)) { + if (shouldChargeBattleEntryCredit(battle, user)) { creditService.addCredit(user.getId(), CreditType.BATTLE_ENTRY, battle.getId()); } vote = BattleVote.createPreVote(user, battle, option); @@ -202,8 +202,13 @@ public void completeTts(Long battleId, Long userId) { userBattleService.upsertStep(user, battle, UserBattleStep.POST_VOTE); } - private boolean shouldChargeBattleEntryCredit(Battle battle) { + // 오늘의 배틀(targetDate == 오늘)은 하루 1회에 한해 무료 진입, 그 외 진입 또는 이미 오늘 무료 진입을 사용한 경우 -5P 차감 + private boolean shouldChargeBattleEntryCredit(Battle battle, User user) { LocalDate today = LocalDate.now(KST); - return battle.getTargetDate() == null || !battle.getTargetDate().isEqual(today); + boolean isTodayBattle = battle.getTargetDate() != null && battle.getTargetDate().isEqual(today); + if (!isTodayBattle) { + return true; + } + return battleVoteRepository.existsByUserIdAndBattle_TargetDate(user.getId(), today); } } diff --git a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java index 016a5024..ca0bf39c 100644 --- a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java @@ -97,7 +97,7 @@ void processReward_Success() throws Exception { assertThat(result).isEqualTo("OK"); // // 4. 호출 검증 - verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.FREE_CHARGE), eq(100), anyLong()); + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.FREE_CHARGE), eq(CreditType.FREE_CHARGE.getDefaultAmount()), anyLong()); verify(adRewardHistoryRepository, times(1)).saveAndFlush(any(AdRewardHistory.class)); verify(userService, times(1)).findByUserTag("pique-1cc4a030"); } diff --git a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java index 6135445a..2f0bf87a 100644 --- a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java @@ -103,6 +103,27 @@ void preVote_doesNotChargeBattleEntryCreditForTodayBattle() { verify(creditService, never()).addCredit(10L, CreditType.BATTLE_ENTRY, 100L); } + @Test + @DisplayName("오늘의 배틀이라도 오늘 이미 무료 진입을 사용했다면 크레딧을 차감한다") + void preVote_chargesBattleEntryCreditWhenFreeEntryAlreadyUsedToday() { + Battle battle = battle(100L, LocalDate.now()); + User user = user(10L); + BattleOption option = option(201L, battle, BattleOptionLabel.A); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(option)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.empty()); + when(battleVoteRepository.existsByUserIdAndBattle_TargetDate(10L, LocalDate.now())).thenReturn(true); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.NONE)); + + VoteResultResponse response = battleVoteService.preVote(100L, 10L, new VoteRequest(201L)); + + assertThat(response.status()).isEqualTo(UserBattleStep.PRE_VOTE); + verify(creditService).addCredit(10L, CreditType.BATTLE_ENTRY, 100L); + } + @Test @DisplayName("이미 사전 투표한 배틀이면 옵션 변경 시 추가 차감하지 않는다") void preVote_doesNotChargeAgainWhenVoteAlreadyExists() {