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
Expand Up @@ -17,9 +17,9 @@
import com.example.cp_main_be.domain.reports.domain.Reports;
import com.example.cp_main_be.domain.reports.domain.repository.ReportRepository;
import com.example.cp_main_be.domain.reports.enums.ReportStatus;
import com.example.cp_main_be.global.exception.QuizNotFoundException;
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.infra.S3Uploader;
import jakarta.persistence.*;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -97,7 +97,7 @@ public boolean deleteQuiz(Long quizId) {
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new QuizNotFoundException("퀴즈가 존재하지 않습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.QUIZ_NOT_FOUND));
quizRepository.delete(quiz);
return true;
}
Expand Down Expand Up @@ -125,7 +125,7 @@ public DailyMissionMaster updateDailyMissionMasters(
DailyMissionMaster dailyMissionMaster =
dailyMissionMastersRepository
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 ID의 미션을 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.MISSION_NOT_FOUND));

// null 인 컬럼들은 수정 안한다.
dailyMissionMaster.update(requestDTO);
Expand Down Expand Up @@ -157,10 +157,10 @@ public QuizOptions createQuizOption(
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new RuntimeException("해당 ID를 가진 퀴즈가 존재하지 않습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.QUIZ_NOT_FOUND));

if (quiz.getDailyMissionMaster().getMissionType() != MissionType.QUIZ) {
throw new IllegalArgumentException("퀴즈 타입의 미션에만 선지를 추가할 수 있습니다.");
throw new CustomApiException(ErrorCode.INVALID_MISSION_TYPE_FOR_QUIZ_OPTION);
}

return QuizOptions.builder() // QuizOptions 퀴즈의 선지
Expand All @@ -180,7 +180,7 @@ public Reports updateReportStatus(Long reportId, ReportStatus reportStatus) {
Reports report =
reportRepository
.findById(reportId)
.orElseThrow(() -> new RuntimeException("신고를 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.REPORT_NOT_FOUND));
report.setStatus(reportStatus);
return report;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@
@Transactional
public class AvatarService {

private static final Long AI_AVATAR_MASTER_ID = 9999L;
private final AvatarRepository avatarRepository;
private final UserRepository userRepository;
private final AvatarMasterRepository avatarMasterRepository; // [추가] AvatarMaster 조회 위해 주입
private final NotificationService notificationService;

private static final Long AI_AVATAR_MASTER_ID = 9999L;
private final WishTreeRepository wishTreeRepository;
private final GardenRepository gardenRepository;

Expand All @@ -39,21 +38,27 @@ public void createAvatar(Long userId, String nickname, String imageUrl, Long mas
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

AvatarMaster master;
if (masterId != null) {
// 1. 기존 목록에서 선택한 경우: 전달받은 masterId로 AvatarMaster를 찾습니다.
// TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다.
// 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나,
// 이 로직에 대한 명확한 주석과 문서화가 필요합니다.
master =
avatarMasterRepository
.findById(masterId + 2)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); // 예외 유형 구체화
.orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND));
Comment on lines +46 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

매직 넘버(masterId + 2) 사용은 즉시 제거해야 합니다

masterId에 2를 가산하는 로직은 데이터 정합성을 심각하게 훼손할 수 있는 크리티컬 이슈입니다. FE/DB 간 ID 매핑 문제는 계약을 맞춰 해결해야 하며, 임시 변환은 금지해야 합니다.

-      // TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다.
-      // 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나,
-      // 이 로직에 대한 명확한 주석과 문서화가 필요합니다.
-      master =
-          avatarMasterRepository
-              .findById(masterId + 2)
+      master =
+          avatarMasterRepository
+              .findById(masterId)
               .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND));
📝 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
// TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다.
// 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나,
// 이 로직에 대한 명확한 주석과 문서화가 필요합니다.
master =
avatarMasterRepository
.findById(masterId + 2)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); // 예외 유형 구체화
.orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND));
master =
avatarMasterRepository
.findById(masterId)
.orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND));
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
around lines 46 to 52, the code uses a magic offset (masterId + 2) when loading
the AvatarMaster which must be removed; change the lookup to use the
frontend-supplied masterId directly (avatarMasterRepository.findById(masterId))
and, if a mapping is required, introduce a clearly named mapping function or DTO
conversion that translates frontend IDs to persistence IDs with unit tests and
documentation; also validate masterId (null/negative) before lookup and keep a
brief Javadoc comment explaining the chosen ID contract (or the mapping
rationale) so the behavior is explicit and not hidden by a magic number.

} else {
// 2. AI로 생성한 경우: 약속된 AI_AVATAR_MASTER_ID로 AvatarMaster를 찾습니다.
master =
avatarMasterRepository
.findById(AI_AVATAR_MASTER_ID)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(
() ->
new CustomApiException(
ErrorCode.AVATAR_MASTER_NOT_FOUND, "AI 아바타 원본을 찾을 수 없습니다."));
}

