diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index ae82f976..3e5356e4 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -158,7 +158,35 @@ --- -### 3.2 `GET /api/v1/notifications/{notificationId}` +### 3.2 `GET /api/v1/notifications/unread` + +알림함 벨 아이콘 배지 표시용으로, 미읽음 알림 존재 여부만 가볍게 확인합니다. 목록 조회와 달리 페이지네이션 없이 boolean 하나만 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `category` | `string` | N | `ALL` \| `CONTENT` \| `NOTICE` \| `EVENT` (생략 시 전체 기준) | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "hasUnread": true + }, + "error": null +} +``` + +--- + +### 3.3 `GET /api/v1/notifications/{notificationId}` 알림 상세를 조회합니다. (목록과 동일한 필드 + `readAt`) @@ -202,7 +230,7 @@ --- -### 3.3 `PATCH /api/v1/notifications/{notificationId}/read` +### 3.4 `PATCH /api/v1/notifications/{notificationId}/read` 알림 1건을 읽음 처리합니다. @@ -222,7 +250,7 @@ --- -### 3.4 `PATCH /api/v1/notifications/read-all` +### 3.5 `PATCH /api/v1/notifications/read-all` 알림함의 모든 알림을 읽음 처리합니다. 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/notification/controller/NotificationController.java b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java index 2cb20dfa..4eb4ecc2 100644 --- a/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationService; import com.swyp.picke.global.common.response.ApiResponse; @@ -35,6 +36,15 @@ public ApiResponse getNotifications( return ApiResponse.onSuccess(notificationService.getNotifications(userId, category, page, size)); } + @Operation(summary = "알림 미읽음 여부 확인 (배지용)") + @GetMapping("/unread") + public ApiResponse hasUnread( + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) NotificationCategory category + ) { + return ApiResponse.onSuccess(notificationService.hasUnread(userId, category)); + } + @Operation(summary = "알림 상세 조회") @GetMapping("/{notificationId}") public ApiResponse getNotificationDetail( diff --git a/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java new file mode 100644 index 00000000..4b6938b0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.notification.dto.response; + +public record NotificationUnreadResponse( + boolean hasUnread +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 7ab6759e..14bf3bd1 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -38,6 +38,20 @@ AND NOT EXISTS ( """) boolean hasUnreadBroadcast(@Param("userId") Long userId, @Param("category") NotificationCategory category); + @Query(""" + SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END + FROM Notification n + WHERE (:category IS NULL OR n.category = :category) + AND ( + (n.user.id = :userId AND n.read = false) + OR (n.user IS NULL AND NOT EXISTS ( + SELECT 1 FROM NotificationRead nr + WHERE nr.notification = n AND nr.userId = :userId + )) + ) + """) + boolean existsUnread(@Param("userId") Long userId, @Param("category") NotificationCategory category); + @Modifying @Query(""" UPDATE Notification n SET n.read = true, n.readAt = CURRENT_TIMESTAMP diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index 1d5be4b5..ea749435 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; import com.swyp.picke.domain.notification.dto.response.NotificationSummaryResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse; import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationRead; import com.swyp.picke.domain.notification.enums.NotificationCategory; @@ -146,6 +147,11 @@ public boolean hasNewBroadcast(Long userId, NotificationCategory category) { return notificationRepository.hasUnreadBroadcast(userId, category); } + public NotificationUnreadResponse hasUnread(Long userId, NotificationCategory category) { + NotificationCategory filterCategory = (category == null || category == NotificationCategory.ALL) ? null : category; + return new NotificationUnreadResponse(notificationRepository.existsUnread(userId, filterCategory)); + } + private Notification getAccessibleNotification(Long userId, Long notificationId) { Notification notification = notificationRepository.findById(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); 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/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java index 69aea51d..0afe16f3 100644 --- a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -283,6 +283,30 @@ void markAllAsRead_calls_repository() { verify(notificationReadRepository).markAllBroadcastAsRead(userId); } + @Test + @DisplayName("ALL 카테고리로 미읽음 여부를 확인하면 카테고리 필터 없이 조회한다") + void hasUnread_withAllCategory_queriesWithoutCategoryFilter() { + Long userId = 1L; + when(notificationRepository.existsUnread(userId, null)).thenReturn(true); + + var response = notificationService.hasUnread(userId, NotificationCategory.ALL); + + assertThat(response.hasUnread()).isTrue(); + verify(notificationRepository).existsUnread(userId, null); + } + + @Test + @DisplayName("카테고리를 지정하면 해당 카테고리로 미읽음 여부를 확인한다") + void hasUnread_withSpecificCategory_queriesWithCategoryFilter() { + Long userId = 1L; + when(notificationRepository.existsUnread(userId, NotificationCategory.CONTENT)).thenReturn(false); + + var response = notificationService.hasUnread(userId, NotificationCategory.CONTENT); + + assertThat(response.hasUnread()).isFalse(); + verify(notificationRepository).existsUnread(userId, NotificationCategory.CONTENT); + } + private User createMockUser() { return User.builder() .userTag("test-user-tag") 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() {