From ed0177883d3cce2c74cee008eceeaacdd5e87edf Mon Sep 17 00:00:00 2001 From: lejuho Date: Mon, 1 Sep 2025 14:32:58 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EC=A4=91=EA=B0=84=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/garden/service/GardenService.java | 3 ++- .../response/UserGardenDetailResponse.java | 3 +++ .../member/user/service/UserService.java | 26 ++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java index d6b167cf..71f52682 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java @@ -92,7 +92,8 @@ public void waterOwnGarden(Long ownerId, Garden garden) { } /** 친구의 정원에 물을 주는 로직을 처리합니다. */ - private void waterFriendGarden(Long actorId, Garden garden) { + @Transactional + public void waterFriendGarden(Long actorId, Garden garden) { User actor = userRepository .findById(actorId) diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java index a7594f78..8a70e56e 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java @@ -16,4 +16,7 @@ public class UserGardenDetailResponse { private HomeResponseDto.AvatarInfo avatarInfo; // 해당 정원에 배치된 아바타의 상세 정보 private Boolean isWateringAbleByMe; // 현재 접속 유저가 이 정원에 물을 줄 수 있는지 여부 + + private Integer waterCount; // 👈 이 필드 추가 + private Integer sunlightCount; // 👈 이 필드 추가 } diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java index f1fd251f..24db5c44 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java @@ -26,12 +26,14 @@ import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service +@Slf4j @RequiredArgsConstructor @Transactional public class UserService { @@ -207,10 +209,14 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId // 2. 남에게 물 줄 수 있는 남은 횟수 계산 (현재 접속 유저 기준) LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay(); + log.info( + "Current Server Time: {}, Calculated startOfDay: {}", + LocalDateTime.now(), + startOfWateringDay); int todayWateringCountForOthers = friendWateringLogRepository.countByWaterGiverAndWateredAtAfter( currentUser, startOfWateringDay); - Long leftWaterCountForOthers = + long leftWaterCountForOthers = (long) (3 - todayWateringCountForOthers); // MAX_FRIEND_WATERING_PER_DAY = 3 // 3. 프로필 주인의 정원 목록 및 물주기 가능 여부 계산 @@ -231,17 +237,25 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId } HomeResponseDto.AvatarInfo avatarInfoForGarden = - HomeResponseDto.AvatarInfo.builder() - .avatarId(garden.getAvatar().getId()) - .avatarName(garden.getAvatar().getNickname()) - .avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl()) - .build(); + null; // 1. avatarInfo를 일단 null로 초기화 + + if (garden.getAvatar() != null) { // 2. 정원에 아바타가 있을 때만 avatarInfo를 채웁니다. + avatarInfoForGarden = + HomeResponseDto.AvatarInfo.builder() + .avatarId(garden.getAvatar().getId()) + .avatarName(garden.getAvatar().getNickname()) + .avatarImageUrl( + garden.getAvatar().getAvatarMaster().getDefaultImageUrl()) + .build(); + } // GardenResponse 대신 UserGardenDetailResponse를 빌드 return UserGardenDetailResponse.builder() .gardenId(garden.getId()) .avatarInfo(avatarInfoForGarden) .isWateringAbleByMe(isWateringAbleByMe) + .waterCount(garden.getWaterCount()) // 👈 이 라인 추가 + .sunlightCount(garden.getSunlightCount()) // 👈 이 라인 추가 .build(); }) .collect(Collectors.toList()); From df5220765ee559ea5141a389e2b67d4b39abb39f Mon Sep 17 00:00:00 2001 From: lejuho Date: Fri, 26 Sep 2025 14:18:49 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor=20:=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/avatar/avatar/domain/Avatar.java | 2 + .../domain/garden/garden/domain/Garden.java | 21 +++ .../garden/garden/service/GardenService.java | 94 ++-------- .../domain/home/HomeResponseDto.java | 5 + .../domain/home/service/HomeService.java | 161 +++++++----------- .../member/level/service/LevelService.java | 53 ------ .../domain/member/user/domain/User.java | 55 +++--- .../dto/response/LevelStatusResponseDto.java | 13 ++ .../dto/response/UserRegisterResponse.java | 16 -- .../user/presentation/UserController.java | 9 +- .../member/user/service/UserService.java | 121 ++++++------- .../domain/repository/DiaryRepository.java | 10 ++ .../dto/response/DiaryFeedItemResponse.java | 11 +- .../diary/dto/response/DiaryInfoResponse.java | 29 ++-- .../diary/dto/response/DiaryResponse.java | 12 +- .../diary/presentation/DiaryController.java | 17 +- .../mission/diary/service/DiaryService.java | 33 +++- .../service/UserDailyMissionService.java | 9 +- .../domain/mission/wishTree/WishTree.java | 52 +++--- .../mission/wishTree/WishTreeService.java | 9 +- .../mission/wishTree/WishTreeStage.java | 35 +--- .../social/avatarpost/domain/AvatarPost.java | 2 +- .../repository/AvatarPostRepository.java | 11 ++ .../dto/AvatarPostFeedItemResponse.java | 10 +- .../avatarpost/dto/PostInfoResponse.java | 2 +- .../feed/presentation/FeedController.java | 12 ++ .../social/feed/service/FeedService.java | 76 ++++++++- .../domain/repository/LikeRepository.java | 19 +++ .../social/like/service/LikeService.java | 47 ++--- .../global/dto/FeedItemResponse.java | 4 +- .../listener/NotificationEventListener.java | 2 + .../V3__refactor_garden_unlock_logic.sql | 5 + 32 files changed, 461 insertions(+), 496 deletions(-) delete mode 100644 src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java create mode 100644 src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java create mode 100644 src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql diff --git a/src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java b/src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java index 6eefffcd..58b3d012 100644 --- a/src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java +++ b/src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java @@ -2,6 +2,7 @@ import com.example.cp_main_be.domain.member.user.domain.User; import jakarta.persistence.*; +import jakarta.validation.constraints.Size; import lombok.*; @Entity @@ -17,6 +18,7 @@ public class Avatar { @Column(name = "avatar_id") private Long id; + @Size(max = 14, message = "닉네임은 14자 이하여야 합니다.") @Column(nullable = false) private String nickname; diff --git a/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java b/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java index 817f98bb..fc34c21e 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java @@ -4,6 +4,7 @@ import com.example.cp_main_be.domain.member.user.domain.User; import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; +import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import lombok.*; @@ -78,6 +79,26 @@ public Garden(User user, Integer slotNumber, GardenBackground gardenBackground, this.avatar = avatar; // [추가] } + @Transient // DB에 저장하지 않는, 계산된 필드임을 명시 + public boolean isWaterableByOwner() { + if (this.lastWateredByOwnerAt == null) { + return true; // 한 번도 물을 준 적이 없다면 항상 가능 + } + LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4); + // 다음 물주기 가능 시간이 현재 시간보다 이전이거나 같으면 true + return !nextWaterableTime.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + } + + @Transient // DB에 저장하지 않는, 계산된 필드임을 명시 + public long getWaterableByOwnerInSeconds() { + if (isWaterableByOwner()) { + return 0L; // 이미 물주기가 가능하면 남은 시간은 0 + } + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4); + return Duration.between(now, nextWaterableTime).getSeconds(); + } + public void unlock() { this.isLocked = false; this.createdAt = LocalDateTime.now(); diff --git a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java index 91ca7297..8907c178 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java @@ -1,6 +1,5 @@ package com.example.cp_main_be.domain.garden.garden.service; -import com.example.cp_main_be.domain.avatar.avatar.domain.repository.AvatarRepository; import com.example.cp_main_be.domain.garden.garden.domain.Garden; import com.example.cp_main_be.domain.garden.garden.domain.GardenBackground; import com.example.cp_main_be.domain.garden.garden.domain.repository.GardenBackgroundRepository; @@ -11,13 +10,11 @@ import com.example.cp_main_be.domain.garden.wateringlog.domain.repository.FriendWateringLogRepository; import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; -import com.example.cp_main_be.domain.member.user.service.UserService; -import com.example.cp_main_be.domain.mission.wishTree.WishTree; -import com.example.cp_main_be.domain.mission.wishTree.WishTreeRepository; import com.example.cp_main_be.domain.mission.wishTree.WishTreeService; import com.example.cp_main_be.global.common.CustomApiException; import com.example.cp_main_be.global.common.ErrorCode; import com.example.cp_main_be.global.event.WishTreeEvolvedEvent; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; @@ -36,19 +33,15 @@ @Slf4j public class GardenService { - private static final int MAX_GARDEN_COUNT = 3; private static final Long WATERING_POINTS = 2L; private static final Long SUNLIGHT_POINTS = 3L; private static final Long MAX_FRIEND_WATERING_PER_DAY = 3L; private final WishTreeService wishTreeService; private final GardenRepository gardenRepository; - private final UserService userService; private final ApplicationEventPublisher eventPublisher; private final GardenBackgroundRepository gardenBackgroundRepository; - private final AvatarRepository avatarRepository; private final UserRepository userRepository; private final FriendWateringLogRepository friendWateringLogRepository; - private final WishTreeRepository wishTreeRepository; public GardenResponse findGardenById(Long gardenId) { Garden garden = @@ -61,8 +54,6 @@ public GardenResponse findGardenById(Long gardenId) { @Transactional public void waterGarden(Long actorId, Long gardenId) { - // N+1 문제를 방지하기 위해 Garden과 User를 함께 조회하는 것을 권장합니다. - // 예: gardenRepository.findByIdWithUser(gardenId) Garden garden = gardenRepository .findById(gardenId) @@ -80,12 +71,8 @@ public void waterGarden(Long actorId, Long gardenId) { /** 자신의 정원에 물을 주는 로직을 처리합니다. */ @Transactional public void waterOwnGarden(Long ownerId, Garden garden) { - // [수정] 시간대 문제를 방지하기 위해, 서울 시간 기준으로 현재 시간을 명시적으로 사용합니다. - LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - - // 8시간 쿨타임 체크 - if (garden.getLastWateredByOwnerAt() != null - && garden.getLastWateredByOwnerAt().plusHours(8).isAfter(nowInSeoul)) { + // [수정] Garden 객체에게 직접 물을 줄 수 있는지 물어봅니다. + if (!garden.isWaterableByOwner()) { throw new CustomApiException(ErrorCode.WATERING_COOL_DOWN); } @@ -102,42 +89,33 @@ public void waterFriendGarden(Long actorId, Garden garden) { .findById(actorId) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); - // 낮 12시 이후 -> 그날의 12시 날짜 반환, 이전 -> 전날의 12시 날짜 반환 - LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay(); - - // 1. 하루에 3회 제한 체크 - checkFriendWateringLimit(actor, startOfWateringDay); + LocalDateTime startOfToday = getStartOfToday(); - // 2. 같은 정원에 하루 한 번 제한 체크 - checkAlreadyWateredToday(actor, garden, startOfWateringDay); + checkFriendWateringLimit(actor, startOfToday); + checkAlreadyWateredToday(actor, garden, startOfToday); - // 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다. wishTreeService.addPointsToWishTree(actor.getId(), WATERING_POINTS); garden.increaseWaterCount(); - // [수정] 시간대(Timezone) 문제를 방지하기 위해, 물 준 시간을 명시적으로 기록합니다. LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - // 물주기 활동 기록 FriendWateringLog log = FriendWateringLog.builder() .waterGiver(actor) .wateredGarden(garden) - .wateredAt(nowInSeoul) // @CreatedDate 대신 직접 시간을 설정합니다. + .wateredAt(nowInSeoul) .build(); friendWateringLogRepository.save(log); } - // 물주기 남은 횟수 확인 private void checkFriendWateringLimit(User actor, LocalDateTime startOfWateringDay) { - int todayWateringCount = + long todayWateringCount = friendWateringLogRepository.countByWaterGiverAndWateredAtAfter(actor, startOfWateringDay); if (todayWateringCount >= MAX_FRIEND_WATERING_PER_DAY) { throw new CustomApiException(ErrorCode.FRIEND_WATERING_LIMIT_EXCEEDED); } } - // 당일에 해당 정원에 이미 물을 주었는지 확인 private void checkAlreadyWateredToday( User actor, Garden garden, LocalDateTime startOfWateringDay) { boolean alreadyWatered = @@ -150,18 +128,15 @@ private void checkAlreadyWateredToday( @Transactional public void sunlightGarden(Long actorId, Long gardenId) { - // 텃밭 가져오기 Garden garden = gardenRepository .findById(gardenId) .orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND)); - // 햇빛은 본인만 줄 수 있도록 검증 if (!garden.getUser().getId().equals(actorId)) { throw new CustomApiException(ErrorCode.ACCESS_DENIED, "자신의 정원에만 햇빛을 줄 수 있습니다."); } - // 하루에 한 번만 햇빛을 줄 수 있도록 체크 (초기화 시간: 오전 6시) LocalDateTime startOfSunlightDay = getStartOfCurrentSunlightDay(); if (garden.getLastSunlightReceivedAt() != null && garden.getLastSunlightReceivedAt().isAfter(startOfSunlightDay)) { @@ -171,38 +146,26 @@ public void sunlightGarden(Long actorId, Long gardenId) { garden.increaseSunlightCount(); wishTreeService.addPointsToWishTree(actorId, SUNLIGHT_POINTS); - - // [수정] 시간대 문제를 해결하기 위해, 서울 시간 기준으로 현재 시간을 명시적으로 기록합니다. garden.recordSunlightTime(); } public void unlockNextGarden(Long userId) { - // 1. 유저와 소원나무 정보 가져오기 User user = userRepository .findById(userId) .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); - WishTree wishTree = - wishTreeRepository - .findByUserId(user.getId()) - .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); - // 2. 해금 가능한 상태인지 확인 - if (!wishTree.isUnlockable()) { - throw new CustomApiException(ErrorCode.GARDEN_SLOT_LOCKED, "정원을 해금할 수 있는 포인트가 부족합니다."); + if (user.getUnlockableGardenCount() <= 0) { + throw new CustomApiException(ErrorCode.GARDEN_SLOT_LOCKED, "해금할 수 있는 정원이 없습니다."); } - // 3. 소원나무 Stage를 다음 단계로 성장시키기 - wishTree.evolveStage(); - - // 4. 다음으로 잠겨있는 정원을 찾아 해금하기 Garden gardenToUnlock = gardenRepository .findFirstByUserAndIsLockedIsTrueOrderBySlotNumberAsc(user) - .orElseThrow( - () -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND, "해금할 정원을 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND, "해금할 정원이 없습니다.")); - gardenToUnlock.unlock(); // Garden 엔티티의 isLocked를 false로 변경 + gardenToUnlock.unlock(); + user.decrementUnlockableGardenCount(); } @Transactional @@ -226,34 +189,13 @@ public List getAllBackgrounds() { .collect(Collectors.toList()); } - /** - * 현재 시간 기준으로 물주기 횟수가 초기화되는 시간(정오)을 계산합니다. - 현재 시간이 정오 이전이면, 어제 정오를 반환합니다. - 현재 시간이 정오 이후이면, 오늘 - * 정오를 반환합니다. - * - * @return 현재 물주기 주기의 시작 시간 - */ - private LocalDateTime getStartOfCurrentWateringDay() { - // 서버 위치와 관계없이 항상 한국 시간 기준으로 동작하도록 시간대를 명시합니다. - LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - LocalDateTime todayNoon = now.toLocalDate().atTime(12, 0); - - if (now.isBefore(todayNoon)) { - return todayNoon.minusDays(1); - } else { - return todayNoon; - } + /** 친구에게 물 주는 횟수가 초기화되는 시간(자정)을 반환합니다. */ + private LocalDateTime getStartOfToday() { + return LocalDate.now(ZoneId.of("Asia/Seoul")).atStartOfDay(); } - /** - * 현재 시간 기준으로 햇빛 주기 횟수가 초기화되는 시간(오전 6시)을 계산합니다. - 현재 시간이 오전 6시 이전이면, 어제 오전 6시를 반환합니다. - 현재 시간이 오전 - * 6시 이후이면, 오늘 오전 6시를 반환합니다. - * - * @return 현재 햇빛 주기의 시작 시간 - */ private LocalDateTime getStartOfCurrentSunlightDay() { - // 서버 위치와 관계없이 항상 한국 시간 기준으로 동작하도록 시간대를 명시합니다. LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - // 해당 날짜의 6시 반환 LocalDateTime todaySixAM = now.toLocalDate().atTime(6, 0); if (now.isBefore(todaySixAM)) { @@ -263,11 +205,9 @@ private LocalDateTime getStartOfCurrentSunlightDay() { } } - /** 오래된 친구 물주기 로그를 주기적으로 삭제하는 스케줄링 작업입니다. cron = "0 0 4 * * *" : 매일 새벽 4시에 실행됩니다. */ @Scheduled(cron = "0 0 4 * * *") - @Transactional // 쓰기 작업이므로 클래스 레벨의 readOnly 설정을 오버라이드합니다. + @Transactional public void cleanupOldWateringLogs() { - // 7일 이상된 기록을 삭제하도록 설정. 이 값은 application.yml에서 관리하는 것이 더 좋습니다. final int RETENTION_DAYS = 7; LocalDateTime cutoffDate = LocalDateTime.now().minusDays(RETENTION_DAYS); log.info("Starting cleanup of friend watering logs older than {} days...", RETENTION_DAYS); diff --git a/src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java b/src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java index 7bad848b..b95a07c0 100644 --- a/src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java +++ b/src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java @@ -1,5 +1,6 @@ package com.example.cp_main_be.domain.home; +import java.time.LocalDateTime; import java.util.List; import lombok.Builder; import lombok.Getter; @@ -38,6 +39,10 @@ public static class GardenSummaryInfo { private boolean isUnlockable; private boolean isOwnerWateringAble; // 본인 정원에 물주기 가능한지 여부 -> 해금안되면 null private boolean isOwnerSunlightAble; // 본인 정원에 햇빛 주기 가능한지 여부 -> 해금안되면 null + // [추가] 다음 물주기 가능 시간 (ISO 8601 형식 문자열) + private LocalDateTime nextWaterableAt; + // [추가] 다음 물주기까지 남은 시간 (초 단위) + private Long waterableInSeconds; } @Getter diff --git a/src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java b/src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java index bfd2315f..853f22d1 100644 --- a/src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java +++ b/src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java @@ -8,11 +8,7 @@ import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; import com.example.cp_main_be.domain.mission.diary.domain.repository.DiaryRepository; -import com.example.cp_main_be.domain.mission.user_daily_mission.domain.UserDailyMission; -import com.example.cp_main_be.domain.mission.user_daily_mission.domain.repository.UserDailyMissionRepository; import com.example.cp_main_be.domain.mission.wishTree.WishTree; -import com.example.cp_main_be.domain.mission.wishTree.WishTreeRepository; -import com.example.cp_main_be.domain.mission.wishTree.WishTreeService; import com.example.cp_main_be.domain.mission.wishTree.WishTreeStage; import com.example.cp_main_be.domain.realquiz.repository.UserQuizRepository; import com.example.cp_main_be.global.common.CustomApiException; @@ -22,37 +18,22 @@ import java.time.ZoneId; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -// HomeService.java @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class HomeService { private final UserRepository userRepository; - private final UserDailyMissionRepository userDailyMissionRepository; private final NotificationRepository notificationRepository; private final DiaryRepository diaryRepository; private final UserQuizRepository userQuizRepository; - private final WishTreeService wishTreeService; - private final DailyQuestionAnswerRepository dailyQuestionAnswerRepository; // <-- 추가 - private final WishTreeRepository wishTreeRepository; - - // ... - - // GardenService에서 가져오거나, 공통 유틸리티로 분리하면 더 좋습니다. - private LocalDateTime getStartOfCurrentWateringDay() { - LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - LocalDateTime todayNoon = now.toLocalDate().atTime(12, 0); - return now.isBefore(todayNoon) ? todayNoon.minusDays(1) : todayNoon; - } + private final DailyQuestionAnswerRepository dailyQuestionAnswerRepository; private LocalDateTime getStartOfCurrentSunlightDay() { LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); @@ -66,49 +47,52 @@ public HomeResponseDto getHomeScreenData(Long userId) { .findById(userId) .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); - WishTree wishTreeOfUser = user.getWishTree(); - // 1. UserInfo 구성 + WishTree wishTree = user.getWishTree(); + if (wishTree == null) { + throw new CustomApiException(ErrorCode.NOT_FOUND, "소망나무 정보를 찾을 수 없습니다."); + } + + WishTreeStage currentStage = wishTree.getStage(); + long totalPoints = wishTree.getPoints(); + long expForCurrentLevelStart = currentStage.getRequiredPoints(); + long expForNextLevelStart = currentStage.getRequiredPointsForNextStage(); + long expInCurrentLevel = totalPoints - expForCurrentLevelStart; + long expNeededForLevelUp = expForNextLevelStart - expForCurrentLevelStart; + int unreadNotificationCount = notificationRepository.countByReceiverAndIsReadFalse(user); HomeResponseDto.UserInfo userInfo = HomeResponseDto.UserInfo.builder() .id(user.getId()) .username(user.getNickname()) - .level(wishTreeOfUser.getStage().ordinal() + 1) - .currentExp(wishTreeOfUser.getPoints()) - .requiredExpForNextLevel(wishTreeOfUser.getStage().getRequiredPointsForNextStage()) + .level(currentStage.getLevel()) + .currentExp(expInCurrentLevel) + .requiredExpForNextLevel(expNeededForLevelUp) .unreadNotificationCount(unreadNotificationCount) .build(); - WishTree wishTree = - wishTreeRepository - .findByUserId(userId) - .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); - - long unlockedGardenCount = user.getGardens().stream().filter(g -> !g.isLocked()).count(); - Map userGardens = user.getGardens().stream() .collect(Collectors.toMap(Garden::getSlotNumber, garden -> garden)); - // 2. GardenSummaries 구성 (모든 정원 순회) + + long unlockedGardenCount = user.getGardens().stream().filter(g -> !g.isLocked()).count(); + List gardenSummaries = IntStream.rangeClosed(1, 4) .mapToObj( slotNumber -> { Garden garden = userGardens.get(slotNumber); - if (garden != null && !garden.isLocked()) { - boolean isOwnerWateringAble = - garden.getLastWateredByOwnerAt() == null - || garden - .getLastWateredByOwnerAt() - .plusHours(8) - .isBefore(LocalDateTime.now()); + // [수정] Garden 객체에 직접 물어보는 방식으로 변경 + boolean isOwnerWateringAble = garden.isWaterableByOwner(); + long waterableInSeconds = garden.getWaterableByOwnerInSeconds(); + LocalDateTime nextWaterableAt = + isOwnerWateringAble ? null : garden.getLastWateredByOwnerAt().plusHours(4); boolean isOwnerSunlightAble = garden.getLastSunlightReceivedAt() == null || garden .getLastSunlightReceivedAt() - .isBefore(getStartOfCurrentSunlightDay()); // 매일 06시 초기화 + .isBefore(getStartOfCurrentSunlightDay()); HomeResponseDto.AvatarInfo avatarInfo = null; if (garden.getAvatar() != null) { @@ -128,141 +112,110 @@ public HomeResponseDto getHomeScreenData(Long userId) { .isUnlockable(false) .isOwnerWateringAble(isOwnerWateringAble) .isOwnerSunlightAble(isOwnerSunlightAble) + .waterableInSeconds(waterableInSeconds) + .nextWaterableAt(nextWaterableAt) .build(); } else { boolean isUnlockable = garden != null - && wishTree.isUnlockable() - && garden.getSlotNumber() == unlockedGardenCount + 1; + && garden.isLocked() + && slotNumber > unlockedGardenCount + && slotNumber <= unlockedGardenCount + user.getUnlockableGardenCount(); return HomeResponseDto.GardenSummaryInfo.builder() .gardenId(garden != null ? garden.getId() : null) .gardenSlotNumber(slotNumber) - .avatar(null) .isLocked(true) .isUnlockable(isUnlockable) - .isOwnerWateringAble(false) - .isOwnerSunlightAble(false) .build(); } }) .collect(Collectors.toList()); - // 3. TodayMissions 구성 (수정된 로직) LocalDate today = LocalDate.now(); LocalDateTime startOfDay = today.atStartOfDay(); LocalDateTime endOfDay = today.atTime(23, 59, 59); - // 미션 1: 일기 쓰기 완료 여부 확인 boolean isDiaryCompleted = diaryRepository.existsByUserAndCreatedAtBetween(user, startOfDay, endOfDay); - - // 미션 2: 퀴즈 풀기 완료 여부 확인 boolean isQuizCompleted = userQuizRepository.existsByUserAndIsCompletedIsTrueAndCreatedAtBetween( user, startOfDay, endOfDay); - - // 미션 3: 오늘의 질문 답변 완료 여부 확인 boolean isCheckingCompleted = dailyQuestionAnswerRepository.existsByUserAndAnsweredDate(user, today); - // 각 미션의 완료 상태를 바탕으로 MissionInfo DTO 리스트 생성 List todayMissions = List.of( HomeResponseDto.MissionInfo.builder() - .missionId(1L) // 임의의 ID 부여 + .missionId(1L) .missionTitle("오늘의 일기 쓰기") .missionType("DIARY") .isCompleted(isDiaryCompleted) .build(), HomeResponseDto.MissionInfo.builder() - .missionId(2L) // 임의의 ID 부여 + .missionId(2L) .missionTitle("오늘의 퀴즈 풀기") .missionType("QUIZ") .isCompleted(isQuizCompleted) .build(), HomeResponseDto.MissionInfo.builder() - .missionId(3L) // 임의의 ID 부여 + .missionId(3L) .missionTitle("오늘의 질문 답변하기") - .missionType("CHECKING") // 또는 적절한 타입 + .missionType("CHECKING") .isCompleted(isCheckingCompleted) .build()); - // // 4. ActivityInfo 구성 - // List weeklyStatus = createWeeklyMissionStatus(userId); - // HomeResponseDto.ActivityInfo activityInfo = - // HomeResponseDto.ActivityInfo.builder().weeklyMissionStatus(weeklyStatus).build(); - - // 5. 최종 DTO 빌드 return HomeResponseDto.builder() .userInfo(userInfo) - .gardenSummaries(gardenSummaries) // 변경된 부분 + .gardenSummaries(gardenSummaries) .todayMissions(todayMissions) .build(); } - private long calculateRequiredExpForLevel(int level) { - return level * 100L; // 예시 로직 - } - - private List createWeeklyMissionStatus(Long userId) { - // 기존 로직과 동일 - LocalDateTime sevenDaysAgo = LocalDate.now().minusDays(6).atStartOfDay(); - List recentMissions = - userDailyMissionRepository.findByUserIdAndCreatedAtAfter(userId, sevenDaysAgo); - - Set completedDates = - recentMissions.stream() - .filter(UserDailyMission::isCompleted) - .map(mission -> mission.getCreatedAt().toLocalDate()) - .collect(Collectors.toSet()); - - return Stream.iterate(LocalDate.now().minusDays(6), date -> date.plusDays(1)) - .limit(7) - .map(completedDates::contains) - .collect(Collectors.toList()); - } - public PannelResponseDTO getPannelData(User user) { LocalDate today = LocalDate.now(); - LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); - LocalDateTime endOfDay = LocalDate.now().atTime(23, 59, 59); + LocalDateTime startOfDay = today.atStartOfDay(); + LocalDateTime endOfDay = today.atTime(23, 59, 59); - // exists 쿼리로 간단하게 변경 boolean isDiaryCompleted = diaryRepository.existsByUserAndCreatedAtBetween(user, startOfDay, endOfDay); boolean isQuizCompleted = userQuizRepository.existsByUserAndIsCompletedIsTrueAndCreatedAtBetween( user, startOfDay, endOfDay); - // 오늘의 질문 완료 여부 확인 로직 추가 boolean isCheckingCompleted = dailyQuestionAnswerRepository.existsByUserAndAnsweredDate(user, today); - // 위시트리 찾기 - WishTree wishTree = wishTreeService.findOrCreateWishTree(user.getId()); - + WishTree wishTree = user.getWishTree(); + if (wishTree == null) { + throw new CustomApiException(ErrorCode.NOT_FOUND, "소망나무 정보를 찾을 수 없습니다."); + } WishTreeStage currentStageEnum = wishTree.getStage(); WishTreeStage nextStageEnum = currentStageEnum.getNextStage(); + long totalPoints = wishTree.getPoints(); + + long expForCurrentLevelStart = currentStageEnum.getRequiredPoints(); + long expForNextLevelStart = currentStageEnum.getRequiredPointsForNextStage(); + long expInCurrentLevel = totalPoints - expForCurrentLevelStart; + long expNeededForLevelUp = expForNextLevelStart - expForCurrentLevelStart; + + long progressPercent = + (expNeededForLevelUp > 0) + ? (long) (((double) expInCurrentLevel / expNeededForLevelUp) * 100) + : 100; PannelResponseDTO.WishTreeDto wishTreeDto = PannelResponseDTO.WishTreeDto.builder() .currentStage(currentStageEnum.getKoreanName()) - // nextStage가 null이 아니면 이름을, null이면 빈 문자열("")을 설정 .nextStage(nextStageEnum != null ? nextStageEnum.getKoreanName() : "") - .currentPoints(wishTree.getPoints()) - .requiredPointsForNextStage( - currentStageEnum.getRequiredPointsForNextStage() - wishTree.getPoints()) - .progressPercent( - (long) - ((double) wishTree.getPoints() - / currentStageEnum.getRequiredPointsForNextStage() - * 100)) + .currentPoints(expInCurrentLevel) + .requiredPointsForNextStage(expNeededForLevelUp) + .progressPercent(progressPercent) .build(); return PannelResponseDTO.builder() - .isDairyCompleted(isDiaryCompleted) // 변수명 오타 수정: isDiaryCompleted + .isDairyCompleted(isDiaryCompleted) .isQuizCompleted(isQuizCompleted) - .isCheckingCompleted(isCheckingCompleted) // 이제 정상적으로 값이 들어감 + .isCheckingCompleted(isCheckingCompleted) .wishTree(wishTreeDto) .build(); } diff --git a/src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java b/src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java deleted file mode 100644 index 6b4e2516..00000000 --- a/src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.example.cp_main_be.domain.member.level.service; - -import com.example.cp_main_be.domain.member.user.domain.User; -import com.example.cp_main_be.global.event.LevelUpEvent; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class LevelService { - - private static final int BASE_EXP = 1000; - private static final int EXP_INCREMENT = 150; - - private final ApplicationEventPublisher eventPublisher; - - public void checkLevelUp(User user) { - long currentLevel = user.getLevel(); - long currentExp = user.getExperience(); - - long requiredExp = calculateRequiredExp(currentLevel); - - while (currentExp >= requiredExp) { - // 2. 실제 레벨업 처리는 User 객체에 위임 - long remainingExp = currentExp - requiredExp; - user.levelUp(remainingExp); - - // 3. 레벨업 이벤트 발행 - eventPublisher.publishEvent(new LevelUpEvent(user, user.getLevel())); - - // 다음 레벨업 체크를 위해 값 업데이트 - currentLevel = user.getLevel(); - currentExp = user.getExperience(); - requiredExp = calculateRequiredExp(currentLevel); - } - } - - // 경험치를 추가하고, 레벨업이 필요한지 확인하는 메서드 - public void addExperienceAndCheckLevelUp(User user, int expToAdd) { - user.addExperience(expToAdd); - checkLevelUp(user); - } - - private long calculateRequiredExp(long level) { - // 레벨 1 -> 2 필요 경험치: 1150 - // 레벨 2 -> 3 필요 경험치: 1300 - // ... - return BASE_EXP + (EXP_INCREMENT * level); - } -} diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java index 5515e772..92edc438 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java @@ -33,9 +33,9 @@ public class User implements UserDetails { @Column(unique = true) private UUID uuid; - @Enumerated(EnumType.STRING) // [추가] Enum 타입을 DB에 저장할 때 문자열로 저장 + @Enumerated(EnumType.STRING) @Column(nullable = false) - private Role role; // 역할 필드 추가 + private Role role; @Column(nullable = false) private String nickname; @@ -46,16 +46,11 @@ public class User implements UserDetails { private String profileImageUrl; - @OneToMany( - mappedBy = "user", - cascade = CascadeType.ALL, - orphanRemoval = true) // User와 Avatar의 1:N 관계 설정 + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List avatarList = new ArrayList<>(); - @Builder.Default private Integer level = 1; // 기본 레벨 설정 - - @Builder.Default private Long experience = 0L; // 기본 경험치 설정 + // [제거] level, experience 관련 필드와 메서드를 모두 삭제합니다. @Column(nullable = false) private LocalDateTime createdAt; @@ -84,6 +79,8 @@ public class User implements UserDetails { @Builder.Default private Boolean notificationEnabled = true; + @Builder.Default private Integer unlockableGardenCount = 0; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private WishTree wishTree; @@ -92,15 +89,14 @@ protected void onCreate() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); if (this.status == null) this.status = UserStatus.ACTIVE; - if (this.role == null) this.role = Role.USER; // [추가] 기본 역할을 USER로 설정 + if (this.role == null) this.role = Role.USER; } - @PreUpdate // 엔티티가 업데이트되기 전에 실행되는 콜백 메서드 + @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } - // == 정보 수정 메서드 ==// public void updateProfile(String nickname, String profileImageUrl) { if (nickname != null) { this.nickname = nickname; @@ -110,65 +106,58 @@ public void updateProfile(String nickname, String profileImageUrl) { } } - // == 연관관계 편의 메서드 ==// public void addGarden(Garden garden) { - // 양방향 연관관계에서 양쪽 모두의 상태를 동기화합니다. this.gardens.add(garden); garden.setUser(this); } - public void addAvatar(Avatar avatar) { - this.avatarList.add(avatar); - avatar.setUser(this); + public void incrementUnlockableGardenCount() { + this.unlockableGardenCount++; } - // == 비즈니스 로직 메서드 (향후 확장용) ==// - public void addExperience(int amount) { - this.experience += amount; - // TODO: 여기에 레벨업 확인 로직을 추가할 수 있습니다. - // ex) if (this.experience >= getRequiredExperienceForNextLevel()) { levelUp(); } + public void decrementUnlockableGardenCount() { + this.unlockableGardenCount--; } - public void levelUp(long remainingExp) { - this.level++; - this.experience = remainingExp; + public void addAvatar(Avatar avatar) { + this.avatarList.add(avatar); + avatar.setUser(this); } + // [제거] addExperience, levelUp 메서드 삭제 + @Override public Collection getAuthorities() { - // 이 사용자가 가진 역할을 기반으로 권한 목록을 반환 return Collections.singletonList(new SimpleGrantedAuthority(this.role.getKey())); } @Override public String getPassword() { - return this.passwordHash; // passwordHash 필드를 반환 + return this.passwordHash; } @Override public String getUsername() { - // Spring Security에서 username은 고유 식별자를 의미합니다. - // 여기서는 uuid를 사용하겠습니다. return this.uuid.toString(); } @Override public boolean isAccountNonExpired() { - return true; // 계정이 만료되지 않았음 + return true; } @Override public boolean isAccountNonLocked() { - return true; // 계정이 잠기지 않았음 + return true; } @Override public boolean isCredentialsNonExpired() { - return true; // 자격 증명(비밀번호)이 만료되지 않았음 + return true; } @Override public boolean isEnabled() { - return this.status == UserStatus.ACTIVE; // 활성 상태인 경우에만 계정 활성화 + return this.status == UserStatus.ACTIVE; } } diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java new file mode 100644 index 00000000..177ab87f --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java @@ -0,0 +1,13 @@ +package com.example.cp_main_be.domain.member.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LevelStatusResponseDto { + private final int level; + private final long currentExp; + private final long requiredExpForNextLevel; + private final long totalPoints; +} diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java index 3d8cab02..2cc9175a 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java @@ -14,20 +14,4 @@ public class UserRegisterResponse { private UUID uuid; private String accessToken; private String refreshToken; - - @Getter - @Builder - public static class LevelStatusResponseDTO { - // GET /level 의 Response - private Integer level; // 레벨 - private Long experience; // 경험 - } - - @Getter - @Builder - public static class MyInfoResponseDTO { - private Long id; - private String username; - private UUID uuid; - } } diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java b/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java index 1f41aa31..3897970b 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java @@ -4,6 +4,7 @@ import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; import com.example.cp_main_be.domain.member.user.dto.request.AvatarChangeRequest; import com.example.cp_main_be.domain.member.user.dto.request.NicknameChangeRequest; +import com.example.cp_main_be.domain.member.user.dto.response.LevelStatusResponseDto; import com.example.cp_main_be.domain.member.user.dto.response.UserProfileResponse; import com.example.cp_main_be.domain.member.user.dto.response.UserRegisterResponse; import com.example.cp_main_be.domain.member.user.service.UserService; @@ -72,11 +73,11 @@ public ResponseEntity> getMyInfo( } @GetMapping("/level") - @Operation(summary = "점수 및 레벨 조회") - public ResponseEntity> getLevel( + @Operation(summary = "소망나무 점수 및 레벨 현황 조회", description = "기존 /level 엔드포인트를 대체합니다.") + public ResponseEntity> getLevelStatus( @AuthenticationPrincipal User user) { - UserRegisterResponse.LevelStatusResponseDTO levelStatusResponseDTO = userService.getLevel(user); - return ResponseEntity.ok(ApiResponse.success(levelStatusResponseDTO)); + LevelStatusResponseDto levelStatus = userService.getLevelStatus(user); + return ResponseEntity.ok(ApiResponse.success(levelStatus)); } @Operation( diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java index cfbbfab2..ed93a324 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java @@ -5,14 +5,16 @@ import com.example.cp_main_be.domain.garden.garden.domain.Garden; import com.example.cp_main_be.domain.garden.wateringlog.domain.repository.FriendWateringLogRepository; import com.example.cp_main_be.domain.home.HomeResponseDto; -import com.example.cp_main_be.domain.member.level.service.LevelService; import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; import com.example.cp_main_be.domain.member.user.dto.request.AvatarChangeRequest; import com.example.cp_main_be.domain.member.user.dto.response.FollowStatus; +import com.example.cp_main_be.domain.member.user.dto.response.LevelStatusResponseDto; import com.example.cp_main_be.domain.member.user.dto.response.UserGardenDetailResponse; import com.example.cp_main_be.domain.member.user.dto.response.UserProfileResponse; -import com.example.cp_main_be.domain.member.user.dto.response.UserRegisterResponse; +import com.example.cp_main_be.domain.mission.wishTree.WishTree; +import com.example.cp_main_be.domain.mission.wishTree.WishTreeService; // [추가] WishTreeService 임포트 +import com.example.cp_main_be.domain.mission.wishTree.WishTreeStage; import com.example.cp_main_be.domain.social.follow.domain.repository.FollowRepository; import com.example.cp_main_be.global.common.CustomApiException; import com.example.cp_main_be.global.common.ErrorCode; @@ -34,22 +36,54 @@ public class UserService { private static final int MAX_FRIEND_WATERING_PER_DAY = 3; private final UserRepository userRepository; - private final LevelService levelService; private final AvatarRepository avatarRepository; private final FriendWateringLogRepository friendWateringLogRepository; private final FollowRepository followRepository; + private final WishTreeService wishTreeService; // [추가] WishTreeService 주입 - public void addExperience(Long actorId, int points) { - User user = - userRepository - .findById(actorId) // ID로 최신 유저 정보를 조회합니다. - .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); - user.addExperience(points); - levelService.checkLevelUp(user); + // [수정] 경험치 추가 로직을 WishTreeService에 위임 + public void addExperience(Long actorId, Long points) { + wishTreeService.addPointsToWishTree(actorId, points); + } + + // [제거] LevelService가 없으므로 getLevel 메서드 삭제 + + // [수정] 최종 레벨 도달 시 분기 처리 추가 + @Transactional(readOnly = true) + public LevelStatusResponseDto getLevelStatus(User user) { + WishTree wishTree = user.getWishTree(); + if (wishTree == null) { + throw new CustomApiException(ErrorCode.NOT_FOUND, "소망나무 정보를 찾을 수 없습니다."); + } + + WishTreeStage currentStage = wishTree.getStage(); + long totalPoints = wishTree.getPoints(); + + // [추가] 최종 레벨에 도달했을 경우의 분기 처리 + if (currentStage == WishTreeStage.FINAL) { + return LevelStatusResponseDto.builder() + .level(currentStage.getLevel()) + .currentExp(0) // 경험치 바를 채울 필요가 없으므로 0으로 설정 + .requiredExpForNextLevel(0) // 다음 레벨이 없으므로 0으로 설정 + .totalPoints(totalPoints) + .build(); + } + + long expForCurrentLevelStart = currentStage.getRequiredPoints(); + long expForNextLevelStart = currentStage.getRequiredPointsForNextStage(); + + long expInCurrentLevel = totalPoints - expForCurrentLevelStart; + long expNeededForLevelUp = expForNextLevelStart - expForCurrentLevelStart; + + return LevelStatusResponseDto.builder() + .level(currentStage.getLevel()) + .currentExp(expInCurrentLevel) + .requiredExpForNextLevel(expNeededForLevelUp) + .totalPoints(totalPoints) + .build(); } public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId) { - // [수정] 불필요한 null 체크를 제거하고, 일관된 예외 처리를 사용합니다. if (user == null) { throw new CustomApiException(ErrorCode.INVALID_TOKEN, "사용자 정보를 찾을 수 없습니다."); } @@ -58,11 +92,8 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId) .findById(avatarId) .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_NOT_FOUND)); - // [버그 수정] AvatarMaster(원본)가 아닌 Avatar(개별 인스턴스)의 imageUrl을 변경해야 합니다. - // Avatar 엔티티에 imageUrl 필드가 있어야 합니다. if (request.getNewAvatarUrl() != null) { - // avatar.getAvatarMaster().setDefaultImageUrl(request.getNewAvatarUrl()); // 절대 이렇게 하면 안됩니다. - avatar.setImageUrl(request.getNewAvatarUrl()); // 이렇게 수정해야 합니다. + avatar.setImageUrl(request.getNewAvatarUrl()); } if (request.getNewAvatarName() != null) { avatar.setNickname(request.getNewAvatarName()); @@ -71,7 +102,6 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId) } public void updateNickname(User user, String newNickname) { - // [수정] stale한 user 객체 대신, ID로 최신 정보를 조회해서 사용합니다. User managedUser = userRepository .findById(user.getId()) @@ -87,7 +117,7 @@ public void saveUser(User user) { public void deleteUser(Long userId) { User user = userRepository - .findById(userId) // ID로 최신 유저 정보를 조회합니다. + .findById(userId) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); userRepository.delete(user); } @@ -108,95 +138,63 @@ public List findAllUsers() { return userRepository.findAll(); } - public UserRegisterResponse.LevelStatusResponseDTO getLevel(User user) { - - return UserRegisterResponse.LevelStatusResponseDTO.builder() - .level(user.getLevel()) - .experience(user.getExperience()) - .build(); - } - public User getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Object principal = authentication.getPrincipal(); - // Principal이 User 객체인 경우 (현재 JWT 필터에서 이렇게 저장함) if (principal instanceof User) { return (User) principal; } - // Principal이 String(UUID)인 경우 (백업 처리) if (principal instanceof String uuidString) { UUID userUuid = UUID.fromString(uuidString); return userRepository - .findByUuid(userUuid) // UUID로 최신 유저 정보를 조회합니다. + .findByUuid(userUuid) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); } throw new CustomApiException(ErrorCode.INVALID_TOKEN, "인증 정보를 찾을 수 없습니다."); } - /** - * 사용자의 모든 텃밭 ID 목록을 슬롯 번호 순으로 정렬하여 반환합니다. - * - * @param user 현재 로그인한 사용자 - * @return 정렬된 텃밭 ID 목록 - */ @Transactional(readOnly = true) public List getMyGardenIds(User user) { - // LazyInitializationException을 방지하기 위해 Fetch Join으로 User와 gardens를 함께 조회 User managedUser = userRepository .findByIdWithGardens(user.getId()) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); return managedUser.getGardens().stream() - .filter(garden -> !garden.isLocked()) // [추가] 잠겨있지 않은(isLocked=false) 텃밭만 필터링합니다. + .filter(garden -> !garden.isLocked()) .sorted(Comparator.comparing(Garden::getSlotNumber)) .map(Garden::getId) .collect(Collectors.toList()); } - /** - * 특정 유저의 프로필 정보를 조회합니다. - * - * @param currentUserId 현재 로그인한 유저(프로필을 보고 있는 사람)의 ID - * @param profileUserId 프로필의 주인 ID - * @return UserProfileResponse DTO - */ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId) { - // 현재 로그인한 유저 User currentUser = userRepository - .findById(currentUserId) // ID로 최신 유저 정보를 조회합니다. + .findById(currentUserId) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); - // 프로필 조회할 유저 User profileUser = userRepository .findByIdWithGardensAndAvatars(profileUserId) .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); - // [수정] 프로필 이미지 URL을 사용자의 첫 번째 아바타 이미지로 설정 String profileImageUrl = profileUser.getGardens().stream() - .min(Comparator.comparing(Garden::getSlotNumber)) // 슬롯 번호가 가장 낮은 텃밭 찾기 - .map(Garden::getAvatar) // 해당 텃밭의 아바타 가져오기 - .filter(Objects::nonNull) // 아바타가 null이 아닌 경우 필터링 + .min(Comparator.comparing(Garden::getSlotNumber)) + .map(Garden::getAvatar) + .filter(Objects::nonNull) .map( avatar -> { - // AI 아바타처럼 Avatar에 직접 저장된 고유 imageUrl이 있다면 그것을 우선 사용합니다. if (avatar.getImageUrl() != null && !avatar.getImageUrl().isBlank()) { return avatar.getImageUrl(); } - // 없다면, AvatarMaster에 정의된 기본 이미지를 사용합니다. return avatar.getAvatarMaster().getDefaultImageUrl(); }) - .orElse(profileUser.getProfileImageUrl()); // 텃밭/아바타가 없으면 기존 프로필 이미지 사용 + .orElse(profileUser.getProfileImageUrl()); - // 1. 팔로우 상태 확인 - // currentUserId -> profileUserId 팔로우 여부 boolean isFollowing = followRepository.existsByFollowerAndFollowing(currentUser, profileUser); - // profileUserId -> currentUserId 팔로우 여부 (맞팔 여부 확인용) boolean isFollowedByProfileUser = followRepository.existsByFollowerAndFollowing(profileUser, currentUser); @@ -209,37 +207,30 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId followStatus = FollowStatus.NOT_FOLLOWING; } - // 2. 남에게 물 줄 수 있는 남은 횟수 계산 LocalDateTime startOfWateringDay = TimeUtil.getStartOfCurrentWateringDay(); - // 2-1. 프로필 주인이 남에게 물을 줄 수 있는 남은 횟수 (응답 DTO용) int profileUserWateringCount = friendWateringLogRepository.countByWaterGiverAndWateredAtAfter( profileUser, startOfWateringDay); long leftWaterCountForProfileUser = Math.max(0, (long) MAX_FRIEND_WATERING_PER_DAY - profileUserWateringCount); - // 2-2. 현재 접속 유저가 남에게 물을 줄 수 있는 남은 횟수 (물주기 가능 여부 판단용) int currentUserWateringCount = friendWateringLogRepository.countByWaterGiverAndWateredAtAfter( currentUser, startOfWateringDay); long leftWaterCountForCurrentUser = Math.max(0, (long) MAX_FRIEND_WATERING_PER_DAY - currentUserWateringCount); - // [성능 개선] N+1 문제를 해결하기 위해, 오늘 내가 물 준 정원 ID 목록을 한 번에 조회합니다. Set wateredGardenIds = friendWateringLogRepository.findWateredGardenIdsByGiverAndDate( currentUser.getId(), startOfWateringDay); - // 3. 프로필 주인의 정원 목록 및 물주기 가능 여부 계산 List userGardens = profileUser.getGardens().stream() - .filter(garden -> !garden.isLocked()) // isLocked가 false인 텃밭만 가져옵니다. + .filter(garden -> !garden.isLocked()) .sorted(Comparator.comparing(Garden::getSlotNumber)) .map( garden -> { - // DB를 반복 조회하는 대신, 미리 조회한 Set에서 확인하여 성능을 개선합니다. - // 로그인한 유저가 해당 정원에 물을 아직 안줬고 횟수가 남았다면 true boolean alreadyWateredByMe = wateredGardenIds.contains(garden.getId()); boolean isWateringAbleByMe = leftWaterCountForCurrentUser > 0 && !alreadyWateredByMe; @@ -251,7 +242,6 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId .avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl()) .build(); - // GardenResponse 대신 UserGardenDetailResponse를 빌드 return UserGardenDetailResponse.builder() .gardenId(garden.getId()) .avatarInfo(avatarInfoForGarden) @@ -260,7 +250,6 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId }) .collect(Collectors.toList()); - // 4. 최종 UserProfileResponse 빌드 return UserProfileResponse.builder() .id(profileUser.getId()) .userNickname(profileUser.getNickname()) diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java index a807fe68..adf1ce2b 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java @@ -61,4 +61,14 @@ List findCompletedCountsPerDay( + "ORDER BY d.createdAt DESC") List findByUserAndYearAndMonth( @Param("user") User user, @Param("year") int year, @Param("month") int month); + + @Query( + value = + "SELECT d.diary_id FROM diaries d WHERE d.is_public = true AND d.diary_id != :excludePostId ORDER BY RAND()", + nativeQuery = true) + List findRandomPublicDiaryIds( + @Param("excludePostId") Long excludePostId, Pageable pageable); + + // [추가] ID 목록으로 Diary 엔티티를 한 번에 조회하는 메서드 + List findAllByIdIn(List ids); } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java index e0e9cb48..77ab0a3e 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java @@ -14,18 +14,19 @@ public class DiaryFeedItemResponse implements FeedItemResponse { private final String title; private final String content; private final String imageUrl; - private final int likeCount; - private int commentCount; // Diary에 Comment 리스트가 있다고 가정 + private final long likeCount; // [수정] int -> long 타입으로 변경 (Repository 반환 타입과 일치) + private final long commentCount; private final LocalDateTime createdAt; - public DiaryFeedItemResponse(Diary diary) { + // [수정] 생성자에서 likeCount와 commentCount를 직접 받도록 변경 + public DiaryFeedItemResponse(Diary diary, long likeCount, long commentCount) { this.postId = diary.getId(); this.author = new AuthorResponse(diary.getUser()); this.title = diary.getTitle(); this.content = diary.getContent(); this.imageUrl = diary.getDiaryImage() != null ? diary.getDiaryImage().getImageUrl() : null; - this.likeCount = diary.getLikeCount(); + this.likeCount = likeCount; // [수정] diary 객체 대신 파라미터로 받은 값을 사용 + this.commentCount = commentCount; // 주석 해제 및 파라미터 값 사용 this.createdAt = diary.getCreatedAt(); - // this.commentCount = diary.getComments().size(); // Diary에 OneToMany Comment 관계가 필요 } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java index 76a15388..9a21135f 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java @@ -8,34 +8,33 @@ import java.util.List; public record DiaryInfoResponse( - Long id, // 일기 id - Long writerId, // 작성자 id - String writerName, // 작성자 이름 - String profileImageUrl, // 작성자 프로필 이미지 url - String title, // 일기 제목 - String content, // 일기 내용 - String imageUrl, // 일기 이미지 - boolean isLiked, // - int likeCount, + Long id, + Long writerId, + String writerName, + String profileImageUrl, + String title, + String content, + String imageUrl, + boolean isLiked, + long likeCount, // [수정] int -> long int commentCount, List comments, LocalDateTime createdAt, LocalDateTime updatedAt, @JsonProperty("isPublic") boolean isPublic) { - public static DiaryInfoResponse from(Diary diary, boolean isLiked) { + // [수정] 생성자에서 likeCount를 직접 받도록 변경 + public static DiaryInfoResponse from(Diary diary, boolean isLiked, long likeCount) { List commentDTOs = diary.getComments().stream().map(CommentResponseDTO::from).toList(); String imageUrl = diary.getDiaryImage() != null ? diary.getDiaryImage().getImageUrl() : null; String writerName = diary.getUser().getNickname(); - // 1. 유저의 아바타 리스트를 가져옵니다. List avatarList = diary.getUser().getAvatarList(); - // 2. 리스트가 비어있는지 확인한 후 프로필 이미지 URL을 설정합니다. - String profileImageUrl = null; // 기본값 설정 + String profileImageUrl = null; if (avatarList != null && !avatarList.isEmpty()) { - profileImageUrl = avatarList.get(0).getImageUrl(); // 리스트에 아이템이 있을 때만 접근 + profileImageUrl = avatarList.get(0).getImageUrl(); } return new DiaryInfoResponse( diary.getId(), @@ -46,7 +45,7 @@ public static DiaryInfoResponse from(Diary diary, boolean isLiked) { diary.getContent(), imageUrl, isLiked, - diary.getLikeCount(), + likeCount, // [수정] 파라미터로 받은 값을 사용 commentDTOs.size(), commentDTOs, diary.getCreatedAt(), diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java index 0a651a60..14b9b2a2 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java @@ -11,20 +11,17 @@ public class DiaryResponse { private final String content; private final String imageUrl; private final boolean isPublic; - private final int likeCount; + private final long likeCount; // [수정] int -> long private final LocalDateTime createdAt; private final LocalDateTime updatedAt; - // private AuthorDto author; // 작성자 정보가 필요하면 추가 - - // Lombok Builder나 생성자를 통해 더 유연하게 만들 수 있습니다. private DiaryResponse( Long diaryId, String title, String content, String imageUrl, boolean isPublic, - int likeCount, + long likeCount, // [수정] int -> long LocalDateTime createdAt, LocalDateTime updatedAt) { this.diaryId = diaryId; @@ -37,7 +34,8 @@ private DiaryResponse( this.updatedAt = updatedAt; } - public static DiaryResponse from(Diary diary) { + // [수정] 생성자에서 likeCount를 직접 받도록 변경 + public static DiaryResponse from(Diary diary, long likeCount) { String imageUrl = diary.getDiaryImage() != null ? diary.getDiaryImage().getImageUrl() : null; return new DiaryResponse( diary.getId(), @@ -45,7 +43,7 @@ public static DiaryResponse from(Diary diary) { diary.getContent(), imageUrl, diary.isPublic(), - diary.getLikeCount(), + likeCount, // [수정] 파라미터로 받은 값을 사용 diary.getCreatedAt(), diary.getUpdatedAt()); } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java index 3ce8965b..d6b72cc7 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java @@ -7,6 +7,7 @@ import com.example.cp_main_be.domain.mission.diary.dto.response.DiaryInfoResponse; import com.example.cp_main_be.domain.mission.diary.dto.response.DiaryResponse; import com.example.cp_main_be.domain.mission.diary.service.DiaryService; +import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; import com.example.cp_main_be.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -24,6 +25,7 @@ public class DiaryController { private final DiaryService diaryService; + private final LikeRepository likeRepository; // [추가] LikeRepository 주입 @Operation(summary = "일기 작성", description = "일기를 작성합니다") @PostMapping @@ -31,24 +33,25 @@ public ResponseEntity> createDiary( @AuthenticationPrincipal User user, @RequestBody @Valid CreateDiaryRequest request) { Long diaryId = diaryService.createDiary(user, request); Diary diary = diaryService.findDiaryById(diaryId); - return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(diary))); + // [수정] 새로 작성된 글의 좋아요는 0개이므로 0L을 전달합니다. + return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(diary, 0L))); } @Operation(summary = "내 일기 목록 조회", description = "내 일기 목록을 조회합니다") @GetMapping public ResponseEntity>> getMyDiaries( @AuthenticationPrincipal User user, @RequestParam int year, @RequestParam int month) { - List diaries = diaryService.findMyDiaries(user, year, month); - List responses = diaries.stream().map(DiaryResponse::from).toList(); - return ResponseEntity.ok(ApiResponse.success(responses)); + List diaries = diaryService.findMyDiariesAsResponses(user, year, month); + // [수정] 각 일기의 좋아요 수를 조회하여 DTO를 생성합니다. + return ResponseEntity.ok(ApiResponse.success(diaries)); } @Operation(summary = "특정 일기 조회", description = "특정 id로 일기를 조회합니다") @GetMapping("/{diaryId}") public ResponseEntity> getDiaryDetail( @PathVariable Long diaryId, @AuthenticationPrincipal User user) { + // DiaryInfoResponse는 서비스 계층에서 이미 likeCount를 처리하고 있으므로 수정 필요 없음 DiaryInfoResponse diaryInfo = diaryService.getDiaryInfo(diaryId, user); - // TODO: 비공개 글일 경우 작성자만 볼 수 있도록 하는 로직 추가 필요 return ResponseEntity.ok(ApiResponse.success(diaryInfo)); } @@ -59,7 +62,9 @@ public ResponseEntity> updateDiary( @PathVariable Long diaryId, @RequestBody @Valid UpdateDiaryRequest request) { Diary updatedDiary = diaryService.updateDiary(user.getId(), diaryId, request); - return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(updatedDiary))); + // [수정] 수정된 일기의 좋아요 수를 조회하여 DTO를 생성합니다. + long likeCount = likeRepository.countByTargetIdAndTargetType(updatedDiary.getId(), "DIARY"); + return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(updatedDiary, likeCount))); } @Operation(summary = "일기 삭제", description = "일기를 삭제합니다") diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java index e9800518..5af64e90 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java @@ -6,14 +6,18 @@ import com.example.cp_main_be.domain.mission.diary.dto.request.CreateDiaryRequest; import com.example.cp_main_be.domain.mission.diary.dto.request.UpdateDiaryRequest; import com.example.cp_main_be.domain.mission.diary.dto.response.DiaryInfoResponse; +import com.example.cp_main_be.domain.mission.diary.dto.response.DiaryResponse; import com.example.cp_main_be.domain.mission.diaryimage.domain.DiaryImage; import com.example.cp_main_be.domain.mission.diaryimage.domain.DiaryImageRepository; import com.example.cp_main_be.domain.mission.wishTree.WishTreeService; import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; import com.example.cp_main_be.global.common.CustomApiException; import com.example.cp_main_be.global.common.ErrorCode; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -92,7 +96,8 @@ public DiaryInfoResponse getDiaryInfo(Long diaryId, User currentUser) { } // 3. 조회된 엔티티와 '좋아요' 여부를 DTO의 팩토리 메서드로 변환하여 반환합니다. - return DiaryInfoResponse.from(diary, isLiked); + return DiaryInfoResponse.from( + diary, isLiked, likeRepository.countByTargetIdAndTargetType(diaryId, "DIARY")); } // 내 일기 목록 조회 (읽기 전용) @@ -101,6 +106,32 @@ public List findMyDiaries(User user, int year, int month) { return diaryRepository.findByUserAndYearAndMonth(user, year, month); } + // [추가] 내 일기 목록을 DTO로 변환하며 N+1 문제를 해결하는 메서드 + @Transactional(readOnly = true) + public List findMyDiariesAsResponses(User user, int year, int month) { + // 1. 먼저 일기 엔티티 목록을 조회합니다. + List diaries = findMyDiaries(user, year, month); + + if (diaries.isEmpty()) { + return Collections.emptyList(); + } + + // 2. 조회된 일기들의 ID를 추출합니다. + List diaryIds = diaries.stream().map(Diary::getId).collect(Collectors.toList()); + + // 3. 한 번의 쿼리로 모든 일기의 좋아요 수를 Map 형태로 가져옵니다. + Map likeCounts = likeRepository.countLikesByTargetIds(diaryIds, "DIARY"); + + // 4. 엔티티 목록을 순회하며 DTO로 변환합니다. 이때 Map에서 좋아요 수를 찾아 사용합니다. + return diaries.stream() + .map( + diary -> { + long likeCount = likeCounts.getOrDefault(diary.getId(), 0L); + return DiaryResponse.from(diary, likeCount); + }) + .collect(Collectors.toList()); + } + public Diary updateDiary(Long userId, Long diaryId, UpdateDiaryRequest request) { Diary diary = findDiaryById(diaryId); diff --git a/src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java b/src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java index 294c9284..5b8a78bc 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java @@ -75,11 +75,14 @@ public void completeDailyMission(Long userDailyMissionId) { final int DIARY_MISSION_COMPLETE_POINT = 15; MissionType type = userDailyMission.getDailyMissionMaster().getMissionType(); if (type.equals(MissionType.PHOTO)) - userService.addExperience(userService.getCurrentUser().getId(), PHOTO_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), (long) PHOTO_MISSION_COMPLETE_POINT); else if (type.equals(MissionType.QUIZ)) - userService.addExperience(userService.getCurrentUser().getId(), QUIZ_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), (long) QUIZ_MISSION_COMPLETE_POINT); else - userService.addExperience(userService.getCurrentUser().getId(), DIARY_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), (long) DIARY_MISSION_COMPLETE_POINT); userDailyMissionRepository.save(userDailyMission); } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java index 884d80d8..9cc4a74c 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java @@ -30,44 +30,40 @@ public class WishTree { @Builder public WishTree(User user) { this.user = user; - this.points = user.getExperience(); + // [수정] User에 더 이상 experience 필드가 없으므로, 신규 생성 시 0점으로 시작합니다. + this.points = 0L; this.stage = WishTreeStage.SPROUT; } - @Column(nullable = false) - private boolean isUnlockable = false; - - /** - * 포인트 추가 및 성장 로직 - * - * @param amount 추가할 포인트 - * @return 성장을 했는지 여부 - */ public void addPoints(Long points) { this.points += points; + evolveStageIfNeeded(); + } - // 이미 해금 가능 상태이거나, 다음 스테이지가 없으면 아무것도 하지 않음 - if (this.isUnlockable || this.stage.getNextStage() == null) { - return; - } - - // 다음 스테이지의 요구 포인트를 넘었는지 확인 - WishTreeStage nextStage = this.stage.getNextStage(); - if (this.points >= this.stage.getRequiredPointsForNextStage()) { - this.isUnlockable = true; // 👈 Stage를 바로 바꾸는 대신, 해금 가능 상태로 변경 - } + /** + * @deprecated canEvolve() 메서드 사용을 권장합니다. + * @return 진화 가능 여부 + */ + @Deprecated + public boolean isUnlockable() { + // [수정] 중복 로직을 제거하고 canEvolve()를 사용하도록 통일합니다. + return canEvolve(); } - public void evolveStage() { - if (!this.isUnlockable) { - // 해금 불가능한 상태에서 호출 시 예외 처리 또는 로깅 - return; + public boolean canEvolve() { + // 다음 스테이지가 없으면 진화 불가 + if (this.stage == WishTreeStage.FINAL) { + return false; } + return this.stage.getRequiredPointsForNextStage() <= this.points; + } - WishTreeStage nextStage = this.stage.getNextStage(); - if (nextStage != null) { - this.stage = nextStage; - this.isUnlockable = false; // 상태 플래그 초기화 + private void evolveStageIfNeeded() { + // 진화할 수 있는 동안 계속 반복 (경험치를 몰아서 얻었을 경우 대비) + while (canEvolve()) { + this.stage = this.stage.getNextStage(); + // 연관된 User 객체에 해금 가능 횟수를 1 늘려달라고 요청 + this.user.incrementUnlockableGardenCount(); } } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java index abc107cf..817fa718 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java @@ -20,13 +20,14 @@ public class WishTreeService { @Transactional public WishTree addPointsToWishTree(Long userId, Long points) { - // 1. 유저의 소망 나무를 찾거나, 없으면 새로 생성 WishTree wishTree = findOrCreateWishTree(userId); - // 2. WishTree 엔티티에 포인트 추가 및 해금 가능 상태로 변경 (내부 로직) - wishTree.addPoints(points); + // [추가] 나무 또는 최종 단계에 도달하면 더 이상 포인트를 추가하지 않음 + if (wishTree.getStage() == WishTreeStage.TREE || wishTree.getStage() == WishTreeStage.FINAL) { + return wishTree; // 아무 작업도 하지 않고 현재 상태 반환 + } - // 3. Stage 성장 및 이벤트 발행 로직 모두 제거 + wishTree.addPoints(points); return wishTree; } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java index c8c6247e..acdc8812 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java @@ -6,40 +6,23 @@ @Getter @RequiredArgsConstructor public enum WishTreeStage { - // 다음 단계로 가기 위해 필요한 '누적' 포인트와 이름 정의 - SPROUT(1000L, "새싹", 1L), - FLOWER(2150L, "꽃", 2L), // 1000 + 1150 - FRUIT(3450L, "열매", 3L), // 2150 + 1300 - TREE(4900L, "나무", 4L), // 3450 + 1450, 최대 텃밭 개수 - FINAL(Long.MAX_VALUE, "최종 나무", 4L); // 더 이상 성장 안함, 최대 텃밭 개수 + // 레벨, 해당 레벨 시작 누적 경험치, 다음 레벨 시작 누적 경험치, 한글이름, 해금되는 정원 수 + SPROUT(1, 0L, 1000L, "새싹", 1L), + FLOWER(2, 1000L, 2150L, "꽃", 2L), + FRUIT(3, 2150L, 3450L, "열매", 3L), + TREE(4, 3450L, 4900L, "나무", 4L), + FINAL(5, 4900L, Long.MAX_VALUE, "최종 나무", 4L); - private final Long requiredPointsForNextStage; + private final int level; + private final Long requiredPoints; // 이 단계 시작에 필요한 누적 경험치 + private final Long requiredPointsForNextStage; // 다음 단계 시작에 필요한 누적 경험치 private final String koreanName; private final Long maxGardens; - public static WishTreeStage getStageForPoints(Long points) { - if (points < SPROUT.requiredPointsForNextStage) return SPROUT; - if (points < FLOWER.requiredPointsForNextStage) return FLOWER; - if (points < FRUIT.requiredPointsForNextStage) return FRUIT; - if (points < TREE.requiredPointsForNextStage) return TREE; - return FINAL; - } - public WishTreeStage getNextStage() { if (this == FINAL) { return null; // 마지막 단계에서는 다음 단계 없음 } - // values()는 Enum 상수가 선언된 순서대로 배열을 반환합니다. - // 현재 단계의 순서(ordinal)에 +1을 하여 다음 단계를 찾습니다. return values()[this.ordinal() + 1]; } - - /** - * 현재 단계에서 가질 수 있는 최대 텃밭 개수를 반환합니다. - * - * @return 최대 텃밭 개수 - */ - public Long getMaxGardens() { - return this.maxGardens; - } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java index bf1ed71b..8d2193c1 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java @@ -40,7 +40,7 @@ public class AvatarPost { @Column(name = "like_count") @Builder.Default - private int likeCount = 0; + private long likeCount = 0; @OneToMany(mappedBy = "avatarPost", cascade = CascadeType.ALL) private List comments; diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java index 51d34a33..b5d60a85 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java @@ -25,5 +25,16 @@ public interface AvatarPostRepository extends JpaRepository { List findByUserInAndUser_IdNotIn( List users, List blockedUserIds, Pageable pageable); + // [추가] 랜덤으로 아바타 포스트 ID 목록을 조회하는 쿼리 (MySQL 기준) + // AvatarPost에는 isPublic 필드가 없으므로 모든 포스트를 대상으로 합니다. + @Query( + value = "SELECT ap.id FROM avatar_post ap WHERE ap.id != :excludePostId ORDER BY RAND()", + nativeQuery = true) + List findRandomPublicAvatarPostIds( + @Param("excludePostId") Long excludePostId, Pageable pageable); + + // [추가] ID 목록으로 AvatarPost 엔티티를 한 번에 조회하는 메서드 + List findAllByIdIn(List ids); + List findAllByUser_IdNotIn(List blockedUserIds, Pageable pageable); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java index 297bef73..c4deca44 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java @@ -12,16 +12,16 @@ public class AvatarPostFeedItemResponse implements FeedItemResponse { private final String postType = "AVATAR_POST"; private AuthorResponse author; private String caption; - private int likeCount; - private int commentCount; + private long likeCount; + private long commentCount; private LocalDateTime createdAt; - public AvatarPostFeedItemResponse(AvatarPost post) { + public AvatarPostFeedItemResponse(AvatarPost post, long likeCount, long commentCount) { this.postId = post.getId(); this.author = new AuthorResponse(post.getUser()); this.caption = post.getCaption(); - this.likeCount = post.getLikeCount(); - this.commentCount = post.getComments() != null ? post.getComments().size() : 0; + this.likeCount = likeCount; + this.commentCount = commentCount; this.createdAt = post.getCreatedAt(); } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java index 588811f9..9b653c66 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java @@ -14,7 +14,7 @@ public record PostInfoResponse( String content, String imageUrl, boolean isLiked, - int likeCount, + long likeCount, int commentCount, List comments, LocalDateTime createdAt, diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java index 05a8f08a..82d7fc31 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java @@ -4,6 +4,7 @@ import com.example.cp_main_be.domain.social.feed.dto.response.FeedResponse; import com.example.cp_main_be.domain.social.feed.service.FeedService; import com.example.cp_main_be.global.common.ApiResponse; +import com.example.cp_main_be.global.dto.FeedItemResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; @@ -37,4 +38,15 @@ public ResponseEntity>> getFeed( // List가 아닌 List로 응답 타입을 명확히 합니다. return ResponseEntity.ok(ApiResponse.success(feedItems)); } + + @Operation(summary = "랜덤 피드 조회 (무한 스크롤용)", description = "상세보기 화면에서 하단에 표시될 랜덤 피드를 불러옵니다") + @GetMapping("/random") + public ResponseEntity>> getRandomFeed( + @AuthenticationPrincipal User user, + @RequestParam Long excludePostId, // 화면 상단에 고정된 게시물 ID (중복 방지용) + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + List feedItems = feedService.getRandomFeed(excludePostId, page, size); + return ResponseEntity.ok(ApiResponse.success(feedItems)); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java index 27554257..a1bed5bb 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java @@ -2,17 +2,19 @@ import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; -import com.example.cp_main_be.domain.member.userblock.UserBlockRepository; +import com.example.cp_main_be.domain.mission.diary.domain.Diary; import com.example.cp_main_be.domain.mission.diary.domain.repository.DiaryRepository; +import com.example.cp_main_be.domain.mission.diary.dto.response.DiaryFeedItemResponse; +import com.example.cp_main_be.domain.social.avatarpost.domain.AvatarPost; import com.example.cp_main_be.domain.social.avatarpost.domain.repository.AvatarPostRepository; +import com.example.cp_main_be.domain.social.avatarpost.dto.AvatarPostFeedItemResponse; import com.example.cp_main_be.domain.social.feed.dto.response.FeedResponse; import com.example.cp_main_be.domain.social.follow.domain.Follow; import com.example.cp_main_be.domain.social.follow.domain.repository.FollowRepository; +import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; +import com.example.cp_main_be.global.dto.FeedItemResponse; import com.example.cp_main_be.global.exception.UserNotFoundException; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -30,7 +32,7 @@ public class FeedService { private final DiaryRepository diaryRepository; private final FollowRepository followRepository; private final AvatarPostRepository avatarPostRepository; - private final UserBlockRepository userBlockRepository; + private final LikeRepository likeRepository; public List getFeed(UUID currentUserUuid, String filter, int page, int size) { // 올바른 페이지네이션을 위해, 각 소스에서 요청된 페이지의 끝까지 데이터를 충분히 가져옵니다. @@ -97,4 +99,66 @@ public List getFeed(UUID currentUserUuid, String filter, int page, return sortedFeed.subList(start, end); } + + // [수정] 인스타그램 상세 스크롤과 같은 랜덤 피드 (일기 + 아바타 포스트) + public List getRandomFeed(Long excludePostId, int page, int size) { + // 1. 각 소스에서 가져올 항목 수를 계산합니다. (예: 10개 요청 시 5개씩) + int diarySize = size / 2; + int avatarPostSize = size - diarySize; + + // 2. 각 소스에서 랜덤 ID 목록을 가져옵니다. + List randomDiaryIds = + diaryRepository.findRandomPublicDiaryIds(excludePostId, PageRequest.of(page, diarySize)); + // AvatarPostRepository에 findRandomPublicAvatarPostIds 메서드가 있다고 가정합니다. + List randomAvatarPostIds = + avatarPostRepository.findRandomPublicAvatarPostIds( + excludePostId, PageRequest.of(page, avatarPostSize)); + + List feedItems = new ArrayList<>(); + + // 3. 일기(Diary) 처리 + if (!randomDiaryIds.isEmpty()) { + List diaries = diaryRepository.findAllByIdIn(randomDiaryIds); + Map likeCounts = likeRepository.countLikesByTargetIds(randomDiaryIds, "DIARY"); + // TODO: 댓글 수도 N+1 문제 없이 가져오도록 개선 필요 + // Map commentCounts = + // commentRepository.countCommentsByDiaryIds(randomDiaryIds); + + List diaryItems = + diaries.stream() + .map( + diary -> { + long likeCount = likeCounts.getOrDefault(diary.getId(), 0L); + int commentCount = diary.getComments().size(); // 현재는 N+1 발생 가능 + return new DiaryFeedItemResponse(diary, likeCount, commentCount); + }) + .toList(); + feedItems.addAll(diaryItems); + } + + // 4. 아바타 포스트(AvatarPost) 처리 + if (!randomAvatarPostIds.isEmpty()) { + // AvatarPostRepository에 findAllByIdIn 메서드가 있다고 가정합니다. + List avatarPosts = avatarPostRepository.findAllByIdIn(randomAvatarPostIds); + Map likeCounts = + likeRepository.countLikesByTargetIds(randomAvatarPostIds, "AVATAR_POST"); + + List avatarPostItems = + avatarPosts.stream() + .map( + post -> { + long likeCount = likeCounts.getOrDefault(post.getId(), 0L); + int commentCount = post.getComments().size(); // 현재는 N+1 발생 가능 + // AvatarPost를 FeedItemResponse로 변환하는 DTO가 있다고 가정합니다. + return new AvatarPostFeedItemResponse(post, likeCount, commentCount); + }) + .toList(); + feedItems.addAll(avatarPostItems); + } + + // 5. 최종 리스트를 섞어서 순서를 랜덤하게 만듭니다. + Collections.shuffle(feedItems); + + return feedItems; + } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java b/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java index 291a8451..22b04fd2 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java @@ -2,8 +2,13 @@ import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.social.like.domain.Like; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface LikeRepository extends JpaRepository { Optional findByUserAndTargetIdAndTargetType(User user, Long targetId, String targetType); @@ -17,4 +22,18 @@ public interface LikeRepository extends JpaRepository { // long countByAvatarPost(AvatarPost post); // Optional findByUserAndAvatarPost(User currentUser, AvatarPost post); + + long countByTargetIdAndTargetType(Long targetId, String targetType); + + // [추가] 여러 targetId에 대한 좋아요 수를 한 번의 쿼리로 조회 + @Query( + "SELECT l.targetId, COUNT(l.id) FROM Like l WHERE l.targetType = :targetType AND l.targetId IN :targetIds GROUP BY l.targetId") + List countByTargetIdsAndTargetType( + @Param("targetIds") List targetIds, @Param("targetType") String targetType); + + // [추가] 위 메서드를 편리하게 사용하기 위한 default 메서드 + default Map countLikesByTargetIds(List targetIds, String targetType) { + return countByTargetIdsAndTargetType(targetIds, targetType).stream() + .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java index 7ea30b29..79ab31b8 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java @@ -14,7 +14,6 @@ import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; import com.example.cp_main_be.global.exception.UserNotFoundException; import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +29,7 @@ public class LikeService { private final AvatarPostRepository avatarPostRepository; private final NotificationService notificationService; - public int addLike(Long userId, Long targetId, String targetType) { + public long addLike(Long userId, Long targetId, String targetType) { User user = userRepository .findById(userId) @@ -41,13 +40,8 @@ public int addLike(Long userId, Long targetId, String targetType) { } Like like = Like.builder().user(user).targetId(targetId).targetType(targetType).build(); - try { - likeRepository.save(like); - } catch (DataIntegrityViolationException e) { - throw new RuntimeException("이미 좋아요를 눌렀습니다."); - } + likeRepository.save(like); - // 좋아요 알림 로직 if ("feed".equalsIgnoreCase(targetType)) { Feed feed = feedRepository @@ -55,44 +49,41 @@ public int addLike(Long userId, Long targetId, String targetType) { .orElseThrow(() -> new IllegalArgumentException("피드를 찾을 수 없습니다.")); User receiver = feed.getUser(); - // 자기 자신에게는 알림을 보내지 않음 if (!receiver.getId().equals(userId)) { notificationService.send(receiver, user, NotificationType.FEED_LIKE, "/feeds/" + targetId); } - // 일단 보류 - return 0; } else if ("DIARY".equalsIgnoreCase(targetType)) { Diary diary = diaryRepository .findById(targetId) .orElseThrow(() -> new IllegalArgumentException("일기를 찾을 수 없습니다.")); - diary.increaseLikeCount(); + // [제거] diary.increaseLikeCount(); User receiver = diary.getUser(); if (!receiver.getId().equals(userId)) { notificationService.send( receiver, user, NotificationType.DIARY_LIKE, "/diaries/" + targetId); } - return diary.getLikeCount(); } else if ("AVATAR_POST".equalsIgnoreCase(targetType)) { AvatarPost avatarPost = avatarPostRepository .findById(targetId) .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다.")); - avatarPost.increaseLikeCount(); + // [제거] avatarPost.increaseLikeCount(); User receiver = avatarPost.getUser(); if (!receiver.getId().equals(userId)) { notificationService.send( receiver, user, NotificationType.AVATAR_POST_LIKE, "/avatar-posts/" + targetId); } - return avatarPost.getLikeCount(); } - return 0; + + // [수정] 실제 Like 개수를 세어서 반환 + return likeRepository.countByTargetIdAndTargetType(targetId, targetType); } - public int removeLike(Long userId, Long targetId, String targetType) { + public long removeLike(Long userId, Long targetId, String targetType) { User user = userRepository .findById(userId) @@ -101,26 +92,16 @@ public int removeLike(Long userId, Long targetId, String targetType) { Like like = likeRepository .findByUserAndTargetIdAndTargetType(user, targetId, targetType) - .orElseThrow(() -> new RuntimeException("좋아요를 찾을 수 없습니다.")); // TODO: Custom Exception + .orElseThrow(() -> new RuntimeException("좋아요를 찾을 수 없습니다.")); likeRepository.delete(like); if ("diary".equalsIgnoreCase(targetType)) { - Diary diary = - diaryRepository - .findById(targetId) - .orElseThrow(() -> new IllegalArgumentException("일기를 찾을 수 없습니다.")); - - diary.decreaseLikeCount(); - return diary.getLikeCount(); + // [제거] diary.decreaseLikeCount(); } else if ("avatar_post".equalsIgnoreCase(targetType)) { - AvatarPost avatarPost = - avatarPostRepository - .findById(targetId) - .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다.")); - - avatarPost.decreaseLikeCount(); - return avatarPost.getLikeCount(); + // [제거] avatarPost.decreaseLikeCount(); } - return 0; + + // [수정] 실제 Like 개수를 세어서 반환 + return likeRepository.countByTargetIdAndTargetType(targetId, targetType); } } diff --git a/src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java b/src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java index e0e45b7d..e435f929 100644 --- a/src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java +++ b/src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java @@ -9,9 +9,9 @@ public interface FeedItemResponse { AuthorResponse getAuthor(); - int getLikeCount(); + long getLikeCount(); - int getCommentCount(); + long getCommentCount(); LocalDateTime getCreatedAt(); } diff --git a/src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java b/src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java index 7a4d38d7..7faa2d6e 100644 --- a/src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java +++ b/src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java @@ -1,5 +1,6 @@ package com.example.cp_main_be.global.listener; +import com.example.cp_main_be.domain.garden.garden.service.GardenService; import com.example.cp_main_be.domain.member.notification.domain.NotificationType; import com.example.cp_main_be.domain.member.notification.service.NotificationService; import com.example.cp_main_be.domain.member.user.domain.User; @@ -34,6 +35,7 @@ public class NotificationEventListener { private final DiaryRepository diaryRepository; private final AvatarPostRepository avatarPostRepository; private final WishTreeService wishTreeService; + private final GardenService gardenService; @EventListener @Transactional diff --git a/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql new file mode 100644 index 00000000..9611253d --- /dev/null +++ b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql @@ -0,0 +1,5 @@ +-- users 테이블에 정원 해금 카운트 컬럼 추가하고 기본값을 1로 설정 +ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 1; + +-- wish_tree 테이블에서 더 이상 사용하지 않는 is_unlockable 컬럼 삭제 +ALTER TABLE wish_tree DROP COLUMN is_unlockable; From 4fe3c1a70beadea33db203b08be7c3282721d083 Mon Sep 17 00:00:00 2001 From: lejuho Date: Fri, 26 Sep 2025 16:02:02 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix=20:=20=EB=A1=9C=EC=A7=81=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/garden/garden/domain/Garden.java | 3 ++- .../garden/garden/service/GardenService.java | 5 ++++- .../domain/member/user/domain/User.java | 3 +++ .../domain/repository/DiaryRepository.java | 3 ++- .../social/feed/service/FeedService.java | 22 +++++++++++++------ .../domain/repository/LikeRepository.java | 3 +++ .../social/like/service/LikeService.java | 9 ++++++-- .../V3__refactor_garden_unlock_logic.sql | 3 +++ 8 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java b/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java index fc34c21e..16c1ab56 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java @@ -96,7 +96,8 @@ public long getWaterableByOwnerInSeconds() { } LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4); - return Duration.between(now, nextWaterableTime).getSeconds(); + long remainingSeconds = Duration.between(now, nextWaterableTime).getSeconds(); + return Math.max(0L, remainingSeconds); } public void unlock() { diff --git a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java index 8907c178..d47c3e0c 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java @@ -149,6 +149,7 @@ public void sunlightGarden(Long actorId, Long gardenId) { garden.recordSunlightTime(); } + @Transactional public void unlockNextGarden(Long userId) { User user = userRepository @@ -159,13 +160,15 @@ public void unlockNextGarden(Long userId) { throw new CustomApiException(ErrorCode.GARDEN_SLOT_LOCKED, "해금할 수 있는 정원이 없습니다."); } + // 먼저 정원 조회 (실패 시 여기서 예외) Garden gardenToUnlock = gardenRepository .findFirstByUserAndIsLockedIsTrueOrderBySlotNumberAsc(user) .orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND, "해금할 정원이 없습니다.")); + // 정원이 존재할 때만 실행 gardenToUnlock.unlock(); - user.decrementUnlockableGardenCount(); + user.decrementUnlockableGardenCount(); // 성공 확정 후 카운트 감소 } @Transactional diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java index 92edc438..97579960 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java @@ -116,6 +116,9 @@ public void incrementUnlockableGardenCount() { } public void decrementUnlockableGardenCount() { + if (this.unlockableGardenCount == null || this.unlockableGardenCount <= 0) { + throw new IllegalStateException("unlockableGardenCount는 0보다 작을 수 없습니다."); + } this.unlockableGardenCount--; } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java index adf1ce2b..2170b13e 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java @@ -64,7 +64,8 @@ List findByUserAndYearAndMonth( @Query( value = - "SELECT d.diary_id FROM diaries d WHERE d.is_public = true AND d.diary_id != :excludePostId ORDER BY RAND()", + "SELECT d.diary_id FROM diaries d WHERE d.is_public = true " + + "AND (:excludePostId IS NULL OR d.diary_id <> :excludePostId) ORDER BY RAND()", nativeQuery = true) List findRandomPublicDiaryIds( @Param("excludePostId") Long excludePostId, Pageable pageable); diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java index a1bed5bb..e951e638 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java @@ -106,13 +106,21 @@ public List getRandomFeed(Long excludePostId, int page, int si int diarySize = size / 2; int avatarPostSize = size - diarySize; - // 2. 각 소스에서 랜덤 ID 목록을 가져옵니다. - List randomDiaryIds = - diaryRepository.findRandomPublicDiaryIds(excludePostId, PageRequest.of(page, diarySize)); - // AvatarPostRepository에 findRandomPublicAvatarPostIds 메서드가 있다고 가정합니다. - List randomAvatarPostIds = - avatarPostRepository.findRandomPublicAvatarPostIds( - excludePostId, PageRequest.of(page, avatarPostSize)); + if (size <= 0) { + return Collections.emptyList(); + } + + List randomDiaryIds = Collections.emptyList(); + if (diarySize > 0) { + randomDiaryIds = + diaryRepository.findRandomPublicDiaryIds(excludePostId, PageRequest.of(page, diarySize)); + } + List randomAvatarPostIds = Collections.emptyList(); + if (avatarPostSize > 0) { + randomAvatarPostIds = + avatarPostRepository.findRandomPublicAvatarPostIds( + excludePostId, PageRequest.of(page, avatarPostSize)); + } List feedItems = new ArrayList<>(); diff --git a/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java b/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java index 22b04fd2..ae15bfc1 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java @@ -33,6 +33,9 @@ List countByTargetIdsAndTargetType( // [추가] 위 메서드를 편리하게 사용하기 위한 default 메서드 default Map countLikesByTargetIds(List targetIds, String targetType) { + if (targetIds == null || targetIds.isEmpty()) { + return Map.of(); + } return countByTargetIdsAndTargetType(targetIds, targetType).stream() .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java index 79ab31b8..6bbbcc01 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java @@ -14,6 +14,7 @@ import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; import com.example.cp_main_be.global.exception.UserNotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,8 +40,12 @@ public long addLike(Long userId, Long targetId, String targetType) { throw new RuntimeException("이미 좋아요를 눌렀습니다."); // TODO: Custom Exception } - Like like = Like.builder().user(user).targetId(targetId).targetType(targetType).build(); - likeRepository.save(like); + try { + Like like = Like.builder().user(user).targetId(targetId).targetType(targetType).build(); + likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + throw new IllegalArgumentException("이미 좋아요를 누르셨습니다."); + } if ("feed".equalsIgnoreCase(targetType)) { Feed feed = diff --git a/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql index 9611253d..36b0e1ca 100644 --- a/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql +++ b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql @@ -3,3 +3,6 @@ ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 1; -- wish_tree 테이블에서 더 이상 사용하지 않는 is_unlockable 컬럼 삭제 ALTER TABLE wish_tree DROP COLUMN is_unlockable; + +-- avatar_post.like_count를 BIGINT로 확장 +ALTER TABLE avatar_post MODIFY COLUMN like_count BIGINT NOT NULL DEFAULT 0; \ No newline at end of file From a81b616b1acb637a15042d5081cd89ce89a6c56a Mon Sep 17 00:00:00 2001 From: lejuho Date: Fri, 26 Sep 2025 17:09:13 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20N+1=20=EB=B0=8F=20Race=20Condit?= =?UTF-8?q?ion=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/DiaryRepository.java | 14 + .../repository/AvatarPostRepository.java | 13 + .../domain/repository/CommentRepository.java | 24 +- .../social/comment/dto/CommentCountDto.java | 19 ++ .../feed/presentation/FeedController.java | 7 +- .../social/feed/service/FeedService.java | 247 ++++++++++-------- .../social/like/service/LikeService.java | 1 + .../V3__refactor_garden_unlock_logic.sql | 6 +- 8 files changed, 219 insertions(+), 112 deletions(-) create mode 100644 src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java index 2170b13e..86e1fbe9 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java @@ -72,4 +72,18 @@ List findRandomPublicDiaryIds( // [추가] ID 목록으로 Diary 엔티티를 한 번에 조회하는 메서드 List findAllByIdIn(List ids); + + // 커서 기반 페이지네이션을 위한 메서드 (전체 피드용) + // user와 comments를 함께 조회하여 N+1 문제 해결 + @Query( + "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") + List findPublicDiariesWithCursor(@Param("cursor") LocalDateTime cursor, Pageable pageable); + + // 커서 기반 페이지네이션을 위한 메서드 (팔로잉 피드용) + @Query( + "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.user IN :followingUsers AND d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") + List findFollowingDiariesWithCursor( + @Param("followingUsers") List followingUsers, + @Param("cursor") LocalDateTime cursor, + Pageable pageable); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java index b5d60a85..5fdf9663 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java @@ -2,9 +2,11 @@ import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.social.avatarpost.domain.AvatarPost; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -37,4 +39,15 @@ List findRandomPublicAvatarPostIds( List findAllByIdIn(List ids); List findAllByUser_IdNotIn(List blockedUserIds, Pageable pageable); + + // 커서 기반 페이지네이션을 위한 메서드 (전체 피드용) + // @EntityGraph를 사용하면 더 깔끔하게 N+1 문제 해결 가능 + @EntityGraph(attributePaths = {"user"}) // user 정보를 함께 EAGER 조회 + List findByCreatedAtBeforeOrderByCreatedAtDesc( + LocalDateTime cursor, Pageable pageable); + + // 커서 기반 페이지네이션을 위한 메서드 (팔로잉 피드용) + @EntityGraph(attributePaths = {"user"}) + List findByUserInAndCreatedAtBeforeOrderByCreatedAtDesc( + List followingUsers, LocalDateTime cursor, Pageable pageable); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java b/src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java index af6b1b4e..6f94844a 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java @@ -1,6 +1,28 @@ package com.example.cp_main_be.domain.social.comment.domain.repository; import com.example.cp_main_be.domain.social.comment.domain.Comment; +import com.example.cp_main_be.domain.social.comment.dto.CommentCountDto; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface CommentRepository extends JpaRepository {} +public interface CommentRepository extends JpaRepository { + + // 1. AvatarPost ID 목록으로 댓글 수 조회 + @Query( + "SELECT new com.example.cp_main_be.domain.social.comment.dto.CommentCountDto(c.avatarPost.id, COUNT(c.id)) " + + "FROM Comment c " + + "WHERE c.avatarPost.id IN :avatarPostIds " + + "GROUP BY c.avatarPost.id") + List countCommentsByAvatarPostIds( + @Param("avatarPostIds") List avatarPostIds); + + // 2. Diary ID 목록으로 댓글 수 조회 + @Query( + "SELECT new com.example.cp_main_be.domain.social.comment.dto.CommentCountDto(c.diary.id, COUNT(c.id)) " + + "FROM Comment c " + + "WHERE c.diary.id IN :diaryIds " + + "GROUP BY c.diary.id") + List countCommentsByDiaryIds(@Param("diaryIds") List diaryIds); +} diff --git a/src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java b/src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java new file mode 100644 index 00000000..94a05e9e --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java @@ -0,0 +1,19 @@ +package com.example.cp_main_be.domain.social.comment.dto; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class CommentCountDto { + // Setters (필요한 경우) + // Getters + private Long targetId; + private Long count; + + // JPA 쿼리에서 사용할 생성자 + public CommentCountDto(Long targetId, Long count) { + this.targetId = targetId; + this.count = count; + } +} diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java index 82d7fc31..dd884c8d 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java @@ -7,8 +7,10 @@ import com.example.cp_main_be.global.dto.FeedItemResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -29,11 +31,12 @@ public class FeedController { public ResponseEntity>> getFeed( @AuthenticationPrincipal User user, // 인증된 사용자 정보 @RequestParam(required = false) String filter, - @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime cursor, @RequestParam(defaultValue = "20") int size) { // 서비스 메서드에 현재 사용자의 UUID와 필터 값을 전달 - List feedItems = feedService.getFeed(user.getUuid(), filter, page, size); + List feedItems = feedService.getFeed(user.getUuid(), filter, cursor, size); // List가 아닌 List로 응답 타입을 명확히 합니다. return ResponseEntity.ok(ApiResponse.success(feedItems)); diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java index e951e638..78b9a8a0 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java @@ -8,18 +8,22 @@ import com.example.cp_main_be.domain.social.avatarpost.domain.AvatarPost; import com.example.cp_main_be.domain.social.avatarpost.domain.repository.AvatarPostRepository; import com.example.cp_main_be.domain.social.avatarpost.dto.AvatarPostFeedItemResponse; +import com.example.cp_main_be.domain.social.comment.domain.repository.CommentRepository; +import com.example.cp_main_be.domain.social.comment.dto.CommentCountDto; import com.example.cp_main_be.domain.social.feed.dto.response.FeedResponse; import com.example.cp_main_be.domain.social.follow.domain.Follow; import com.example.cp_main_be.domain.social.follow.domain.repository.FollowRepository; import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; import com.example.cp_main_be.global.dto.FeedItemResponse; import com.example.cp_main_be.global.exception.UserNotFoundException; +import java.time.LocalDateTime; import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,140 +37,169 @@ public class FeedService { private final FollowRepository followRepository; private final AvatarPostRepository avatarPostRepository; private final LikeRepository likeRepository; + private final CommentRepository commentRepository; - public List getFeed(UUID currentUserUuid, String filter, int page, int size) { - // 올바른 페이지네이션을 위해, 각 소스에서 요청된 페이지의 끝까지 데이터를 충분히 가져옵니다. - // 예: 2페이지(page=1) 20개(size=20) 요청 시, (1+1)*20=40개의 후보를 가져옵니다. - // 이는 메모리 사용량과 성능에 영향을 줄 수 있으므로, 매우 깊은 페이지네이션에는 다른 전략(커서 기반)이 더 좋습니다. - int limit = (page + 1) * size; - Pageable candidatePageable = - PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); + // FeedService.java + + // public List getFeed(UUID currentUserUuid, String filter, int page, int size) { + // ... } + // 위 메서드를 아래와 같이 변경합니다. + + public List getFeed( + UUID currentUserUuid, String filter, LocalDateTime cursor, int size) { + // 커서가 없으면(최초 요청) 현재 시간으로 설정 + if (cursor == null) { + cursor = LocalDateTime.now(); + } + Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴 User currentUser = userRepository .findByUuid(currentUserUuid) .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); - // [추가] 1. 현재 사용자가 차단한 유저 ID 목록을 먼저 조회합니다. - List blockedUserIds = Collections.emptyList(); - - Stream diaryStream; - Stream avatarPostStream; + List diaries; + List avatarPosts; if ("following".equalsIgnoreCase(filter)) { List followingUsers = followRepository.findByFollower(currentUser).stream().map(Follow::getFollowing).toList(); - // [수정] 2. 차단된 유저를 제외하고 조회 - diaryStream = - diaryRepository - .findByUserInAndIsPublicIsTrueAndUser_IdNotIn( - followingUsers, blockedUserIds, candidatePageable) - .stream() - .map(FeedResponse::from); - avatarPostStream = - avatarPostRepository - .findByUserInAndUser_IdNotIn(followingUsers, blockedUserIds, candidatePageable) - .stream() - .map(FeedResponse::from); - + diaries = diaryRepository.findFollowingDiariesWithCursor(followingUsers, cursor, pageable); + avatarPosts = + avatarPostRepository.findByUserInAndCreatedAtBeforeOrderByCreatedAtDesc( + followingUsers, cursor, pageable); } else { - // [수정] 2. 차단된 유저를 제외하고 조회 - diaryStream = - diaryRepository - .findByIsPublicIsTrue(candidatePageable) // NotIn 제거 - .stream() - .map(FeedResponse::from); - avatarPostStream = - avatarPostRepository - .findAllBy(candidatePageable) // NotIn 제거 - .stream() - .map(FeedResponse::from); - } - - // 3. 두 스트림을 합치고, 전체 목록을 생성 시간 기준으로 다시 정렬합니다. - List sortedFeed = - Stream.concat(diaryStream, avatarPostStream) - .sorted(Comparator.comparing(FeedResponse::createdAt).reversed()) - .toList(); - - // 4. 정렬된 전체 목록에서 요청된 페이지에 해당하는 부분만 잘라내어 반환합니다. - int start = page * size; - if (start >= sortedFeed.size()) { - return Collections.emptyList(); // 요청된 페이지가 데이터 범위를 벗어난 경우 빈 리스트 반환 + diaries = diaryRepository.findPublicDiariesWithCursor(cursor, pageable); + avatarPosts = + avatarPostRepository.findByCreatedAtBeforeOrderByCreatedAtDesc(cursor, pageable); } - int end = Math.min(start + size, sortedFeed.size()); - return sortedFeed.subList(start, end); + // 두 스트림을 합치고, size만큼만 잘라낸 후, DTO로 변환 + return Stream.concat( + diaries.stream().map(FeedResponse::from), avatarPosts.stream().map(FeedResponse::from)) + .sorted(Comparator.comparing(FeedResponse::createdAt).reversed()) + .limit(size) + .toList(); } - // [수정] 인스타그램 상세 스크롤과 같은 랜덤 피드 (일기 + 아바타 포스트) public List getRandomFeed(Long excludePostId, int page, int size) { - // 1. 각 소스에서 가져올 항목 수를 계산합니다. (예: 10개 요청 시 5개씩) - int diarySize = size / 2; - int avatarPostSize = size - diarySize; - if (size <= 0) { return Collections.emptyList(); } - List randomDiaryIds = Collections.emptyList(); - if (diarySize > 0) { - randomDiaryIds = - diaryRepository.findRandomPublicDiaryIds(excludePostId, PageRequest.of(page, diarySize)); - } - List randomAvatarPostIds = Collections.emptyList(); - if (avatarPostSize > 0) { - randomAvatarPostIds = - avatarPostRepository.findRandomPublicAvatarPostIds( - excludePostId, PageRequest.of(page, avatarPostSize)); - } + // 1. 각 소스에서 가져올 항목 수를 계산합니다. + int diarySize = size / 2; + int avatarPostSize = size - diarySize; - List feedItems = new ArrayList<>(); + // 2. 각 소스에서 랜덤 ID 목록을 조회합니다. + List randomDiaryIds = + (diarySize > 0) + ? diaryRepository.findRandomPublicDiaryIds( + excludePostId, PageRequest.of(page, diarySize)) + : Collections.emptyList(); - // 3. 일기(Diary) 처리 - if (!randomDiaryIds.isEmpty()) { - List diaries = diaryRepository.findAllByIdIn(randomDiaryIds); - Map likeCounts = likeRepository.countLikesByTargetIds(randomDiaryIds, "DIARY"); - // TODO: 댓글 수도 N+1 문제 없이 가져오도록 개선 필요 - // Map commentCounts = - // commentRepository.countCommentsByDiaryIds(randomDiaryIds); - - List diaryItems = - diaries.stream() - .map( - diary -> { - long likeCount = likeCounts.getOrDefault(diary.getId(), 0L); - int commentCount = diary.getComments().size(); // 현재는 N+1 발생 가능 - return new DiaryFeedItemResponse(diary, likeCount, commentCount); - }) - .toList(); - feedItems.addAll(diaryItems); - } + List randomAvatarPostIds = + (avatarPostSize > 0) + ? avatarPostRepository.findRandomPublicAvatarPostIds( + excludePostId, PageRequest.of(page, avatarPostSize)) + : Collections.emptyList(); - // 4. 아바타 포스트(AvatarPost) 처리 - if (!randomAvatarPostIds.isEmpty()) { - // AvatarPostRepository에 findAllByIdIn 메서드가 있다고 가정합니다. - List avatarPosts = avatarPostRepository.findAllByIdIn(randomAvatarPostIds); - Map likeCounts = - likeRepository.countLikesByTargetIds(randomAvatarPostIds, "AVATAR_POST"); - - List avatarPostItems = - avatarPosts.stream() - .map( - post -> { - long likeCount = likeCounts.getOrDefault(post.getId(), 0L); - int commentCount = post.getComments().size(); // 현재는 N+1 발생 가능 - // AvatarPost를 FeedItemResponse로 변환하는 DTO가 있다고 가정합니다. - return new AvatarPostFeedItemResponse(post, likeCount, commentCount); - }) - .toList(); - feedItems.addAll(avatarPostItems); - } + // 3. 각 소스의 데이터를 가져와 FeedItemResponse로 변환합니다. + List feedItems = new ArrayList<>(); - // 5. 최종 리스트를 섞어서 순서를 랜덤하게 만듭니다. + // Diary 처리 + List diaryItems = + fetchAndMapFeedItems( + randomDiaryIds, + "DIARY", + diaryRepository::findAllByIdIn, + Diary::getId, + (diary, likeCount, commentCount) -> + new DiaryFeedItemResponse(diary, likeCount, commentCount)); + feedItems.addAll(diaryItems); + + // AvatarPost 처리 + List avatarPostItems = + fetchAndMapFeedItems( + randomAvatarPostIds, + "AVATAR_POST", + avatarPostRepository::findAllByIdIn, + AvatarPost::getId, + (post, likeCount, commentCount) -> + new AvatarPostFeedItemResponse(post, likeCount, commentCount)); + feedItems.addAll(avatarPostItems); + + // 4. 최종 리스트를 섞어서 순서를 랜덤하게 만듭니다. Collections.shuffle(feedItems); return feedItems; } + + /** + * ID 목록을 기반으로 엔티티와 관련 데이터(좋아요, 댓글 수)를 조회하고 FeedItemResponse로 매핑하는 공통 메서드 + * + * @param ids 조회할 엔티티 ID 목록 + * @param type 대상 타입 문자열 ("DIARY", "AVATAR_POST") + * @param entityFetcher ID 목록으로 엔티티 목록을 조회하는 함수 + * @param idExtractor 엔티티에서 ID를 추출하는 함수 + * @param responseMapper 엔티티와 카운트 정보로 최종 DTO를 생성하는 함수 + * @return 변환된 FeedItemResponse 목록 + * @param 엔티티 타입 (Diary, AvatarPost) + * @param 응답 DTO 타입 (DiaryFeedItemResponse, AvatarPostFeedItemResponse) + */ + private List fetchAndMapFeedItems( + List ids, + String type, + Function, List> entityFetcher, + Function idExtractor, + TriFunction responseMapper) { + + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + + // 엔티티 조회 + List entities = entityFetcher.apply(ids); + + // --- 이 부분이 수정되었습니다 --- + // 좋아요 및 댓글 수를 타입에 따라 분기하여 조회합니다. + Map likeCounts; + List commentCountDtos; + + if ("DIARY".equalsIgnoreCase(type)) { + likeCounts = + likeRepository.countLikesByTargetIds(ids, "DIARY"); // likeRepository도 분리되었다면 수정 필요 + commentCountDtos = commentRepository.countCommentsByDiaryIds(ids); + } else if ("AVATAR_POST".equalsIgnoreCase(type)) { + likeCounts = + likeRepository.countLikesByTargetIds(ids, "AVATAR_POST"); // likeRepository도 분리되었다면 수정 필요 + commentCountDtos = commentRepository.countCommentsByAvatarPostIds(ids); + } else { + likeCounts = Collections.emptyMap(); + commentCountDtos = Collections.emptyList(); + } + // --- 수정 끝 --- + + Map commentCounts = + commentCountDtos.stream() + .collect(Collectors.toMap(CommentCountDto::getTargetId, CommentCountDto::getCount)); + + // 엔티티를 최종 DTO로 변환 + return entities.stream() + .map( + entity -> { + long entityId = idExtractor.apply(entity); + long likeCount = likeCounts.getOrDefault(entityId, 0L); + int commentCount = commentCounts.getOrDefault(entityId, 0L).intValue(); + return responseMapper.apply(entity, likeCount, commentCount); + }) + .toList(); + } + + // 3개의 파라미터를 받는 함수형 인터페이스가 기본 API에 없으므로 직접 정의합니다. + @FunctionalInterface + interface TriFunction { + R apply(T t, U u, V v); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java index 6bbbcc01..d78eb095 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java @@ -43,6 +43,7 @@ public long addLike(Long userId, Long targetId, String targetType) { try { Like like = Like.builder().user(user).targetId(targetId).targetType(targetType).build(); likeRepository.save(like); + likeRepository.flush(); } catch (DataIntegrityViolationException e) { throw new IllegalArgumentException("이미 좋아요를 누르셨습니다."); } diff --git a/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql index 36b0e1ca..827b5120 100644 --- a/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql +++ b/src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql @@ -1,8 +1,10 @@ -- users 테이블에 정원 해금 카운트 컬럼 추가하고 기본값을 1로 설정 -ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 0; -- wish_tree 테이블에서 더 이상 사용하지 않는 is_unlockable 컬럼 삭제 ALTER TABLE wish_tree DROP COLUMN is_unlockable; -- avatar_post.like_count를 BIGINT로 확장 -ALTER TABLE avatar_post MODIFY COLUMN like_count BIGINT NOT NULL DEFAULT 0; \ No newline at end of file +ALTER TABLE avatar_post ALTER COLUMN like_count TYPE BIGINT; +ALTER TABLE avatar_post ALTER COLUMN like_count SET DEFAULT 0; +ALTER TABLE avatar_post ALTER COLUMN like_count SET NOT NULL; \ No newline at end of file From 1ed90d2b2835014fb7dd15b63e155b9bec4450b9 Mon Sep 17 00:00:00 2001 From: lejuho Date: Tue, 30 Sep 2025 09:51:21 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix=20:=20=ED=94=BC=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/DiaryRepository.java | 20 +++ .../repository/AvatarPostRepository.java | 18 +++ .../feed/dto/request/RandomFeedRequest.java | 20 +++ .../feed/dto/response/FeedScrollResponse.java | 13 ++ .../feed/presentation/FeedController.java | 31 +++-- .../social/feed/service/FeedService.java | 121 +++++++++++------- 6 files changed, 163 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java create mode 100644 src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java index 86e1fbe9..62c110a4 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java @@ -86,4 +86,24 @@ List findFollowingDiariesWithCursor( @Param("followingUsers") List followingUsers, @Param("cursor") LocalDateTime cursor, Pageable pageable); + + // ⭐⭐ 새로 추가: 리스트 제외용 메서드 + // SpEL로 빈 리스트 체크 (가장 안전한 방법) + @Query( + value = + """ + SELECT d.diary_id + FROM diaries d + WHERE d.is_public = true + AND ( + :#{#excludeIds == null} = true + OR :#{#excludeIds.isEmpty()} = true + OR d.diary_id NOT IN (:excludeIds) + ) + ORDER BY RAND() + LIMIT :limit + """, + nativeQuery = true) + List findRandomPublicDiaryIdsExcluding( + @Param("excludeIds") List excludeIds, @Param("limit") int limit); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java index 5fdf9663..46dcd9a6 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java @@ -50,4 +50,22 @@ List findByCreatedAtBeforeOrderByCreatedAtDesc( @EntityGraph(attributePaths = {"user"}) List findByUserInAndCreatedAtBeforeOrderByCreatedAtDesc( List followingUsers, LocalDateTime cursor, Pageable pageable); + + // AvatarPostRepository.java + @Query( + value = + """ + SELECT ap.id + FROM avatar_post ap + WHERE ( + :#{#excludeIds == null} = true + OR :#{#excludeIds.isEmpty()} = true + OR ap.id NOT IN (:excludeIds) + ) + ORDER BY RAND() + LIMIT :limit + """, + nativeQuery = true) + List findRandomPublicAvatarPostIdsExcluding( + @Param("excludeIds") List excludeIds, @Param("limit") int limit); } diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java b/src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java new file mode 100644 index 00000000..dc412dfe --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java @@ -0,0 +1,20 @@ +package com.example.cp_main_be.domain.social.feed.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RandomFeedRequest { + private List excludeDiaryIds; + private List excludeAvatarPostIds; + + @Min(1) + @Max(50) + private int size = 10; +} diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java b/src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java new file mode 100644 index 00000000..716b8cfe --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java @@ -0,0 +1,13 @@ +package com.example.cp_main_be.domain.social.feed.dto.response; + +import com.example.cp_main_be.global.dto.FeedItemResponse; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FeedScrollResponse { + private List items; + private boolean hasMore; +} diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java index dd884c8d..0477cbf5 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java @@ -1,22 +1,21 @@ package com.example.cp_main_be.domain.social.feed.presentation; import com.example.cp_main_be.domain.member.user.domain.User; +import com.example.cp_main_be.domain.social.feed.dto.request.RandomFeedRequest; import com.example.cp_main_be.domain.social.feed.dto.response.FeedResponse; +import com.example.cp_main_be.domain.social.feed.dto.response.FeedScrollResponse; import com.example.cp_main_be.domain.social.feed.service.FeedService; import com.example.cp_main_be.global.common.ApiResponse; -import com.example.cp_main_be.global.dto.FeedItemResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -43,13 +42,21 @@ public ResponseEntity>> getFeed( } @Operation(summary = "랜덤 피드 조회 (무한 스크롤용)", description = "상세보기 화면에서 하단에 표시될 랜덤 피드를 불러옵니다") - @GetMapping("/random") - public ResponseEntity>> getRandomFeed( + @PostMapping("/random") // GET → POST로 변경 + public ResponseEntity> getRandomFeed( @AuthenticationPrincipal User user, - @RequestParam Long excludePostId, // 화면 상단에 고정된 게시물 ID (중복 방지용) - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - List feedItems = feedService.getRandomFeed(excludePostId, page, size); - return ResponseEntity.ok(ApiResponse.success(feedItems)); + @RequestBody RandomFeedRequest request) { // @RequestBody로 변경 + + FeedScrollResponse response = + feedService.getRandomFeed( + request.getExcludeDiaryIds() != null + ? request.getExcludeDiaryIds() + : Collections.emptyList(), + request.getExcludeAvatarPostIds() != null + ? request.getExcludeAvatarPostIds() + : Collections.emptyList(), + request.getSize()); + + return ResponseEntity.ok(ApiResponse.success(response)); } } diff --git a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java index 78b9a8a0..6bd2d88d 100644 --- a/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java +++ b/src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java @@ -11,6 +11,7 @@ import com.example.cp_main_be.domain.social.comment.domain.repository.CommentRepository; import com.example.cp_main_be.domain.social.comment.dto.CommentCountDto; import com.example.cp_main_be.domain.social.feed.dto.response.FeedResponse; +import com.example.cp_main_be.domain.social.feed.dto.response.FeedScrollResponse; import com.example.cp_main_be.domain.social.follow.domain.Follow; import com.example.cp_main_be.domain.social.follow.domain.repository.FollowRepository; import com.example.cp_main_be.domain.social.like.domain.repository.LikeRepository; @@ -20,7 +21,6 @@ import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -39,19 +39,15 @@ public class FeedService { private final LikeRepository likeRepository; private final CommentRepository commentRepository; - // FeedService.java - - // public List getFeed(UUID currentUserUuid, String filter, int page, int size) { - // ... } - // 위 메서드를 아래와 같이 변경합니다. - + /** 시간순 정렬된 피드 조회 (커서 기반) */ public List getFeed( UUID currentUserUuid, String filter, LocalDateTime cursor, int size) { - // 커서가 없으면(최초 요청) 현재 시간으로 설정 + if (cursor == null) { cursor = LocalDateTime.now(); } - Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴 + + Pageable pageable = PageRequest.of(0, size); User currentUser = userRepository @@ -75,37 +71,44 @@ public List getFeed( avatarPostRepository.findByCreatedAtBeforeOrderByCreatedAtDesc(cursor, pageable); } - // 두 스트림을 합치고, size만큼만 잘라낸 후, DTO로 변환 - return Stream.concat( - diaries.stream().map(FeedResponse::from), avatarPosts.stream().map(FeedResponse::from)) - .sorted(Comparator.comparing(FeedResponse::createdAt).reversed()) - .limit(size) - .toList(); + // 두 정렬된 리스트를 merge하여 size개만 반환 + return mergeSortedFeeds(diaries, avatarPosts, size); } - public List getRandomFeed(Long excludePostId, int page, int size) { + /** 랜덤 피드 조회 (제외 목록 기반) */ + public FeedScrollResponse getRandomFeed( + List excludeDiaryIds, List excludeAvatarPostIds, int size) { + if (size <= 0) { - return Collections.emptyList(); + return new FeedScrollResponse(Collections.emptyList(), false); } - // 1. 각 소스에서 가져올 항목 수를 계산합니다. + // null 체크 + List safeDiaryIds = (excludeDiaryIds != null) ? excludeDiaryIds : Collections.emptyList(); + List safeAvatarIds = + (excludeAvatarPostIds != null) ? excludeAvatarPostIds : Collections.emptyList(); + + // 각 소스에서 가져올 항목 수 계산 int diarySize = size / 2; int avatarPostSize = size - diarySize; - // 2. 각 소스에서 랜덤 ID 목록을 조회합니다. + // 충분한 양을 가져오기 위해 2배로 요청 + int diaryFetchSize = diarySize * 2; + int avatarPostFetchSize = avatarPostSize * 2; + + // 각 소스에서 랜덤 ID 조회 (제외 목록 포함) List randomDiaryIds = - (diarySize > 0) - ? diaryRepository.findRandomPublicDiaryIds( - excludePostId, PageRequest.of(page, diarySize)) + (diaryFetchSize > 0) + ? diaryRepository.findRandomPublicDiaryIdsExcluding(safeDiaryIds, diaryFetchSize) : Collections.emptyList(); List randomAvatarPostIds = - (avatarPostSize > 0) - ? avatarPostRepository.findRandomPublicAvatarPostIds( - excludePostId, PageRequest.of(page, avatarPostSize)) + (avatarPostFetchSize > 0) + ? avatarPostRepository.findRandomPublicAvatarPostIdsExcluding( + safeAvatarIds, avatarPostFetchSize) : Collections.emptyList(); - // 3. 각 소스의 데이터를 가져와 FeedItemResponse로 변환합니다. + // 각 소스의 데이터를 FeedItemResponse로 변환 List feedItems = new ArrayList<>(); // Diary 처리 @@ -130,24 +133,51 @@ public List getRandomFeed(Long excludePostId, int page, int si new AvatarPostFeedItemResponse(post, likeCount, commentCount)); feedItems.addAll(avatarPostItems); - // 4. 최종 리스트를 섞어서 순서를 랜덤하게 만듭니다. + // 랜덤 섞기 Collections.shuffle(feedItems); - return feedItems; + // size만큼만 반환 + List result = feedItems.stream().limit(size).toList(); + + // 더 가져올 데이터가 있는지 확인 (각 소스별로 체크) + boolean hasDiaryMore = randomDiaryIds.size() >= diaryFetchSize; + boolean hasAvatarPostMore = randomAvatarPostIds.size() >= avatarPostFetchSize; + boolean hasMore = hasDiaryMore || hasAvatarPostMore; + + return new FeedScrollResponse(result, hasMore); + } + + /** 시간순 정렬된 두 리스트를 병합하여 size개만 반환 */ + private List mergeSortedFeeds( + List diaries, List avatarPosts, int size) { + + List result = new ArrayList<>(size); + int i = 0, j = 0; + + while (result.size() < size && (i < diaries.size() || j < avatarPosts.size())) { + if (i >= diaries.size()) { + // Diary 소진, AvatarPost만 추가 + result.add(FeedResponse.from(avatarPosts.get(j++))); + } else if (j >= avatarPosts.size()) { + // AvatarPost 소진, Diary만 추가 + result.add(FeedResponse.from(diaries.get(i++))); + } else { + // 둘 다 있으면 시간 비교 + LocalDateTime diaryTime = diaries.get(i).getCreatedAt(); + LocalDateTime postTime = avatarPosts.get(j).getCreatedAt(); + + if (diaryTime.isAfter(postTime)) { + result.add(FeedResponse.from(diaries.get(i++))); + } else { + result.add(FeedResponse.from(avatarPosts.get(j++))); + } + } + } + + return result; } - /** - * ID 목록을 기반으로 엔티티와 관련 데이터(좋아요, 댓글 수)를 조회하고 FeedItemResponse로 매핑하는 공통 메서드 - * - * @param ids 조회할 엔티티 ID 목록 - * @param type 대상 타입 문자열 ("DIARY", "AVATAR_POST") - * @param entityFetcher ID 목록으로 엔티티 목록을 조회하는 함수 - * @param idExtractor 엔티티에서 ID를 추출하는 함수 - * @param responseMapper 엔티티와 카운트 정보로 최종 DTO를 생성하는 함수 - * @return 변환된 FeedItemResponse 목록 - * @param 엔티티 타입 (Diary, AvatarPost) - * @param 응답 DTO 타입 (DiaryFeedItemResponse, AvatarPostFeedItemResponse) - */ + /** ID 목록을 기반으로 엔티티와 관련 데이터(좋아요, 댓글 수)를 조회하고 FeedItemResponse로 매핑 */ private List fetchAndMapFeedItems( List ids, String type, @@ -162,24 +192,20 @@ private List fetchAndMapFeedItems( // 엔티티 조회 List entities = entityFetcher.apply(ids); - // --- 이 부분이 수정되었습니다 --- - // 좋아요 및 댓글 수를 타입에 따라 분기하여 조회합니다. + // 좋아요 및 댓글 수를 타입에 따라 분기하여 조회 Map likeCounts; List commentCountDtos; if ("DIARY".equalsIgnoreCase(type)) { - likeCounts = - likeRepository.countLikesByTargetIds(ids, "DIARY"); // likeRepository도 분리되었다면 수정 필요 + likeCounts = likeRepository.countLikesByTargetIds(ids, "DIARY"); commentCountDtos = commentRepository.countCommentsByDiaryIds(ids); } else if ("AVATAR_POST".equalsIgnoreCase(type)) { - likeCounts = - likeRepository.countLikesByTargetIds(ids, "AVATAR_POST"); // likeRepository도 분리되었다면 수정 필요 + likeCounts = likeRepository.countLikesByTargetIds(ids, "AVATAR_POST"); commentCountDtos = commentRepository.countCommentsByAvatarPostIds(ids); } else { likeCounts = Collections.emptyMap(); commentCountDtos = Collections.emptyList(); } - // --- 수정 끝 --- Map commentCounts = commentCountDtos.stream() @@ -197,7 +223,6 @@ private List fetchAndMapFeedItems( .toList(); } - // 3개의 파라미터를 받는 함수형 인터페이스가 기본 API에 없으므로 직접 정의합니다. @FunctionalInterface interface TriFunction { R apply(T t, U u, V v); From d216215d79360bbce491a5643dbb1c153f9721c1 Mon Sep 17 00:00:00 2001 From: lejuho Date: Thu, 2 Oct 2025 10:10:04 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat=20:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20ap?= =?UTF-8?q?i=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/AddKeywordRequest.java | 8 ++++ .../presentation/KeywordController.java | 14 +++++-- .../keyword/service/KeywordService.java | 42 ++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java diff --git a/src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java b/src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java new file mode 100644 index 00000000..34611eea --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java @@ -0,0 +1,8 @@ +package com.example.cp_main_be.domain.mission.keyword.dto.request; + +import lombok.Getter; + +@Getter +public class AddKeywordRequest { + String keyword; +} diff --git a/src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java b/src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java index ffb0eeb5..bf8a10e2 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java @@ -1,5 +1,6 @@ package com.example.cp_main_be.domain.mission.keyword.presentation; +import com.example.cp_main_be.domain.mission.keyword.dto.request.AddKeywordRequest; import com.example.cp_main_be.domain.mission.keyword.dto.response.KeywordResponse; import com.example.cp_main_be.domain.mission.keyword.dto.response.TodayKeywordResponse; import com.example.cp_main_be.domain.mission.keyword.service.KeywordService; @@ -9,9 +10,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -21,7 +20,7 @@ public class KeywordController { private final KeywordService keywordService; - @Operation(summary = "오늘의 일일미션 조회", description = "오늘 할당된 일일미션을 조회합니다") + @Operation(summary = "오늘의 일기 키워드 조회", description = "오늘 할당된 일기 키워드를 조회합니다") @GetMapping("/today") public ResponseEntity> getTodaysKeywords() { TodayKeywordResponse response = keywordService.getTodayKeyword(); @@ -34,4 +33,11 @@ public ResponseEntity>> getAllKeywords() { List response = keywordService.getAllKeywords(); return ResponseEntity.ok(ApiResponse.success(response)); } + + @Operation(summary = "키워드 추가", description = "db에 키워드를 추가합니다") + @PostMapping + public ResponseEntity addKeyword(@RequestBody AddKeywordRequest request) { + keywordService.addNewKeyword(request); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java b/src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java index a62cfd0b..380408fa 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java @@ -1,12 +1,18 @@ package com.example.cp_main_be.domain.mission.keyword.service; +import com.example.cp_main_be.domain.mission.keyword.domain.Keyword; import com.example.cp_main_be.domain.mission.keyword.domain.repository.KeywordRepository; +import com.example.cp_main_be.domain.mission.keyword.dto.request.AddKeywordRequest; import com.example.cp_main_be.domain.mission.keyword.dto.response.KeywordResponse; import com.example.cp_main_be.domain.mission.keyword.dto.response.TodayKeywordResponse; import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @Service @@ -16,9 +22,41 @@ public class KeywordService { private final KeywordRepository keywordRepository; + public void addNewKeyword(AddKeywordRequest request) { + Keyword newKeyword = Keyword.builder().keyword(request.getKeyword()).build(); + + keywordRepository.save(newKeyword); // save 메소드 호출 + } + public TodayKeywordResponse getTodayKeyword() { - // 오늘의 키워드 등록에 따라 달라질 예정 - return new TodayKeywordResponse("today_keyword"); + // 1. DB에 저장된 전체 키워드 개수를 가져옵니다. + long totalKeywords = keywordRepository.count(); + + // 키워드가 하나도 없으면 기본 메시지를 반환합니다. + if (totalKeywords == 0) { + return new TodayKeywordResponse("등록된 키워드가 없습니다."); + } + + // 2. 기준이 될 시작 날짜를 정합니다. (서비스 시작일 등) + LocalDate startDate = LocalDate.of(2025, 10, 1); + LocalDate today = LocalDate.now(); + + // 3. 시작 날짜로부터 오늘까지 며칠이 지났는지 계산합니다. + long daysSinceStart = ChronoUnit.DAYS.between(startDate, today); + + // 4. (지난 날짜 % 전체 키워드 개수)로 오늘 보여줄 키워드의 순번(index)을 구합니다. + int keywordIndex = (int) (daysSinceStart % totalKeywords); + + // 5. PageRequest를 이용해 해당 순번의 키워드 하나만 조회합니다. (효율적) + Page keywordPage = keywordRepository.findAll(PageRequest.of(keywordIndex, 1)); + + if (keywordPage.hasContent()) { + String todayKeyword = keywordPage.getContent().get(0).getKeyword(); + return new TodayKeywordResponse(todayKeyword); + } + + // 위에서 개수 체크를 했기 때문에 이 경우는 거의 발생하지 않지만, 만약을 대비한 코드입니다. + return new TodayKeywordResponse("키워드를 가져오는데 실패했습니다."); } public List getAllKeywords() {