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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.example.cp_main_be.domain.member.user.presentation;

import com.example.cp_main_be.domain.garden.garden.domain.Garden;
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;
Expand All @@ -13,9 +12,7 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand Down Expand Up @@ -89,12 +86,8 @@ public ResponseEntity<ApiResponse<UserRegisterResponse.LevelStatusResponseDTO>>
@GetMapping("/me/gardens")
public ResponseEntity<ApiResponse<List<Long>>> getMyGardenIds(
@AuthenticationPrincipal User user) {
List<Long> gardenIds =
user.getGardens().stream()
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(Garden::getId)
.collect(Collectors.toList());

// [수정] 서비스 계층으로 로직을 위임하여 트랜잭션 내에서 안전하게 데이터를 조회합니다.
List<Long> gardenIds = userService.getMyGardenIds(user);
return ResponseEntity.ok(ApiResponse.success(gardenIds));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,9 @@
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;
import com.example.cp_main_be.global.exception.AvatarNotFoundException;
import com.example.cp_main_be.global.exception.UserNotFoundException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
Expand All @@ -36,6 +31,8 @@
@Transactional
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;
Expand All @@ -45,8 +42,8 @@ public class UserService {
public void addExperience(Long actorId, int points) {
User user =
userRepository
.findById(actorId)
.orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND.getMessage()));
.findById(actorId) // ID로 최신 유저 정보를 조회합니다.
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
user.addExperience(points);
levelService.checkLevelUp(user);
}
Expand All @@ -59,7 +56,7 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId)
Avatar avatar =
avatarRepository
.findById(avatarId)
.orElseThrow(() -> new AvatarNotFoundException("아바타를 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_NOT_FOUND));

Comment on lines 57 to 60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

빌드 실패: ErrorCode.AVATAR_NOT_FOUND 심볼 없음

CI 오류를 막기 위해 기존 에러코드로 대체하거나(ErrorCode.NOT_FOUND 등), ErrorCode에 상수를 추가하세요. 단기해결(컴파일 우선) 패치 예시:

-            .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_NOT_FOUND));
+            .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND, "아바타를 찾을 수 없습니다.")); // ErrorCode.NOT_FOUND가 없다면 적절한 기존 코드로 교체

권장: ErrorCode에 도메인 전용 상수 추가 후 원복.

- AVATAR_NOT_FOUND
+ AVATAR_NOT_FOUND

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Actions: Spring Boot CI/CD with AWS

[error] 59-59: Cannot find symbol AVATAR_NOT_FOUND in ErrorCode (referenced in UserService.java:59).

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java
around lines 57 to 60, the code references ErrorCode.AVATAR_NOT_FOUND which
doesn't exist and causes build failures; fix by either (preferred) adding a new
constant AVATAR_NOT_FOUND to the ErrorCode enum/class with an appropriate
message/code and use that here, or as a short-term compile-fix replace
ErrorCode.AVATAR_NOT_FOUND with an existing constant such as
ErrorCode.NOT_FOUND; ensure the ErrorCode file is saved/compiled and imports are
correct before committing.

// [버그 수정] AvatarMaster(원본)가 아닌 Avatar(개별 인스턴스)의 imageUrl을 변경해야 합니다.
// Avatar 엔티티에 imageUrl 필드가 있어야 합니다.
Expand All @@ -74,8 +71,12 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId)
}

public void updateNickname(User user, String newNickname) {

user.updateProfile(newNickname, null);
// [수정] stale한 user 객체 대신, ID로 최신 정보를 조회해서 사용합니다.
User managedUser =
userRepository
.findById(user.getId())
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
managedUser.updateProfile(newNickname, null);
userRepository.save(user);
}
Comment on lines 73 to 81
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

버그: managedUser를 수정하고도 save(user) 호출

영속 엔티티가 managedUser이므로 save 대상이 잘못되었습니다.

-    managedUser.updateProfile(newNickname, null);
-    userRepository.save(user);
+    managedUser.updateProfile(newNickname, null);
+    userRepository.save(managedUser); // 또는 @Transactional에 의존해 생략
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java
around lines 73 to 81, the code updates the fetched managedUser but calls
userRepository.save(user) (the stale/unmanaged instance); change the save target
to the managed entity by calling userRepository.save(managedUser) (or remove the
explicit save entirely if relying on JPA dirty checking) so the persisted update
is applied to the correct entity.


