From 6d4a7ec4a3e8bb258b7565e1a864da693dcc91fb Mon Sep 17 00:00:00 2001 From: itaekyung Date: Tue, 26 Aug 2025 21:55:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=ED=85=83=EB=B0=AD=20=EB=AC=BC?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20api=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/garden/service/GardenService.java | 131 ++++++++++++------ .../wateringlog/domain/FriendWateringLog.java | 7 +- .../FriendWateringLogRepository.java | 12 ++ .../cp_main_be/global/common/ErrorCode.java | 26 ++++ 4 files changed, 126 insertions(+), 50 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 54884abd..19bb387e 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 @@ -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; private static final int WATERING_POINTS = 2; private static final int SUNLIGHT_POINTS = 3; private static final int MAX_FRIEND_WATERING_PER_DAY = 3; @@ -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); + } + + // 물주기 남은 횟수 확인 + 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); } } @@ -141,7 +168,7 @@ public void unlockNewGardenSlot(Long userId) { if (currentGardens >= MAX_GARDEN_COUNT) { // 이미 최대치이므로 조용히 종료하거나 예외를 던질 수 있습니다. // 여기서는 추가 생성을 막고 그냥 리턴합니다. - return; + throw new CustomApiException(ErrorCode.GARDEN_SLOT_MAXED_OUT); } // [기존 레벨 체크 로직 삭제!] @@ -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); + } } diff --git a/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java b/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java index 7b7fc39b..4dd588d0 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java @@ -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) @EntityListeners(AuditingEntityListener.class) @Table(name = "friend_watering_log") diff --git a/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java b/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java index 56859b6b..d43b48de 100644 --- a/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java +++ b/src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java @@ -5,6 +5,9 @@ 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 { @@ -12,4 +15,13 @@ public interface FriendWateringLogRepository extends JpaRepository Date: Tue, 26 Aug 2025 22:55:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20rebase=20=EC=A0=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mission/diaryimage/domain/DiaryImage.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java index 59fdfb3b..5a31150c 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java @@ -1,5 +1,6 @@ package com.example.cp_main_be.domain.mission.diaryimage.domain; +import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.mission.diary.domain.Diary; import jakarta.persistence.*; import java.time.LocalDateTime; @@ -19,15 +20,23 @@ public class DiaryImage { @Column(name = "image_url", nullable = false) private String imageUrl; - @Setter @OneToOne - @JoinColumn(name = "diary_id", nullable = false) + @JoinColumn(name = "diary_id", unique = true) private Diary diary; + // 어떤 사용자가 업로드했는지 기록하여, 추후 권한 검증에 사용합니다. + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + @Column(name = "created_at") private LocalDateTime createdAt; public void updateImageUrl(String newImageUrl) { this.imageUrl = newImageUrl; } + + public void setDiary(Diary diary) { + this.diary = diary; + } } From aa392686afeabe7b6c9f59d0c9e756f675713653 Mon Sep 17 00:00:00 2001 From: itaekyung Date: Wed, 27 Aug 2025 01:30:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EC=9D=BC=EA=B8=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20api=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EC=9D=BC=EA=B8=B0=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20api=20=EC=88=98=EC=A0=95,=20=EC=98=A4=EB=A5=98=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/service/AdminService.java | 14 +- .../domain/mission/diary/domain/Diary.java | 4 + .../diary/dto/request/CreateDiaryRequest.java | 2 + .../diary/dto/request/UpdateDiaryRequest.java | 2 + .../diary/presentation/DiaryController.java | 5 +- .../mission/diary/service/DiaryService.java | 162 ++++++------------ .../dto/response/ImageUploadResponse.java | 11 ++ .../presentation/DiaryImageController.java | 28 +-- .../diaryimage/service/DiaryImageService.java | 42 +++++ .../global/common/CustomApiException.java | 5 + .../cp_main_be/global/common/ErrorCode.java | 7 + 11 files changed, 149 insertions(+), 133 deletions(-) create mode 100644 src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java create mode 100644 src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java diff --git a/src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java b/src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java index 924dee13..1bce9e36 100644 --- a/src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java +++ b/src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java @@ -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; @@ -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; } @@ -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); @@ -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 퀴즈의 선지 @@ -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; } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java index ee15708c..c7d6b8a2 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java @@ -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; + } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java index b63f99c6..6376558e 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java @@ -25,4 +25,6 @@ public class CreateDiaryRequest { // 공개 여부는 선택사항으로, 값을 보내지 않으면 엔티티의 기본값(true)을 따릅니다. @NotNull(message = "공개 여부는 필수값입니다. (true/false)") private Boolean isPublic; + + private Long imageId; } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java index 287729a0..5c6ab4ef 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java @@ -23,4 +23,6 @@ public class UpdateDiaryRequest { @NotNull(message = "공개 여부는 필수값입니다. (true/false)") private Boolean isPublic; + + private Long imageId; } 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 4ce6921a..51071f87 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 @@ -29,8 +29,9 @@ public class DiaryController { @PostMapping public ResponseEntity> 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 = "내 일기 목록을 조회합니다") 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 ad51452b..19223ccc 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 @@ -1,26 +1,21 @@ package com.example.cp_main_be.domain.mission.diary.service; -import com.example.cp_main_be.domain.avatar.image.ImageUploader; 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.Diary; import com.example.cp_main_be.domain.mission.diary.domain.repository.DiaryRepository; 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.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.List; import java.util.Objects; -import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -28,12 +23,10 @@ public class DiaryService { private final DiaryRepository diaryRepository; - private final UserRepository userRepository; - private final ImageUploader imageUploader; // 의존성 주입은 인터페이스로 private final DiaryImageRepository diaryImageRepository; private final LikeRepository likeRepository; - public Diary createDiary(User user, CreateDiaryRequest request) { + public Long createDiary(User user, CreateDiaryRequest request) { // 1. 먼저 Diary 객체를 생성하고 저장합니다 Diary diary = Diary.builder() @@ -42,22 +35,26 @@ public Diary createDiary(User user, CreateDiaryRequest request) { .content(request.getContent()) .isPublic(request.getIsPublic()) .build(); - - // 2. Diary를 먼저 저장하여 ID를 생성합니다 Diary savedDiary = diaryRepository.save(diary); - // 3. 이미지가 있다면 DiaryImage를 생성하고 연관관계를 설정합니다 - if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) { + // Step 2: 요청에 imageId가 포함된 경우, 미리 업로드된 이미지와 연결 + if (request.getImageId() != null) { DiaryImage diaryImage = - DiaryImage.builder() - .imageUrl(request.getImageUrl()) - .diary(savedDiary) // ✅ 연관관계의 주인인 DiaryImage에 Diary를 설정 - .build(); + diaryImageRepository + .findById(request.getImageId()) + .orElseThrow(() -> new CustomApiException(ErrorCode.IMAGE_NOT_FOUND)); + + // 보안: 이미지를 업로드한 사용자와 일기 작성자가 동일한지 확인 + if (!diaryImage.getUser().getId().equals(user.getId())) { + throw new CustomApiException(ErrorCode.ACCESS_DENIED); + } - diaryImageRepository.save(diaryImage); + // 연관관계 편의 메서드를 사용하여 양방향 관계를 모두 설정합니다. + // 이렇게 하면 savedDiary 객체에서도 diaryImage를 즉시 참조할 수 있습니다. + savedDiary.updateImage(diaryImage); } - return savedDiary; + return savedDiary.getId(); } // 일기 조회 (읽기 전용) @@ -65,7 +62,7 @@ public Diary createDiary(User user, CreateDiaryRequest request) { public Diary findDiaryById(Long diaryId) { return diaryRepository .findById(diaryId) - .orElseThrow(() -> new IllegalArgumentException("일기를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.DIARY_NOT_FOUND)); } // 일기 상세 조회 (읽기 전용) @@ -75,12 +72,12 @@ public DiaryInfoResponse getDiaryInfo(Long diaryId, User currentUser) { Diary diary = diaryRepository .findByIdWithDetails(diaryId) - .orElseThrow(() -> new IllegalArgumentException("일기를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.DIARY_NOT_FOUND)); // 비공개 글 접근 제어: 비로그인 또는 작성자 외 사용자는 차단 if (!diary.isPublic()) { if (currentUser == null || !diary.getUser().getId().equals(currentUser.getId())) { - throw new AccessDeniedException("비공개 일기를 볼 권한이 없습니다."); + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "비공개 일기를 볼 권한이 없습니다."); } } @@ -103,33 +100,42 @@ public List findMyDiaries(User user) { public Diary updateDiary(Long userId, Long diaryId, UpdateDiaryRequest request) { Diary diary = findDiaryById(diaryId); + // 소유권 확인 if (!Objects.equals(diary.getUser().getId(), userId)) { - throw new SecurityException("일기를 수정할 권한이 없습니다."); + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "일기를 수정할 권한이 없습니다."); } // 일기 내용 수정 diary.updateDiary(request.getTitle(), request.getContent(), request.getIsPublic()); - // 이미지 처리 - DiaryImage existingImage = diary.getDiaryImage(); - - if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) { - if (existingImage != null) { - // 기존 이미지가 있으면 URL만 업데이트 - existingImage.updateImageUrl(request.getImageUrl()); - } else { - // 기존 이미지가 없으면 새로 생성 - DiaryImage newDiaryImage = - DiaryImage.builder().imageUrl(request.getImageUrl()).diary(diary).build(); - diaryImageRepository.save(newDiaryImage); - } - } else { - // 이미지 URL이 null이거나 빈 문자열이면 기존 이미지 삭제 - if (existingImage != null) { - diaryImageRepository.delete(existingImage); - } + // --- 이미지 처리 로직 개선 --- + DiaryImage oldImage = diary.getDiaryImage(); + Long newImageId = request.getImageId(); + + // Case 1: 이미지가 변경되지 않은 경우 (둘 다 없거나, ID가 같음) + if (Objects.equals(oldImage != null ? oldImage.getId() : null, newImageId)) { + return diary; + } + + // Case 2: 기존 이미지를 제거해야 하는 경우 (교체 또는 삭제) + if (oldImage != null) { + diaryImageRepository.delete(oldImage); + diary.setDiaryImage(null); } + // Case 3: 새로운 이미지를 연결해야 하는 경우 (추가 또는 교체) + if (newImageId != null) { + DiaryImage newImage = + diaryImageRepository + .findById(newImageId) + .orElseThrow(() -> new CustomApiException(ErrorCode.IMAGE_NOT_FOUND)); + + // 새 이미지의 소유권 확인 + if (!newImage.getUser().getId().equals(userId)) { + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "다른 사용자의 이미지를 사용할 수 없습니다."); + } + diary.updateImage(newImage); // 연관관계 편의 메서드로 새 이미지 연결 + } return diary; } @@ -137,80 +143,10 @@ public Diary updateDiary(Long userId, Long diaryId, UpdateDiaryRequest request) public void deleteDiary(Long userId, Long diaryId) { Diary diary = findDiaryById(diaryId); - // 소유권 확인 if (!Objects.equals(diary.getUser().getId(), userId)) { - throw new SecurityException("일기를 삭제할 권한이 없습니다."); + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "일기를 삭제할 권한이 없습니다."); } diaryRepository.delete(diary); } - - public DiaryResponse saveDiaryImage(Long diaryId, MultipartFile file) { - // 1. 현재 로그인한 사용자 정보 가져오기 - String uuidString = - (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - UUID userUuid = UUID.fromString(uuidString); - User user = - userRepository - .findByUuid(userUuid) - .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 존재하지 않습니다.")); - - // 2. 다이어리 조회 - Diary diary = - diaryRepository - .findById(diaryId) - .orElseThrow(() -> new IllegalArgumentException("해당 다이어리가 존재하지 않습니다.")); - - // 3. 권한 검증: 일기 작성자와 현재 사용자가 동일한지 확인 - if (!diary.getUser().getId().equals(user.getId())) { - throw new IllegalStateException("해당 다이어리에 이미지를 추가할 권한이 없습니다."); - } - - // 4. 이미지 파일을 업로더에 전달하고 URL 받기 - String imageUrl = imageUploader.upload(file, "diary-images"); - - DiaryImage diaryImage = DiaryImage.builder().imageUrl(imageUrl).diary(diary).build(); - - // 새로 생성된 DiaryImage를 명시적으로 저장합니다. - diaryImageRepository.save(diaryImage); - - // 5. 다이어리 엔티티의 imageUrl 필드 업데이트 - diary.updateImage(diaryImage); - - return DiaryResponse.from(diary); - } - - public void deleteDiaryImage(Long diaryId, Long imageId) { - // 1. 현재 로그인한 사용자 정보 가져오기 - String uuidString = - (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - UUID userUuid = UUID.fromString(uuidString); - User user = - userRepository - .findByUuid(userUuid) - .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 존재하지 않습니다.")); - - // 2. 다이어리 조회 및 권한 검증 - Diary diary = - diaryRepository - .findById(diaryId) - .orElseThrow(() -> new IllegalArgumentException("해당 다이어리가 존재하지 않습니다.")); - if (!diary.getUser().getId().equals(user.getId())) { - throw new IllegalStateException("해당 다이어리에 이미지를 삭제할 권한이 없습니다."); - } - - // 3. 이미지 엔티티 조회 및 소유권 검증 - DiaryImage diaryImage = - diaryImageRepository - .findById(imageId) - .orElseThrow(() -> new IllegalArgumentException("해당 이미지가 존재하지 않습니다.")); - if (!diaryImage.getDiary().getId().equals(diaryId)) { - throw new IllegalArgumentException("해당 이미지는 다이어리에 속하지 않습니다."); - } - - // 4. 클라우드 스토리지(S3)에서 실제 파일 삭제 - imageUploader.delete(diaryImage.getImageUrl()); - - diaryImageRepository.deleteById(diaryImage.getId()); - } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java new file mode 100644 index 00000000..60bc9bc0 --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java @@ -0,0 +1,11 @@ +package com.example.cp_main_be.domain.mission.diaryimage.dto.response; + +import com.example.cp_main_be.domain.mission.diaryimage.domain.DiaryImage; + +/** 이미지 업로드 후 결과(ID, URL)를 반환하는 DTO입니다. */ +public record ImageUploadResponse(Long imageId, String imageUrl) { + + public static ImageUploadResponse from(DiaryImage diaryImage) { + return new ImageUploadResponse(diaryImage.getId(), diaryImage.getImageUrl()); + } +} diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java index 6321e919..6d2adc16 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java @@ -1,12 +1,15 @@ package com.example.cp_main_be.domain.mission.diaryimage.presentation; -import com.example.cp_main_be.domain.mission.diary.service.DiaryService; +import com.example.cp_main_be.domain.member.user.domain.User; +import com.example.cp_main_be.domain.mission.diaryimage.dto.response.ImageUploadResponse; +import com.example.cp_main_be.domain.mission.diaryimage.service.DiaryImageService; import com.example.cp_main_be.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -16,25 +19,28 @@ @Tag(name = "일기 이미지 API", description = "일기의 이미지 관련 기능을 제공합니다") public class DiaryImageController { - private final DiaryService diaryService; + // DiaryService가 아닌 DiaryImageService를 주입받아 역할을 분리합니다. + private final DiaryImageService diaryImageService; - @Operation(summary = "일기 이미지 등록", description = "일기 이미지를 등록합니다") - @PostMapping(value = "{diaryId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity> saveDiaryImage( - @PathVariable Long diaryId, @RequestParam MultipartFile file) { + @Operation( + summary = "일기 이미지 임시 업로드", + description = "일기 생성 전에 이미지를 먼저 업로드하고, 이미지 ID와 URL을 반환받습니다.") + @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadDiaryImage( + @RequestParam MultipartFile file, @AuthenticationPrincipal User user) { - // 1. 서비스 계층에 이미지 저장 및 연결 요청 - diaryService.saveDiaryImage(diaryId, file); + // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) + ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user); - // 2. 성공 응답 반환 - return ResponseEntity.ok(ApiResponse.success(null)); + // 2. 업로드된 이미지의 ID와 URL을 반환 + return ResponseEntity.ok(ApiResponse.success(response)); } @Operation(summary = "일기 이미지 삭제", description = "일기 이미지를 삭제합니다") @DeleteMapping("/diaries/{diaryId}/images/{imageId}") public ResponseEntity> deleteDiaryImage( @PathVariable Long diaryId, @PathVariable Long imageId) { - diaryService.deleteDiaryImage(diaryId, imageId); + diaryImageService.deleteDiaryImage(diaryId, imageId); return ResponseEntity.ok(ApiResponse.success(null)); } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java new file mode 100644 index 00000000..9f35e3cc --- /dev/null +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java @@ -0,0 +1,42 @@ +package com.example.cp_main_be.domain.mission.diaryimage.service; + +import com.example.cp_main_be.domain.member.user.domain.User; +import com.example.cp_main_be.domain.mission.diary.domain.repository.DiaryRepository; +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.diaryimage.dto.response.ImageUploadResponse; +import com.example.cp_main_be.global.infra.S3Uploader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Transactional +public class DiaryImageService { + + private final DiaryImageRepository diaryImageRepository; + private final DiaryRepository diaryRepository; + private final S3Uploader s3Uploader; + + /** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */ + public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { + String imageUrl = s3Uploader.upload(file, "diary-images"); + + DiaryImage diaryImage = + DiaryImage.builder() + .imageUrl(imageUrl) + .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용 + .build(); + + DiaryImage savedImage = diaryImageRepository.save(diaryImage); + + return ImageUploadResponse.from(savedImage); + } + + // 기존 삭제 로직은 여기에 위치하는 것이 더 적합합니다. + public void deleteDiaryImage(Long diaryId, Long imageId) { + // TODO: 삭제 로직 구현 (작성자 또는 관리자만 삭제 가능하도록 권한 검증 필요) + } +} diff --git a/src/main/java/com/example/cp_main_be/global/common/CustomApiException.java b/src/main/java/com/example/cp_main_be/global/common/CustomApiException.java index 5d4e015a..7a27cbb8 100644 --- a/src/main/java/com/example/cp_main_be/global/common/CustomApiException.java +++ b/src/main/java/com/example/cp_main_be/global/common/CustomApiException.java @@ -10,4 +10,9 @@ public CustomApiException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } + + public CustomApiException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } } diff --git a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java index af437b35..e9eb2b7a 100644 --- a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java +++ b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java @@ -26,6 +26,8 @@ public enum ErrorCode { GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."), INVALID_FILE(HttpStatus.BAD_REQUEST, "E40007", "적절하지 않은 파일 내용/포맷입니다."), FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E40008", "파일 크기 제한을 넘었습니다."), + INVALID_MISSION_TYPE_FOR_QUIZ_OPTION( + HttpStatus.BAD_REQUEST, "E40009", "퀴즈 타입의 미션에만 선지를 추가할 수 있습니다."), // 403 Forbidden ACCESS_DENIED(HttpStatus.FORBIDDEN, "E40301", "요청에 대한 권한이 없습니다."), @@ -34,6 +36,11 @@ public enum ErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "E40400", "리소스를 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E40401", "해당 사용자를 찾을 수 없습니다."), GARDEN_NOT_FOUND(HttpStatus.NOT_FOUND, "E40402", "해당 텃밭을 찾을 수 없습니다."), + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40403", "해당 이미지를 찾을 수 없습니다."), + DIARY_NOT_FOUND(HttpStatus.NOT_FOUND, "E40404", "해당 일기를 찾을 수 없습니다."), + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "E40405", "해당 미션을 찾을 수 없습니다."), + QUIZ_NOT_FOUND(HttpStatus.NOT_FOUND, "E40406", "해당 퀴즈를 찾을 수 없습니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "E40407", "해당 신고를 찾을 수 없습니다."), // 417 Expectation Failed UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."), From d68eee86442c88dd005750970f343e44ca955e77 Mon Sep 17 00:00:00 2001 From: itaekyung Date: Wed, 27 Aug 2025 01:40:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20ErrorCode=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avatar/avatar/service/AvatarService.java | 25 ++++++++++++------- .../cp_main_be/global/common/ErrorCode.java | 11 ++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java b/src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java index 0a04d36f..10384763 100644 --- a/src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java +++ b/src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java @@ -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; @@ -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)); } 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 = @@ -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, "사용자의 소원나무를 찾을 수 없습니다.")); WishTreeStage stage = wishTree.getStage(); int maxGardens = stage.getMaxGardens(); @@ -80,7 +86,8 @@ 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); } } @@ -88,14 +95,14 @@ public void createAvatar(Long userId, String nickname, String imageUrl, Long mas 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(); diff --git a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java index e9eb2b7a..e608b3f8 100644 --- a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java +++ b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java @@ -7,17 +7,9 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { - // ... 다른 에러 코드들 ... - AI_AVATAR_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E-50002", "아바타 생성에 실패했습니다."), - INVALID_FILE(HttpStatus.BAD_REQUEST, "E-50003", "적절하지 않은 파일 내용/포맷입니다."), - INVALID_TOKEN(HttpStatus.BAD_REQUEST, "E-50004", "부적절한 토큰입니다."), - FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E-50005", "파일 크기 제한을 넘었습니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, "E-50006", "Resource를 찾을 수 없습니다."), - UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E-50007", "파일 업로드에 실패했습니다."), - MAX_GARDENS_REACHED(HttpStatus.BAD_REQUEST, "E-50008", "현재 레벨의 최대 정원수에 도달했습니다."); // === 4xx Client Errors === // 400 Bad Request - INVALID_TOKEN(HttpStatus.BAD_REQUEST, "E40001", "부적절한 토큰입니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "E40001", "유효하지 않은 토큰입니다."), WATERING_COOL_DOWN(HttpStatus.BAD_REQUEST, "E40002", "아직 물을 줄 수 없습니다. 8시간이 지나야 가능합니다."), FRIEND_WATERING_LIMIT_EXCEEDED( HttpStatus.BAD_REQUEST, "E40003", "오늘은 다른 사람의 정원에 더 이상 물을 줄 수 없습니다."), @@ -41,6 +33,7 @@ public enum ErrorCode { MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "E40405", "해당 미션을 찾을 수 없습니다."), QUIZ_NOT_FOUND(HttpStatus.NOT_FOUND, "E40406", "해당 퀴즈를 찾을 수 없습니다."), REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "E40407", "해당 신고를 찾을 수 없습니다."), + AVATAR_MASTER_NOT_FOUND(HttpStatus.NOT_FOUND, "E40408", "해당 아바타 원본을 찾을 수 없습니다."), // 417 Expectation Failed UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."), From ff5f892f89526dd12c986fcbcfa7dd7554b35255 Mon Sep 17 00:00:00 2001 From: itaekyung Date: Wed, 27 Aug 2025 01:48:55 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mission/diary/dto/request/CreateDiaryRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java index 6376558e..87a4927f 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java @@ -26,5 +26,5 @@ public class CreateDiaryRequest { @NotNull(message = "공개 여부는 필수값입니다. (true/false)") private Boolean isPublic; - private Long imageId; + private Long imageId; // 있으면 받고 없으면 안받음 } From 891deb0f2105e14523a44919800dc105aaba346e Mon Sep 17 00:00:00 2001 From: itaekyung Date: Wed, 27 Aug 2025 02:37:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/DiaryImageController.java | 4 +- .../diaryimage/service/DiaryImageService.java | 38 +++++++++++++++++-- .../cp_main_be/global/common/ErrorCode.java | 1 + 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java index 6d2adc16..1faadc29 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java @@ -39,8 +39,8 @@ public ResponseEntity> uploadDiaryImage( @Operation(summary = "일기 이미지 삭제", description = "일기 이미지를 삭제합니다") @DeleteMapping("/diaries/{diaryId}/images/{imageId}") public ResponseEntity> deleteDiaryImage( - @PathVariable Long diaryId, @PathVariable Long imageId) { - diaryImageService.deleteDiaryImage(diaryId, imageId); + @PathVariable Long diaryId, @PathVariable Long imageId, @AuthenticationPrincipal User user) { + diaryImageService.deleteDiaryImage(diaryId, imageId, user.getId()); return ResponseEntity.ok(ApiResponse.success(null)); } } diff --git a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java index 9f35e3cc..4ba2d2f2 100644 --- a/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java +++ b/src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java @@ -5,8 +5,11 @@ 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.diaryimage.dto.response.ImageUploadResponse; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -14,6 +17,7 @@ @Service @RequiredArgsConstructor @Transactional +@Slf4j public class DiaryImageService { private final DiaryImageRepository diaryImageRepository; @@ -35,8 +39,36 @@ public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { return ImageUploadResponse.from(savedImage); } - // 기존 삭제 로직은 여기에 위치하는 것이 더 적합합니다. - public void deleteDiaryImage(Long diaryId, Long imageId) { - // TODO: 삭제 로직 구현 (작성자 또는 관리자만 삭제 가능하도록 권한 검증 필요) + public void deleteDiaryImage(Long diaryId, Long imageId, Long userId) { + DiaryImage image = + diaryImageRepository + .findById(imageId) + .orElseThrow(() -> new CustomApiException(ErrorCode.IMAGE_NOT_FOUND)); + + // Diary와 연결되어 있다면 diaryId 일치 여부 확인 + if (image.getDiary() != null) { + if (!image.getDiary().getId().equals(diaryId)) { + throw new CustomApiException(ErrorCode.INVALID_REQUEST, "요청한 일기와 이미지가 일치하지 않습니다."); + } + // 작성자 권한 검증 + if (!image.getDiary().getUser().getId().equals(userId)) { + throw new CustomApiException(ErrorCode.ACCESS_DENIED); + } + image.getDiary().setDiaryImage(null); + } else { + // Diary에 미연결된 임시 이미지의 경우 업로더만 삭제 가능 + if (!image.getUser().getId().equals(userId)) { + throw new CustomApiException(ErrorCode.ACCESS_DENIED); + } + } + + // S3 객체 삭제 (메서드 존재 시) + try { + s3Uploader.delete(image.getImageUrl()); + } catch (Exception ignored) { + // S3에서 객체 삭제에 실패하더라도 DB에서는 삭제를 계속 진행합니다. + log.warn("S3 이미지 삭제 실패. DB에서는 삭제를 계속합니다. URL: {}", image.getImageUrl(), ignored); + } + diaryImageRepository.delete(image); } } diff --git a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java index e608b3f8..ce2f7958 100644 --- a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java +++ b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java @@ -20,6 +20,7 @@ public enum ErrorCode { FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E40008", "파일 크기 제한을 넘었습니다."), INVALID_MISSION_TYPE_FOR_QUIZ_OPTION( HttpStatus.BAD_REQUEST, "E40009", "퀴즈 타입의 미션에만 선지를 추가할 수 있습니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "E40010", "잘못된 요청입니다."), // 403 Forbidden ACCESS_DENIED(HttpStatus.FORBIDDEN, "E40301", "요청에 대한 권한이 없습니다."),