Avatar newAvatar =
Expand All @@ -69,7 +74,8 @@ public void createAvatar(Long userId, String nickname, String imageUrl, Long mas
WishTree wishTree =
wishTreeRepository
.findByUserId(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(
() -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));

Comment on lines +77 to 79
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

WishTree 미존재 시 전용 에러 코드 사용 검토

현재 NOT_FOUND + 메시지를 사용합니다. 에러 핸들링 일관성과 클라이언트 분기 처리를 위해 WISH_TREE_NOT_FOUND 같은 전용 코드 사용을 권장합니다(존재 시).

-            .orElseThrow(
-                () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
+            .orElseThrow(
+                () -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));

ErrorCode에 WISH_TREE_NOT_FOUND가 없으면 추가 도입이 필요합니다. 다음 스크립트로 확인하세요.


🏁 Script executed:

#!/bin/bash
rg -n "enum\s+ErrorCode" -A200 | rg -n "WISH_TREE_NOT_FOUND|AVATAR_NOT_FOUND|GARDEN_SLOT_MAXED_OUT"

Length of output: 256


WishTree 전용 에러 코드 추가 필요

ErrorCode enum에 WISH_TREE_NOT_FOUND가 정의되어 있지 않아, 서비스 코드에서 해당 코드를 사용하려면 먼저 enum에 항목을 추가해야 합니다. 아래 위치를 확인하고 수정해주세요.

• src/main/java/com/example/cp_main_be/global/common/ErrorCode.java
기존 enum 목록 중 적절한 위치에 다음 항목을 추가합니다.
(HTTP 상태 및 코드 체계에 맞춰 번호는 필요에 따라 조정하세요.)

 public enum ErrorCode {
     // ...

     GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."),

+    WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40404", "사용자의 소원나무를 찾을 수 없습니다."),

     // ...
 }

• src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
조회 실패 시 NOT_FOUND 대신 추가한 전용 코드를 사용하도록 수정합니다.

-            .orElseThrow(
-                () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
+            .orElseThrow(
+                () -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
📝 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
.orElseThrow(
() -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
public enum ErrorCode {
// ...
GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."),
WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40404", "사용자의 소원나무를 찾을 수 없습니다."),
// ...
}
Suggested change
.orElseThrow(
() -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
.orElseThrow(
() -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
around lines 77-79, the code throws a CustomApiException using
ErrorCode.NOT_FOUND for missing WishTree; add a dedicated enum entry to
src/main/java/com/example/cp_main_be/global/common/ErrorCode.java (e.g.,
WISH_TREE_NOT_FOUND with the appropriate HTTP status and numeric code consistent
with your scheme) and then change the AvatarService.orElseThrow to use
ErrorCode.WISH_TREE_NOT_FOUND (preserving or updating the message text as
needed).

WishTreeStage stage = wishTree.getStage();
int maxGardens = stage.getMaxGardens();
Expand All @@ -80,22 +86,23 @@ public void createAvatar(Long userId, String nickname, String imageUrl, Long mas
Garden.builder().user(user).slotNumber(userGardens.size() + 1).avatar(newAvatar).build();
gardenRepository.save(newGarden);
} else {
throw new CustomApiException(ErrorCode.MAX_GARDENS_REACHED);
// 이미 존재하는 'GARDEN_SLOT_MAXED_OUT' 에러 코드를 재사용하여 일관성을 유지합니다.
throw new CustomApiException(ErrorCode.GARDEN_SLOT_MAXED_OUT);
}
}

@Transactional(readOnly = true)
public Avatar findAvatarById(Long avatarId) {
return avatarRepository
.findById(avatarId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND, "해당 아바타를 찾을 수 없습니다."));
}

public void givePollen(Long senderId, Long avatarId) {
User sender =
userRepository
.findById(senderId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

Avatar avatar = findAvatarById(avatarId);
User receiver = avatar.getUser();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class GardenService {

private static final int MAX_GARDEN_COUNT = 4;
private static final int MAX_GARDEN_COUNT = 3;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

MAX_GARDEN_COUNT 4→3 변경은 브레이킹 체인지: 도메인/클라이언트 영향 검증 필요

정원 최대 개수 축소는 사용자 경험과 기존 데이터/클라이언트 로직에 영향을 줍니다. 정책 변경 근거와 마이그레이션/알림 계획, 관련 화면(예: 슬롯 해제 버튼, 홈 요약)의 표기 변경을 점검해 주세요.

영향 분포 탐색 스크립트:


🏁 Script executed:

#!/bin/bash
# MAX_GARDEN_COUNT 사용처 및 슬롯 관련 문구/상수 검색
rg -n "MAX_GARDEN_COUNT|maxGardens|GARDEN_SLOT_MAXED_OUT|slotNumber|정원|텃밭" -C2

Length of output: 39874


🏁 Script executed:

#!/bin/bash
cat src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java | sed -n '1,200p'

Length of output: 1063


GardenService 최대 개수 논리 불일치 – 즉각 수정 필요

GardenService의 MAX_GARDEN_COUNT = 3이 WishTree 단계별 허용 슬롯(최대 4개) 및 AvatarService(stage.getMaxGardens()) 로직과 맞지 않습니다. 이로 인해

  • WishTreeService가 TREE 단계(4개 허용) 진입 시 unlockNewGardenSlot 호출 → currentGardens >= 3 판정으로 예외 발생
  • AvatarService는 최대 4개까지 허용
  • 기존 주석 처리된 GardenServiceTest는 “최대 4개” 기준으로 테스트 기대치 설정

등 심각한 동작 불일치가 발생합니다. 아래 항목을 긴급 정비해주세요:

GardenService.java (33행)
MAX_GARDEN_COUNT 상수를 제거하거나, WishTreeStage.getMaxGardens(userStage) 기반으로 동적 계산하도록 리팩토링
WishTreeStage.java (16–20행)
– 단계별 maxGardens 값(1·2·3·4·5)이 의도한 정책(최대 3 vs. 4)과 부합하는지 재검토
AvatarService.java (81–89행)
MAX_GARDEN_COUNT 대신 stage.getMaxGardens() 로직과 일관성 유지
GardenServiceTest.java
– “최대 텃밭 개수(3개)” 테스트명, slotNumber, 예외 메시지(“더 이상 텃밭을 추가할 수 없습니다.”)로 업데이트
• UI/클라이언트 화면
– 슬롯 확장 버튼 활성화 조건, 홈 요약 가드닝 슬롯 표시 텍스트(“3/3” or “4/4”) 동기화
• 데이터 & 마이그레이션 계획
– 이미 4개를 보유한 유저 처리 로직, 기존 데이터 검증 및 사용자 공지 방안 수립

위 수정이 완료되면 종합 테스트 후 클라이언트 영향 범위를 최종 확인해 주세요.

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around line 33, the hardcoded MAX_GARDEN_COUNT = 3 conflicts with
WishTreeStage/AvatarService which use stage.getMaxGardens() (allowing up to 4);
remove the hardcoded constant and replace usages with a dynamic check against
the user's current stage (e.g., stage.getMaxGardens(userStage) or equivalent
lookup) so GardenService enforces the same limit as AvatarService/WishTree; also
review WishTreeStage (lines ~16–20) to confirm stage maxGardens values are
correct, update AvatarService ( ~81–89) to rely on stage.getMaxGardens() if not
already, adjust GardenServiceTest expectations/messages to the chosen max
(rename test and update slotNumber and exception text), and coordinate UI/client
and data-migration plans for users already holding 4 gardens.

private static final int WATERING_POINTS = 2;
private static final int SUNLIGHT_POINTS = 3;
private static final int MAX_FRIEND_WATERING_PER_DAY = 3;
Expand All @@ -50,57 +53,81 @@ 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)
.orElseThrow(() -> new IllegalArgumentException("해당 텃밭을 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND));

User owner = garden.getUser(); // 정원 주인
User owner = garden.getUser();

// Case 1: 자신의 정원에 물을 주는 경우
if (owner.getId().equals(actorId)) {
// 8시간 쿨타임 체크
if (garden.getLastWateredByOwnerAt() != null
&& garden.getLastWateredByOwnerAt().plusHours(8).isAfter(LocalDateTime.now())) {
throw new IllegalStateException("아직 물을 줄 수 없습니다. 8시간이 지나야 가능합니다.");
}

garden.increaseWaterCount();
userService.addExperience(actorId, WATERING_POINTS);
garden.recordOwnerWateringTime(); // 주인이 물 준 시간 기록
waterOwnGarden(actorId, garden);
} else {
waterFriendGarden(actorId, garden);
}
// Case 2: 남의 정원에 물을 주는 경우
else {
User actor =
userRepository
.findById(actorId)
.orElseThrow(() -> new IllegalArgumentException("물을 주는 사용자를 찾을 수 없습니다."));

LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay();

// 1. 하루에 3회 제한 체크
int todayWateringCount =
friendWateringLogRepository.countByWaterGiverAndWateredAtAfter(actor, startOfWateringDay);
if (todayWateringCount >= MAX_FRIEND_WATERING_PER_DAY) {
throw new IllegalStateException("오늘은 다른 사람의 정원에 더 이상 물을 줄 수 없습니다. (일일 3회 제한)");
}

// 2. 같은 정원에 하루 한 번 제한 체크
boolean alreadyWatered =
friendWateringLogRepository.existsByWaterGiverAndWateredGardenAndWateredAtAfter(
actor, garden, startOfWateringDay);
if (alreadyWatered) {
throw new IllegalStateException("이 정원에는 오늘 이미 물을 주었습니다.");
}

// 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다.
userService.addExperience(actorId, WATERING_POINTS);
garden.increaseWaterCount();

// 물주기 활동 기록
FriendWateringLog log =
FriendWateringLog.builder().waterGiver(actor).wateredGarden(garden).build();
friendWateringLogRepository.save(log);
}

/** 자신의 정원에 물을 주는 로직을 처리합니다. */
private void waterOwnGarden(Long ownerId, Garden garden) {
// 8시간 쿨타임 체크
if (garden.getLastWateredByOwnerAt() != null
&& garden.getLastWateredByOwnerAt().plusHours(8).isAfter(LocalDateTime.now())) {
throw new CustomApiException(ErrorCode.WATERING_COOL_DOWN);
}

garden.increaseWaterCount();
userService.addExperience(ownerId, WATERING_POINTS);
garden.recordOwnerWateringTime(); // 주인이 물 준 시간 기록
}

/** 친구의 정원에 물을 주는 로직을 처리합니다. */
private void waterFriendGarden(Long actorId, Garden garden) {
User actor =
userRepository
.findById(actorId)
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

// 낮 12시 이후 -> 그날의 12시 날짜 반환, 이전 -> 전날의 12시 날짜 반환
LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay();

// 1. 하루에 3회 제한 체크
checkFriendWateringLimit(actor, startOfWateringDay);

// 2. 같은 정원에 하루 한 번 제한 체크
checkAlreadyWateredToday(actor, garden, startOfWateringDay);

// 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다.
userService.addExperience(actorId, WATERING_POINTS);
garden.increaseWaterCount();

// 물주기 활동 기록
FriendWateringLog log =
FriendWateringLog.builder()
.waterGiver(actor)
.wateredGarden(garden) // wateredAt은 @CreatedDate가 자동으로 설정합니다.
.build();
friendWateringLogRepository.save(log);
}
Comment on lines +86 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

친구 물주기 동시성·중복 처리 보강

동시에 두 요청이 들어올 경우 존재 여부 체크 후 삽입 사이 레이스가 발생할 수 있습니다. DB 레벨 제약 또는 고유 인덱스를 두어 하루 1회/정원 제한을 강제하세요. 또한 count/exists 쿼리에 필요한 인덱스(water_giver_id, watered_garden_id, watered_at)를 확인해 주세요.

  • 제약(예시): UNIQUE (water_giver_id, watered_garden_id, watering_day)
  • watering_day는 watered_at을 정오 기준으로 버킷팅한 값(생성 시 트리거/애플리케이션 측 계산)으로 저장
  • Repository exists/count 쿼리 컬럼에 복합 인덱스 추가


// 물주기 남은 횟수 확인
private void checkFriendWateringLimit(User actor, LocalDateTime startOfWateringDay) {
int 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 =
friendWateringLogRepository.existsByWaterGiverAndWateredGardenAndWateredAtAfter(
actor, garden, startOfWateringDay);
if (alreadyWatered) {
throw new CustomApiException(ErrorCode.ALREADY_WATERED_GARDEN);
}
}

Expand Down Expand Up @@ -141,7 +168,7 @@ public void unlockNewGardenSlot(Long userId) {
if (currentGardens >= MAX_GARDEN_COUNT) {
// 이미 최대치이므로 조용히 종료하거나 예외를 던질 수 있습니다.
// 여기서는 추가 생성을 막고 그냥 리턴합니다.
return;
throw new CustomApiException(ErrorCode.GARDEN_SLOT_MAXED_OUT);
}

// [기존 레벨 체크 로직 삭제!]
Expand Down Expand Up @@ -211,4 +238,16 @@ private LocalDateTime getStartOfCurrentSunlightDay() {
return todaySixAM;
}
}

/** 오래된 친구 물주기 로그를 주기적으로 삭제하는 스케줄링 작업입니다. cron = "0 0 4 * * *" : 매일 새벽 4시에 실행됩니다. */
@Scheduled(cron = "0 0 4 * * *")
@Transactional // 쓰기 작업이므로 클래스 레벨의 readOnly 설정을 오버라이드합니다.
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);
int deletedCount = friendWateringLogRepository.deleteByWateredAtBefore(cutoffDate);
log.info("Finished cleanup. Deleted {} old friend watering logs.", deletedCount);
}
Comment on lines +242 to +252
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

정기 로그 정리: 리포지토리 메서드 시그니처/인덱스/설정 외부화 확인

  • deleteByWateredAtBefore가 @Modifying으로 선언되어 있고 반환 타입이 int인지 확인 필요
  • 대용량 삭제 대비 인덱스(watered_at) 존재 확인
  • RETENTION_DAYS를 설정 값으로 외부화(@value 또는 @ConfigurationProperties) 권장

리포지토리/인덱스/프로퍼티 점검 스크립트:

설정 외부화 예시:

@Value("${watering-log.retention-days:7}")
private int retentionDays;

🏁 Script executed:

#!/bin/bash
# 메서드 선언 확인
rg -nP "interface\s+FriendWateringLogRepository" -A200 | rg -n "deleteByWateredAtBefore"
# @Modifying 사용 여부
rg -n "@Modifying" -C2
# watered_at 컬럼 인덱스 힌트 (DDL/엔티티)
rg -n "watered_at|wateredAt|@Index|@Table" -C2
# 설정 파일에서 보존일 외부화 여부
fd -a "application*.yml" | xargs -I{} sh -c 'echo "--- {} ---"; rg -n "watering-log-retention|retention|cleanup" {}'

Length of output: 10651


GardenService.cleanupOldWateringLogs() 개선 필요 사항 확인

아래 사항들을 반영해 주세요:

  • FriendWateringLogRepository.deleteByWateredAtBefore 메서드는 이미 @Modifying 애노테이션을 사용하고 있으며 int 반환 타입을 갖고 있어 적절히 정의되어 있습니다.
  • 대용량 데이터 삭제 성능을 위해 watered_at 컬럼에 인덱스가 존재하는지 확인하고, 없다면 데이터베이스나 JPA 엔티티(@Index) 수준에서 인덱스를 추가해 주세요.
  • 현재 RETENTION_DAYS 상수가 코드에 하드코딩되어 있으므로, @value 또는 @ConfigurationProperties 를 활용해 application.yml 등 설정 파일로 외부화해 주세요.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 242-252, remove the hardcoded RETENTION_DAYS and inject it from
configuration (use @Value("${garden.watering.retention-days}") or a
@ConfigurationProperties class) so the cron job reads the retention days from
application.yml; keep the cleanup logic but use the injected int field and
ensure transactional and @Scheduled remain; verify
FriendWateringLogRepository.deleteByWateredAtBefore(...) is used as-is (it
already returns int and is @Modifying); and add an index on the watered_at
column at the DB level or annotate the JPA entity with @Table(indexes =
@Index(name = "idx_watered_at", columnList = "watered_at")) if missing to
improve delete performance.

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@
import com.example.cp_main_be.domain.member.user.domain.User;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Comment on lines +13 to 15
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

중복 @builder로 인한 Lombok 생성 코드 충돌

클래스 레벨과 생성자 레벨에 동시에 @Builder가 선언되어 있어 builder() 메서드 중복 생성으로 컴파일 오류가 발생합니다. 하나만 유지하세요. 보통 엔티티에는 클래스 레벨 @Builder만 두고, 생성자 레벨의 것은 제거합니다.

 @Getter
-@Builder
 @AllArgsConstructor(access = AccessLevel.PROTECTED)
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
 ...
-  @Builder
   public FriendWateringLog(User waterGiver, Garden wateredGarden) {
     this.waterGiver = waterGiver;
     this.wateredGarden = wateredGarden;
   }

필요 시 클래스 레벨에만 @Builder를 유지하시고, 빌더 팩토리 메서드명이 충돌하지 않도록 합니다.

Also applies to: 36-40

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java
around lines 13-15 (and also lines 36-40), there are duplicate Lombok @Builder
annotations causing duplicate builder() generation and compile errors; remove
the constructor-level @Builder and keep only the class-level @Builder (or vice
versa per project convention, typically keep class-level on entities), and if
you need a custom builder factory name use
@Builder(builderMethodName="customBuilder") on the single retained annotation so
no builder factory names collide.

@EntityListeners(AuditingEntityListener.class)
@Table(name = "friend_watering_log")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@
import com.example.cp_main_be.domain.member.user.domain.User;
import java.time.LocalDateTime;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface FriendWateringLogRepository extends JpaRepository<FriendWateringLog, Long> {

int countByWaterGiverAndWateredAtAfter(User waterGiver, LocalDateTime startOfDay);

boolean existsByWaterGiverAndWateredGardenAndWateredAtAfter(
User waterGiver, Garden wateredGarden, LocalDateTime startOfDay);

/**
* 지정된 날짜 이전의 모든 물주기 로그를 삭제합니다. (벌크 삭제)
*
* @return 삭제된 레코드의 수
*/
@Modifying
@Query("DELETE FROM FriendWateringLog fwl WHERE fwl.wateredAt < :cutoffDate")
int deleteByWateredAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
Comment on lines +24 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

벌크 삭제 후 영속성 컨텍스트 정합성 보장

JPQL 벌크 연산은 1차 캐시를 무시합니다. 이후 동일 트랜잭션 내에서 엔티티 상태 불일치가 생길 수 있으므로 @ModifyingclearAutomatically=true, flushAutomatically=true를 지정하는 것을 권장합니다.

-  @Modifying
+  @Modifying(clearAutomatically = true, flushAutomatically = true)
   @Query("DELETE FROM FriendWateringLog fwl WHERE fwl.wateredAt < :cutoffDate")
   int deleteByWateredAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
📝 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
@Modifying
@Query("DELETE FROM FriendWateringLog fwl WHERE fwl.wateredAt < :cutoffDate")
int deleteByWateredAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM FriendWateringLog fwl WHERE fwl.wateredAt < :cutoffDate")
int deleteByWateredAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java
around lines 24 to 26, the JPQL bulk delete ignores the persistence context
which can leave stale entities in the first‑level cache; update the @Modifying
annotation to include clearAutomatically = true and flushAutomatically = true so
the persistence context is flushed before and cleared after the bulk operation
(ensuring consistency within the same transaction).

}
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,8 @@ public void addComment(Comment comment) {
this.comments.add(comment);
comment.setDiary(this); // Comment 엔티티에 setDiary 메서드가 있다고 가정
}

public void setDiaryImage(DiaryImage diaryImage) {
this.diaryImage = diaryImage;
}
Comment on lines +101 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

setDiaryImage는 양방향 일관성을 보장하지 않음 → 가시성 축소 또는 내부 전용화 권장

updateImage는 자식 역참조까지 맞추지만 setDiaryImage는 필드만 교체하여 불일치 위험이 있습니다. 외부에서 실수로 호출될 여지를 줄이기 위해 가시성을 축소(패키지 프라이빗)하거나, 동일한 일관성 로직을 적용하세요.

두 가지 중 하나 선택:

옵션 A) 내부 전용화(패키지 프라이빗)

-  public void setDiaryImage(DiaryImage diaryImage) {
+  void setDiaryImage(DiaryImage diaryImage) {
     this.diaryImage = diaryImage;
   }

옵션 B) 양방향 일관성 유지

   public void setDiaryImage(DiaryImage diaryImage) {
-    this.diaryImage = diaryImage;
+    DiaryImage prev = this.diaryImage;
+    this.diaryImage = diaryImage;
+    if (prev != null && prev != diaryImage) {
+      prev.setDiary(null);
+    }
+    if (diaryImage != null) {
+      diaryImage.setDiary(this);
+    }
   }

해당 setter 호출처를 점검해 외부 사용 여부를 확인해 주세요.


🏁 Script executed:

#!/bin/bash
rg -nP '\bsetDiaryImage\s*\(' --type=java -C2

Length of output: 1098


setDiaryImage 메서드에 양방향 연관관계 일관성 추가 필요

setDiaryImage는 단순히 필드만 교체하여, 이전 또는 새로 설정된 DiaryImage 객체의 diary 참조가 불일치 상태로 남을 수 있습니다. 또한, DiaryService에서 직접 setDiaryImage(null)을 호출하고 있으므로 가시성을 축소하면 호출이 불가능해집니다.

아래와 같이 setter 내부에서

  1. 이전 DiaryImagediary 연결 해제
  2. 필드 교체
  3. DiaryImagediary 연결 설정

로직을 추가해주세요.

   public void setDiaryImage(DiaryImage diaryImage) {
-    this.diaryImage = diaryImage;
+    // 1) 이전 연결 해제
+    DiaryImage prev = this.diaryImage;
+    if (prev != null && prev != diaryImage) {
+      prev.setDiary(null);
+    }
+    // 2) 필드 교체
+    this.diaryImage = diaryImage;
+    // 3) 새 연결 설정
+    if (diaryImage != null) {
+      diaryImage.setDiary(this);
+    }
   }

— 또는 —

서비스 레이어에서 diary.setDiaryImage(null) 대신 diary.updateImage(null)을 사용하도록 변경해, 일관성 로직이 한 곳(updateImage)에만 모이게 할 수도 있습니다.

📝 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
public void setDiaryImage(DiaryImage diaryImage) {
this.diaryImage = diaryImage;
}
public void setDiaryImage(DiaryImage diaryImage) {
// 1) 이전 연결 해제
DiaryImage prev = this.diaryImage;
if (prev != null && prev != diaryImage) {
prev.setDiary(null);
}
// 2) 필드 교체
this.diaryImage = diaryImage;
// 3) 새 연결 설정
if (diaryImage != null) {
diaryImage.setDiary(this);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public class CreateDiaryRequest {
// 공개 여부는 선택사항으로, 값을 보내지 않으면 엔티티의 기본값(true)을 따릅니다.
@NotNull(message = "공개 여부는 필수값입니다. (true/false)")
private Boolean isPublic;

private Long imageId; // 있으면 받고 없으면 안받음
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

imageId 유효성 보강 + imageUrl과의 상호배타(XOR) 검증이 필요합니다

  • 값 범위 제약이 없어 0/음수 등 잘못된 ID가 들어올 수 있습니다.
  • imageUrl과 imageId를 동시에 보낼 경우의 우선순위가 정의되어 있지 않습니다. 명시적으로 둘 중 하나만 허용(XOR)하거나, 우선순위를 문서/검증으로 고정하세요.

적용 예시:

-  private Long imageId; // 있으면 받고 없으면 안받음
+  @Positive(message = "imageId는 양수여야 합니다.")
+  private Long imageId; // 있으면 받고 없으면 안받음

추가로, 클래스 수준의 단순 Bean Validation도 권장합니다(선택):

// 추가 import
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Positive;

// 클래스 내부에 추가
@AssertTrue(message = "imageUrl과 imageId는 동시에 보낼 수 없습니다.")
public boolean isImageReferenceValid() {
  return imageUrl == null || imageId == null; // 둘 다 null 허용, 동시 지정 금지
}

필요 import:

  • jakarta.validation.constraints.Positive
  • jakarta.validation.constraints.AssertTrue
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java
around line 29, strengthen validation for imageId and enforce mutual exclusivity
with imageUrl: add jakarta.validation.constraints.Positive to imageId to forbid
0/negative values and add a class-level validation method (e.g., an
@AssertTrue-annotated boolean) that returns true only when not both imageId and
imageUrl are set (allowing both null or exactly one set) to enforce XOR; also
add the necessary imports for Positive and AssertTrue and ensure the DTO is
validated by the controller/service (e.g., @Valid usage) so the constraints are
applied.

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ public class UpdateDiaryRequest {

@NotNull(message = "공개 여부는 필수값입니다. (true/false)")
private Boolean isPublic;

private Long imageId;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

imageId에 양수 제약 추가 및 imageUrl과의 동시 입력 방지 권장

Update에서도 동일하게 유효성 보강이 필요합니다.

적용 예시:

-  private Long imageId;
+  @Positive(message = "imageId는 양수여야 합니다.")
+  private Long imageId;

추가(선택): Create와 동일한 XOR 검증을 넣어 일관성 확보

import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Positive;

@AssertTrue(message = "imageUrl과 imageId는 동시에 보낼 수 없습니다.")
public boolean isImageReferenceValid() {
  return imageUrl == null || imageId == null;
}
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java
around line 27, add validation to ensure imageId is positive and prevent sending
imageId and imageUrl simultaneously: annotate imageId with a positive constraint
(e.g., @Positive) and add an @AssertTrue boolean method that returns true only
when at most one of imageId and imageUrl is non-null, with a clear validation
message like "imageUrl과 imageId는 동시에 보낼 수 없습니다."; this mirrors Create DTO
behavior to keep validation consistent.

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ public class DiaryController {
@PostMapping
public ResponseEntity<ApiResponse<DiaryResponse>> createDiary(
@AuthenticationPrincipal User user, @RequestBody @Valid CreateDiaryRequest request) {
Diary createdDiary = diaryService.createDiary(user, request);
return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(createdDiary)));
Long diaryId = diaryService.createDiary(user, request);
Diary diary = diaryService.findDiaryById(diaryId);
return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(diary)));
}

@Operation(summary = "내 일기 목록 조회", description = "내 일기 목록을 조회합니다")
Expand Down
Loading