Expand All @@ -86,21 +87,21 @@ public void saveUser(User user) {
public void deleteUser(Long userId) {
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
.findById(userId) // ID로 최신 유저 정보를 조회합니다.
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
userRepository.delete(user);
}

public User findUserById(Long id) {
return this.userRepository
.findById(id)
.orElseThrow(() -> new UserNotFoundException("해당 ID의 사용자를 찾을 수 없습니다 : " + id));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
}

public User findUserByUuid(UUID uuid) {
return this.userRepository
.findByUuid(uuid)
.orElseThrow(() -> new UserNotFoundException("해당 UUID의 사용자를 찾을 수 없습니다 : " + uuid));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
}

public List<User> findAllUsers() {
Expand Down Expand Up @@ -128,11 +129,11 @@ public User getCurrentUser() {
if (principal instanceof String uuidString) {
UUID userUuid = UUID.fromString(uuidString);
return userRepository
.findByUuid(userUuid)
.orElseThrow(() -> new IllegalArgumentException("현재 로그인한 사용자를 찾을 수 없습니다."));
.findByUuid(userUuid) // UUID로 최신 유저 정보를 조회합니다.
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
}

throw new IllegalArgumentException("인증 정보를 찾을 수 없습니다.");
throw new CustomApiException(ErrorCode.INVALID_TOKEN, "인증 정보를 찾을 수 없습니다.");
}

/**
Expand All @@ -147,9 +148,10 @@ public List<Long> getMyGardenIds(User user) {
User managedUser =
userRepository
.findByIdWithGardens(user.getId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

return managedUser.getGardens().stream()
.filter(garden -> !garden.isLocked()) // [추가] 잠겨있지 않은(isLocked=false) 텃밭만 필터링합니다.
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(Garden::getId)
.collect(Collectors.toList());
Expand All @@ -165,12 +167,12 @@ public List<Long> getMyGardenIds(User user) {
public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId) {
User currentUser =
userRepository
.findById(currentUserId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.findById(currentUserId) // ID로 최신 유저 정보를 조회합니다.
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));
User profileUser =
userRepository
.findByIdWithGardensAndAvatars(profileUserId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

// [수정] 프로필 이미지 URL을 사용자의 첫 번째 아바타 이미지로 설정
String profileImageUrl =
Expand Down Expand Up @@ -210,29 +212,28 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId
int todayWateringCountForOthers =
friendWateringLogRepository.countByWaterGiverAndWateredAtAfter(
currentUser, startOfWateringDay);
Long leftWaterCountForOthers =
(long) (3 - todayWateringCountForOthers); // MAX_FRIEND_WATERING_PER_DAY = 3
long leftWaterCountForOthers =
Math.max(0, (long) MAX_FRIEND_WATERING_PER_DAY - todayWateringCountForOthers);

// [성능 개선] N+1 문제를 해결하기 위해, 오늘 내가 물 준 정원 ID 목록을 한 번에 조회합니다.
Set<Long> wateredGardenIds =
friendWateringLogRepository.findWateredGardenIdsByGiverAndDate(
currentUser.getId(), startOfWateringDay);

// 3. 프로필 주인의 정원 목록 및 물주기 가능 여부 계산
List<UserGardenDetailResponse> userGardens =
profileUser.getGardens().stream()
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(
garden -> {
// 현재 접속 유저가 이 정원에 오늘 물을 줄 수 있는지 여부
boolean isWateringAbleByMe = false;
// 조건: 1) 아직 오늘 남에게 물 줄 수 있는 횟수가 남아있어야 하고 (leftWaterCountForOthers > 0)
// 2) 오늘 이 정원에 내가 물을 준 적이 없어야 한다.
if (leftWaterCountForOthers > 0) {
boolean alreadyWateredByMe =
friendWateringLogRepository
.existsByWaterGiverAndWateredGardenAndWateredAtAfter(
currentUser, garden, startOfWateringDay);
isWateringAbleByMe = !alreadyWateredByMe;
}
// DB를 반복 조회하는 대신, 미리 조회한 Set에서 확인하여 성능을 개선합니다.
boolean alreadyWateredByMe = wateredGardenIds.contains(garden.getId());
boolean isWateringAbleByMe = leftWaterCountForOthers > 0 && !alreadyWateredByMe;

HomeResponseDto.AvatarInfo avatarInfoForGarden =
HomeResponseDto.AvatarInfo.builder()
.avatarId(garden.getAvatar().getId())
// 아바타가 없는 텃밭이 있을 수 있는 예외 케이스를 방어합니다.
.avatarId(garden.getAvatar() != null ? garden.getAvatar().getId() : null)
.avatarName(garden.getAvatar().getNickname())
.avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl())
.build();
Comment on lines 225 to 239
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

NPE 위험: 아바타가 없는 텃밭 처리 미흡

avatarId만 null-세이프, 이름/이미지 접근은 그대로 NPE입니다. 안전하게 빌드하세요.

-                  HomeResponseDto.AvatarInfo avatarInfoForGarden =
-                      HomeResponseDto.AvatarInfo.builder()
-                          // 아바타가 없는 텃밭이 있을 수 있는 예외 케이스를 방어합니다.
-                          .avatarId(garden.getAvatar() != null ? garden.getAvatar().getId() : null)
-                          .avatarName(garden.getAvatar().getNickname())
-                          .avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl())
-                          .build();
+                  var avatar = garden.getAvatar();
+                  HomeResponseDto.AvatarInfo avatarInfoForGarden =
+                      HomeResponseDto.AvatarInfo.builder()
+                          .avatarId(avatar != null ? avatar.getId() : null)
+                          .avatarName(avatar != null ? avatar.getNickname() : null)
+                          .avatarImageUrl(
+                              avatar != null
+                                  ? (avatar.getImageUrl() != null && !avatar.getImageUrl().isBlank()
+                                        ? avatar.getImageUrl()
+                                        : (avatar.getAvatarMaster() != null
+                                            ? avatar.getAvatarMaster().getDefaultImageUrl()
+                                            : null))
+                                  : null)
+                          .build();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
profileUser.getGardens().stream()
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(
garden -> {
// 현재 접속 유저가 이 정원에 오늘 물을 줄 수 있는지 여부
boolean isWateringAbleByMe = false;
// 조건: 1) 아직 오늘 남에게 물 줄 수 있는 횟수가 남아있어야 하고 (leftWaterCountForOthers > 0)
// 2) 오늘 이 정원에 내가 물을 준 적이 없어야 한다.
if (leftWaterCountForOthers > 0) {
boolean alreadyWateredByMe =
friendWateringLogRepository
.existsByWaterGiverAndWateredGardenAndWateredAtAfter(
currentUser, garden, startOfWateringDay);
isWateringAbleByMe = !alreadyWateredByMe;
}
// DB를 반복 조회하는 대신, 미리 조회한 Set에서 확인하여 성능을 개선합니다.
boolean alreadyWateredByMe = wateredGardenIds.contains(garden.getId());
boolean isWateringAbleByMe = leftWaterCountForOthers > 0 && !alreadyWateredByMe;
HomeResponseDto.AvatarInfo avatarInfoForGarden =
HomeResponseDto.AvatarInfo.builder()
.avatarId(garden.getAvatar().getId())
// 아바타가 없는 텃밭이 있을 수 있는 예외 케이스를 방어합니다.
.avatarId(garden.getAvatar() != null ? garden.getAvatar().getId() : null)
.avatarName(garden.getAvatar().getNickname())
.avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl())
.build();
profileUser.getGardens().stream()
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(
garden -> {
// DB를 반복 조회하는 대신, 미리 조회한 Set에서 확인하여 성능을 개선합니다.
boolean alreadyWateredByMe = wateredGardenIds.contains(garden.getId());
boolean isWateringAbleByMe = leftWaterCountForOthers > 0 && !alreadyWateredByMe;
var avatar = garden.getAvatar();
HomeResponseDto.AvatarInfo avatarInfoForGarden =
HomeResponseDto.AvatarInfo.builder()
.avatarId(avatar != null ? avatar.getId() : null)
.avatarName(avatar != null ? avatar.getNickname() : null)
.avatarImageUrl(
avatar != null
? (avatar.getImageUrl() != null && !avatar.getImageUrl().isBlank()
? avatar.getImageUrl()
: (avatar.getAvatarMaster() != null
? avatar.getAvatarMaster().getDefaultImageUrl()
: null))
: null)
.build();
// ...rest of mapping logic...
})
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java
around lines 225 to 239, the builder assumes garden.getAvatar() (and
avatar.getAvatarMaster()) are non-null which causes NPEs; make all
avatar-derived fields null-safe by checking garden.getAvatar() before accessing
nickname and avatarMaster, and also guard avatar.getAvatarMaster() before
calling getDefaultImageUrl(), populating avatarId, avatarName and avatarImageUrl
with null (or sensible defaults) when the corresponding objects are absent.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public enum ErrorCode {
AVATAR_MASTER_NOT_FOUND(HttpStatus.NOT_FOUND, "E40408", "해당 아바타 원본을 찾을 수 없습니다."),
WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40409", "소원나무 정보를 찾을 수 없습니다."),
DEFAULT_RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40410", "필수 기본 리소스를 찾을 수 없습니다."),
AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "E40411", "해당 아바타를 찾을 수 없습니다."),

// 417 Expectation Failed
UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."),
Expand Down