From 942283525900ce869e173b7bb95835b117353437 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Mon, 3 Nov 2025 15:19:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=A6=89=EC=8B=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 멤버 즉시 삭제 요청 API 구현 * feat: 멤버와 관련된 모든 데이터를 즉시 삭제하는 기능 구현 (Trip, Stamp, Mission, DailyGoal, DailyMission, Pomodoro, StudyLog, StudyLogDailyMission, TripReport, TripReportStudyLog, Image) * feat: 이벤트 기반 이미지 삭제 로직 추가 * chore: S3 스토리지 설정 추가 (retry, time-out) * test: 멤버 즉시 삭제 통합 테스트 추가 * test: 멤버와 관련된 모든 데이터를 즉시 삭제하는 단위 테스트 추가 --- .../studytrip/global/config/S3Config.java | 17 ++ .../config/properties/S3Properties.java | 11 +- .../application/dto/CleanupImagesResult.java | 9 + .../event/ImageCleanupBatchEvent.java | 9 + .../application/event/ImageEventListener.java | 19 ++ .../event/ImageEventPublisher.java | 20 ++ .../application/service/ImageService.java | 65 ++++++ .../infra/s3/client/S3ImageStorageClient.java | 12 +- .../s3/provider/S3ImageStorageProvider.java | 31 ++- .../application/facade/MemberFacade.java | 68 ++++++- .../service/MemberCommandService.java | 4 + .../controller/MemberController.java | 9 + .../service/DailyMissionCommandService.java | 4 + .../service/MissionCommandService.java | 4 + .../DailyMissionQueryRepository.java | 2 + .../repository/MissionQueryRepository.java | 2 + .../DailyMissionQueryRepositoryAdapter.java | 19 ++ .../MissionQueryRepositoryAdapter.java | 18 ++ .../service/PomodoroCommandService.java | 4 + .../repository/PomodoroQueryRepository.java | 2 + .../PomodoroQueryRepositoryAdapter.java | 17 ++ .../service/StampCommandService.java | 4 + .../repository/StampQueryRepository.java | 2 + .../querydsl/StampQueryRepositoryAdapter.java | 15 ++ .../service/StudyLogCommandService.java | 4 + .../StudyLogDailyMissionCommandService.java | 4 + .../service/StudyLogQueryService.java | 4 + .../StudyLogDailyMissionQueryRepository.java | 2 + .../repository/StudyLogQueryRepository.java | 4 + ...LogDailyMissionQueryRepositoryAdapter.java | 18 ++ .../StudyLogQueryRepositoryAdapter.java | 14 ++ .../service/DailyGoalCommandService.java | 4 + .../service/TripCommandService.java | 4 + .../service/TripReportCommandService.java | 4 + .../service/TripReportQueryService.java | 6 + .../TripReportStudyLogCommandService.java | 4 + .../repository/DailyGoalQueryRepository.java | 2 + .../repository/TripQueryRepository.java | 2 + .../repository/TripReportQueryRepository.java | 6 + .../TripReportStudyLogQueryRepository.java | 2 + .../DailyGoalQueryRepositoryAdapter.java | 16 ++ .../querydsl/TripQueryRepositoryAdapter.java | 5 + .../TripReportQueryRepositoryAdapter.java | 24 +++ ...pReportStudyLogQueryRepositoryAdapter.java | 19 ++ src/main/resources/application-storage.yml | 7 +- .../application/service/ImageServiceTest.java | 185 ++++++++++++++++++ .../service/MemberCommandServiceTest.java | 19 ++ .../MemberControllerIntegrationTest.java | 65 ++++++ .../DailyMissionCommandServiceTest.java | 33 ++++ .../service/MissionCommandServiceTest.java | 33 ++++ .../service/PomodoroCommandServiceTest.java | 33 ++++ .../service/StampCommandServiceTest.java | 33 ++++ .../service/StudyLogCommandServiceTest.java | 33 ++++ ...tudyLogDailyMissionCommandServiceTest.java | 37 ++++ .../service/StudyLogQueryServiceTest.java | 38 ++++ .../service/DailyGoalCommandServiceTest.java | 33 ++++ .../service/TripCommandServiceTest.java | 33 ++++ .../service/TripReportCommandServiceTest.java | 33 ++++ .../service/TripReportQueryServiceTest.java | 42 ++++ .../TripReportStudyLogCommandServiceTest.java | 37 ++++ 60 files changed, 1195 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java create mode 100644 src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java create mode 100644 src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java create mode 100644 src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java diff --git a/src/main/java/com/ject/studytrip/global/config/S3Config.java b/src/main/java/com/ject/studytrip/global/config/S3Config.java index f589e6e..becf266 100644 --- a/src/main/java/com/ject/studytrip/global/config/S3Config.java +++ b/src/main/java/com/ject/studytrip/global/config/S3Config.java @@ -1,11 +1,14 @@ package com.ject.studytrip.global.config; import com.ject.studytrip.global.config.properties.S3Properties; +import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.RetryPolicy; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -18,7 +21,21 @@ public class S3Config { @Bean public S3Client s3Client() { + RetryPolicy retry = + RetryPolicy.builder(RetryMode.STANDARD) + .numRetries(props.retry().maxAttempts()) + .build(); + return S3Client.builder() + .overrideConfiguration( + config -> + config.retryPolicy(retry) + .apiCallTimeout( + Duration.ofSeconds( + props.timeout().apiCallInSeconds())) + .apiCallAttemptTimeout( + Duration.ofSeconds( + props.timeout().apiCallAttemptInSeconds()))) .region(Region.of(props.region())) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); diff --git a/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java b/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java index adf2a4f..8b09f79 100644 --- a/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java +++ b/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java @@ -3,4 +3,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "aws.s3") -public record S3Properties(String bucket, String region, long presignExpiresInMinutes) {} +public record S3Properties( + String bucket, + String region, + long presignExpiresInMinutes, + S3Retry retry, + S3Timeout timeout) { + public record S3Retry(int maxAttempts) {} + + public record S3Timeout(int apiCallInSeconds, int apiCallAttemptInSeconds) {} +} diff --git a/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java b/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java new file mode 100644 index 0000000..7a3baec --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.image.application.dto; + +import java.util.List; + +public record CleanupImagesResult(int success, List failedKeys) { + public static CleanupImagesResult of(int success, List failedKeys) { + return new CleanupImagesResult(success, failedKeys); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java b/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java new file mode 100644 index 0000000..855607f --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.image.application.event; + +import java.util.List; + +public record ImageCleanupBatchEvent(List imageUrls) { + public static ImageCleanupBatchEvent of(List imageUrls) { + return new ImageCleanupBatchEvent(imageUrls); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java b/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java new file mode 100644 index 0000000..81d7efd --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java @@ -0,0 +1,19 @@ +package com.ject.studytrip.image.application.event; + +import com.ject.studytrip.image.application.service.ImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ImageEventListener { + + private final ImageService imageService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCleanupBatch(ImageCleanupBatchEvent event) { + imageService.cleanupBatch(event.imageUrls()); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java b/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java new file mode 100644 index 0000000..cf57610 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.image.application.event; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ImageEventPublisher { + + private final ApplicationEventPublisher publisher; + + public void publishCleanupBatch(List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) return; + + ImageCleanupBatchEvent event = ImageCleanupBatchEvent.of(imageUrls); + publisher.publishEvent(event); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java index 9595691..4b30481 100644 --- a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java +++ b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java @@ -3,7 +3,9 @@ import com.ject.studytrip.global.config.properties.CdnProperties; import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.global.util.FilenameUtil; +import com.ject.studytrip.image.application.dto.CleanupImagesResult; import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.application.event.ImageEventPublisher; import com.ject.studytrip.image.domain.constants.ImageConstants; import com.ject.studytrip.image.domain.factory.ImageKeyFactory; import com.ject.studytrip.image.domain.policy.ImagePolicy; @@ -11,17 +13,26 @@ import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@Slf4j public class ImageService { + private static final int MAX_BATCH = 1000; + private final S3ImageStorageProvider s3Provider; private final TikaImageProbeProvider tikaProvider; + private final ImageEventPublisher publisher; + private final CdnProperties cdnProps; // Presigned URL 발급 @@ -75,6 +86,47 @@ public void cleanup(String imageUrl) { ImageUrlUtil.extractKey(cdnProps.domain(), imageUrl).ifPresent(s3Provider::deleteByKey); } + // 이미지 배치 삭제 + public void cleanupBatch(List imageUrls) { + List keys = extractKeysFromUrls(imageUrls); + if (keys.isEmpty()) return; + + int attempted = 0; + int succeeded = 0; + List failed = new ArrayList<>(); + + // 배치 삭제 (S3 DeleteObjects 최대 개수: 1000개) + for (int i = 0; i < keys.size(); i += MAX_BATCH) { + List batch = + new ArrayList<>(keys.subList(i, Math.min(keys.size(), i + MAX_BATCH))); + attempted += batch.size(); + + CleanupImagesResult result = s3Provider.deleteByKeys(batch); + succeeded += result.success(); + + if (!result.failedKeys().isEmpty()) failed.addAll(result.failedKeys()); + } + + log.info( + "Image Cleanup Batch attempted={}, succeeded={}, failed={}", + attempted, + succeeded, + failed.size()); + + if (!failed.isEmpty()) { + // 우선 로깅 처리 + // 추후 Outbox 패턴으로 확장 가능 + log.debug( + "Image Cleanup Batch Failed. failedCount={}, failedKeys={}", + failed.size(), + failed); + } + } + + public void publishCleanupBatchEvent(List imageUrls) { + publisher.publishCleanupBatch(imageUrls); + } + // 이미지 사이즈 검증, 실패 시 삭제 private void validateSizeWithCleanup(String tmpKey, long contentLength) { try { @@ -111,4 +163,17 @@ private void cleanupAndThrow(String tmpKey, CustomException exception) { s3Provider.deleteByKey(tmpKey); throw exception; } + + // 중복, 빈 값 제거 후 키 목록 추출 + private List extractKeysFromUrls(List urls) { + if (urls == null || urls.isEmpty()) return List.of(); + return urls.stream() + .filter(Objects::nonNull) + .map(url -> ImageUrlUtil.extractKey(cdnProps.domain(), url)) + .flatMap(Optional::stream) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .toList(); + } } diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java b/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java index b911f16..4d2d2cb 100644 --- a/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java +++ b/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java @@ -56,13 +56,11 @@ public void deleteObject(String key) { () -> client.deleteObject(builder -> builder.bucket(props.bucket()).key(key))); } - public void deleteObjects(List objects) { - S3ExceptionTranslator.executeWithExceptionTranslation( - () -> - client.deleteObjects( - builder -> - builder.bucket(props.bucket()) - .delete(d -> d.quiet(true).objects(objects)))); + public DeleteObjectsResponse deleteObjects(List objects) { + return client.deleteObjects( + builder -> + builder.bucket(props.bucket()) + .delete(d -> d.quiet(false).objects(objects))); } public void copyObject(String tmpKey, String finalKey) { diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java b/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java index 773b2d2..16031ba 100644 --- a/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java +++ b/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java @@ -1,13 +1,15 @@ package com.ject.studytrip.image.infra.s3.provider; +import com.ject.studytrip.image.application.dto.CleanupImagesResult; import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient; import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import software.amazon.awssdk.services.s3.model.HeadObjectResponse; -import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.model.*; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; @Component @@ -35,11 +37,32 @@ public void deleteByKey(String key) { s3Client.deleteObject(key); } - public void deleteByKeys(List keys) { + public CleanupImagesResult deleteByKeys(List keys) { List objects = keys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList(); + int attempts = objects.size(); - s3Client.deleteObjects(objects); + try { + DeleteObjectsResponse response = s3Client.deleteObjects(objects); + List failedKeys = + response.errors() == null + ? List.of() + : response.errors().stream() + .map(S3Error::key) + .filter(Objects::nonNull) + .filter(key -> !key.isBlank()) + .distinct() + .toList(); + int success = attempts - failedKeys.size(); + + return CleanupImagesResult.of(success, failedKeys); + } catch (S3Exception | SdkClientException e) { + // S3 삭제는 멱등이기 때문에 키가 존재하지 않거나, 중복이여도 에러가 발생하지 않음 + // 삭제 시 발생하는 에러는 보통 S3 내부 서버 문제(IO/네트워크) 혹은 인증/자격, 권한, 정책, 상태 등으로 발생 + // 따라서 요청 레벨 실패로 간주하고 배치를 전체 실패로 처리 + log.warn("S3 deleteObjects request failure: {}", e.getMessage(), e); + return CleanupImagesResult.of(0, keys); + } } public void copyByKey(String tmpKey, String finalKey) { diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java index 9d78d25..f1edfbf 100644 --- a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java +++ b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java @@ -13,9 +13,17 @@ import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import com.ject.studytrip.mission.application.service.DailyMissionCommandService; +import com.ject.studytrip.mission.application.service.MissionCommandService; +import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService; +import com.ject.studytrip.stamp.application.service.StampCommandService; +import com.ject.studytrip.studylog.application.service.StudyLogCommandService; +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService; import com.ject.studytrip.studylog.application.service.StudyLogQueryService; import com.ject.studytrip.trip.application.dto.TripCount; -import com.ject.studytrip.trip.application.service.TripQueryService; +import com.ject.studytrip.trip.application.service.*; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -30,8 +38,19 @@ public class MemberFacade { private final MemberQueryService memberQueryService; private final TripQueryService tripQueryService; private final StudyLogQueryService studyLogQueryService; + private final TripReportQueryService tripReportQueryService; private final MemberCommandService memberCommandService; + private final TripCommandService tripCommandService; + private final StampCommandService stampCommandService; + private final MissionCommandService missionCommandService; + private final DailyGoalCommandService dailyGoalCommandService; + private final PomodoroCommandService pomodoroCommandService; + private final DailyMissionCommandService dailyMissionCommandService; + private final StudyLogCommandService studyLogCommandService; + private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService; + private final TripReportCommandService tripReportCommandService; + private final TripReportStudyLogCommandService tripReportStudyLogCommandService; private final ImageService imageService; @@ -94,4 +113,51 @@ public void confirmImage(Long memberId, ConfirmProfileImageRequest request) { // 새로운 이미지 업데이트 memberCommandService.updateProfileImage(member, finalKey); } + + @Transactional + public void hardDeleteMemberCascade(Long memberId) { + Member member = memberQueryService.getValidMember(memberId); + + // 삭제할 이미지 목록 + List imageUrls = collectImageUrlsForMember(member); + + // 멤버의 모든 데이터 즉시 삭제 + cascadeHardDeleteByMemberId(member.getId()); + + // 이미지 삭제 이벤트 발행 + // 트랜잭션 커밋 이후 이미지 삭제 처리 + imageService.publishCleanupBatchEvent(imageUrls); + } + + private List collectImageUrlsForMember(Member member) { + List imageUrls = new ArrayList<>(); + + // TripReport 이미지 목록 조회 + imageUrls.addAll(tripReportQueryService.getTripReportImageUrlsByMemberId(member.getId())); + + // StudyLog 이미지 목록 조회 + imageUrls.addAll(studyLogQueryService.getStudyLogImageUrlsByMemberId(member.getId())); + + if (member.getProfileImage() != null && !member.getProfileImage().isBlank()) { + imageUrls.add(member.getProfileImage()); + } + return imageUrls; + } + + private void cascadeHardDeleteByMemberId(Long memberId) { + // 자식 -> 부모 순으로 삭제 진행 + tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsByMember(memberId); + tripReportCommandService.hardDeleteTripReportsByMember(memberId); + + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsByMember(memberId); + pomodoroCommandService.hardDeletePomodorosByMember(memberId); + studyLogCommandService.hardDeleteStudyLogsByMember(memberId); + dailyMissionCommandService.hardDeleteDailyMissionsByMember(memberId); + dailyGoalCommandService.hardDeleteDailyGoalsByMember(memberId); + + missionCommandService.hardDeleteMissionsByMember(memberId); + stampCommandService.hardDeleteStampsByMember(memberId); + tripCommandService.hardDeleteTripsByMember(memberId); + memberCommandService.hardDeleteMemberById(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java index a96cb1d..afcf7b6 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java @@ -55,6 +55,10 @@ public long hardDeleteMembers() { return memberQueryRepository.deleteAllByDeletedAtIsNotNull(); } + public void hardDeleteMemberById(Long memberId) { + memberRepository.deleteById(memberId); + } + private void validateMemberIsUnique(SocialProvider socialProvider, String socialId) { boolean isMemberDuplicated = memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId); diff --git a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java index 3599e46..4bc1924 100644 --- a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java +++ b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java @@ -135,4 +135,13 @@ public ResponseEntity confirm( memberFacade.confirmImage(Long.valueOf(memberId), request); return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); } + + @Operation(summary = "멤버 즉시 삭제", description = "멤버를 즉시 삭제하고 관련된 모든 데이터를 삭제합니다. (CASCADE)") + @DeleteMapping("/me/hard-delete") + public ResponseEntity deleteMemberHardDelete( + @AuthenticationPrincipal String memberId) { + memberFacade.hardDeleteMemberCascade(Long.valueOf(memberId)); + + return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionCommandService.java b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionCommandService.java index 65decb4..935fa56 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionCommandService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionCommandService.java @@ -40,4 +40,8 @@ public long hardDeleteDailyMissionsOwnedByDeletedMission() { public long hardDeleteDailyMissionsOwnedByDeletedDailyGoal() { return dailyMissionQueryRepository.deleteAllByDeletedDailyGoalOwner(); } + + public long hardDeleteDailyMissionsByMember(Long memberId) { + return dailyMissionQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/mission/application/service/MissionCommandService.java b/src/main/java/com/ject/studytrip/mission/application/service/MissionCommandService.java index dbabc87..fb7fc63 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/MissionCommandService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/MissionCommandService.java @@ -59,4 +59,8 @@ public long hardDeleteMissions() { public long hardDeleteMissionsOwnedByDeletedStamp() { return missionQueryRepository.deleteAllByDeletedStampOwner(); } + + public long hardDeleteMissionsByMember(Long memberId) { + return missionQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java index 7ae8a8f..afa5e97 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java @@ -13,4 +13,6 @@ public interface DailyMissionQueryRepository { long deleteAllByDeletedMissionOwner(); long deleteAllByDeletedDailyGoalOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java index 93da4aa..10f12a6 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java @@ -11,4 +11,6 @@ public interface MissionQueryRepository { long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedStampOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java index ecc78a9..72ae5fd 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java @@ -6,6 +6,7 @@ import com.ject.studytrip.mission.domain.repository.DailyMissionQueryRepository; import com.ject.studytrip.stamp.domain.model.QStamp; import com.ject.studytrip.trip.domain.model.QDailyGoal; +import com.ject.studytrip.trip.domain.model.QTrip; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -17,6 +18,7 @@ public class DailyMissionQueryRepositoryAdapter implements DailyMissionQueryRepository { private final JPAQueryFactory queryFactory; private final QDailyMission dailyMission = QDailyMission.dailyMission; + private final QTrip trip = QTrip.trip; private final QStamp stamp = QStamp.stamp; private final QMission mission = QMission.mission; private final QDailyGoal dailyGoal = QDailyGoal.dailyGoal; @@ -74,4 +76,21 @@ public long deleteAllByDeletedDailyGoalOwner() { .where(dailyGoal.deletedAt.isNotNull()))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(dailyMission.id) + .from(dailyMission) + .join(dailyMission.mission, mission) + .join(mission.stamp, stamp) + .join(stamp.trip, trip) + .where(trip.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(dailyMission).where(dailyMission.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java index ac51eec..85b07fa 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java @@ -4,6 +4,7 @@ import com.ject.studytrip.mission.domain.model.QMission; import com.ject.studytrip.mission.domain.repository.MissionQueryRepository; import com.ject.studytrip.stamp.domain.model.QStamp; +import com.ject.studytrip.trip.domain.model.QTrip; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -16,6 +17,7 @@ public class MissionQueryRepositoryAdapter implements MissionQueryRepository { private final JPAQueryFactory queryFactory; private final QMission mission = QMission.mission; private final QStamp stamp = QStamp.stamp; + private final QTrip trip = QTrip.trip; @Override public List findAllByIdsInFetchJoinStamp(List ids) { @@ -58,4 +60,20 @@ public long deleteAllByDeletedStampOwner() { .where(stamp.deletedAt.isNotNull()))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(mission.id) + .from(mission) + .join(mission.stamp, stamp) + .join(stamp.trip, trip) + .where(trip.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(mission).where(mission.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java index 2ebcc98..eda424a 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java +++ b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java @@ -43,4 +43,8 @@ public long hardDeletePomodoros() { public long hardDeletePomodorosOwnedByDeletedDailyGoal() { return pomodoroQueryRepository.deleteAllByDeletedDailyGoalOwner(); } + + public long hardDeletePomodorosByMember(Long memberId) { + return pomodoroQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java index 31c0b69..9160407 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java @@ -6,4 +6,6 @@ public interface PomodoroQueryRepository { long deleteAllByDeletedDailyGoalOwner(); long sumFocusHoursByTripId(Long tripId); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java index 2eb65ef..43f9ff0 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java @@ -6,6 +6,7 @@ import com.ject.studytrip.trip.domain.model.QTrip; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -53,4 +54,20 @@ public long sumFocusHoursByTripId(Long tripId) { return seconds / 3600L; // 정수 시간(내림) } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(pomodoro.id) + .from(pomodoro) + .join(pomodoro.dailyGoal, dailyGoal) + .join(dailyGoal.trip, trip) + .where(trip.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(pomodoro).where(pomodoro.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java b/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java index ce4824b..ca36b8f 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java +++ b/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java @@ -175,4 +175,8 @@ private int computeNextStampOrder(Long tripId) { Integer lastOrder = stampQueryRepository.findMaxStampOrderByTripId(tripId); return lastOrder == null ? 1 : lastOrder + 1; } + + public long hardDeleteStampsByMember(Long memberId) { + return stampQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java index 419af6a..c492cea 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java @@ -16,4 +16,6 @@ public interface StampQueryRepository { long deleteAllByDeletedTripOwner(); Integer findMaxStampOrderByTripId(Long tripId); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java index 2b63914..1587926 100644 --- a/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java @@ -84,4 +84,19 @@ public Integer findMaxStampOrderByTripId(Long tripId) { .where(stamp.trip.id.eq(tripId), stamp.deletedAt.isNull()) .fetchOne(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(stamp.id) + .from(stamp) + .join(stamp.trip, trip) + .where(trip.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(stamp).where(stamp.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java index 0db1360..c309be5 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java @@ -39,4 +39,8 @@ public void updateImageUrl(StudyLog studyLog, String imageUrl) { studyLog.updateImageUrl(imageUrl); } + + public long hardDeleteStudyLogsByMember(Long memberId) { + return studyLogQueryRepository.deleteByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandService.java index 1147441..92b876b 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandService.java @@ -39,4 +39,8 @@ public long hardDeleteStudyLogDailyMissionsOwnedByDeletedDailyMission() { public long hardDeleteStudyLogDailyMissionsOwnedByDeletedStudyLog() { return studyLogDailyMissionQueryRepository.deleteAllByDeletedStudyLogOwner(); } + + public long hardDeleteStudyLogDailyMissionsByMember(Long memberId) { + return studyLogDailyMissionQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java index de816ba..08700c2 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java @@ -60,4 +60,8 @@ public Slice getStudyLogsSliceByTripReportId(Long tripReportId, int pa public List getStudyLogIdsByTripId(Long tripId) { return studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId); } + + public List getStudyLogImageUrlsByMemberId(Long memberId) { + return studyLogQueryRepository.findImageUrlsByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java index 65f3114..b95bff6 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java @@ -13,4 +13,6 @@ Map> findStudyLogDailyMissionsGroupedByStudyLog long deleteAllByDeletedDailyMissionOwner(); long deleteAllByDeletedStudyLogOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java index 8568d58..c485942 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java @@ -22,4 +22,8 @@ Slice findSliceByTripReportIdOrderByCreatedAtDesc( Long tripReportId, Pageable pageable); List findAllIdsByTripIdOrderByCreatedDesc(Long tripId); + + List findImageUrlsByMemberId(Long memberId); + + long deleteByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java index 7917582..8cfa3e2 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java @@ -71,4 +71,22 @@ public long deleteAllByDeletedStudyLogOwner() { .where(studyLog.deletedAt.isNotNull()))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(studyLogDailyMission.id) + .from(studyLogDailyMission) + .join(studyLogDailyMission.studyLog, studyLog) + .where(studyLog.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory + .delete(studyLogDailyMission) + .where(studyLogDailyMission.id.in(ids)) + .execute(); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java index eb52064..616e994 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java @@ -140,6 +140,20 @@ public List findAllIdsByTripIdOrderByCreatedDesc(Long tripId) { .fetch(); } + @Override + public List findImageUrlsByMemberId(Long memberId) { + return queryFactory + .select(studyLog.imageUrl) + .from(studyLog) + .where(studyLog.member.id.eq(memberId)) + .fetch(); + } + + @Override + public long deleteByMemberId(Long memberId) { + return queryFactory.delete(studyLog).where(studyLog.member.id.eq(memberId)).execute(); + } + private OrderSpecifier[] orderSpecifiers(String order) { return (order.equalsIgnoreCase("OLDEST")) ? new OrderSpecifier[] {studyLog.createdAt.asc(), studyLog.id.asc()} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalCommandService.java index 94441d9..2def6b8 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalCommandService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalCommandService.java @@ -31,4 +31,8 @@ public long hardDeleteDailyGoals() { public long hardDeleteDailyGoalsOwnedByDeletedTrip() { return dailyGoalQueryRepository.deleteAllByDeletedTripOwner(); } + + public long hardDeleteDailyGoalsByMember(Long memberId) { + return dailyGoalQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripCommandService.java index 472bc68..445e4c0 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripCommandService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripCommandService.java @@ -76,4 +76,8 @@ public long hardDeleteTrips() { public long hardDeleteTripsOwnedByDeletedMember() { return tripQueryRepository.deleteAllByDeletedMemberOwner(); } + + public long hardDeleteTripsByMember(Long memberId) { + return tripQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java index 6ba8578..2e780fd 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java @@ -46,4 +46,8 @@ public long hardDeleteTripReports() { public long hardDeleteTripReportsOwnedByDeletedMember() { return tripReportQueryRepository.deleteAllByDeletedMemberOwner(); } + + public long hardDeleteTripReportsByMember(Long memberId) { + return tripReportQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java index c09e80e..a9e1141 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java @@ -4,6 +4,7 @@ import com.ject.studytrip.trip.domain.error.TripReportErrorCode; import com.ject.studytrip.trip.domain.model.TripReport; import com.ject.studytrip.trip.domain.policy.TripReportPolicy; +import com.ject.studytrip.trip.domain.repository.TripReportQueryRepository; import com.ject.studytrip.trip.domain.repository.TripReportRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -13,6 +14,7 @@ @RequiredArgsConstructor public class TripReportQueryService { private final TripReportRepository tripReportRepository; + private final TripReportQueryRepository tripReportQueryRepository; public TripReport getTripReport(Long tripReportId) { return tripReportRepository @@ -39,4 +41,8 @@ public List getTripReportsByMemberId(Long memberId) { return tripReportRepository.findAllByMemberIdAndDeletedAtIsNullOrderByCreatedAtDesc( memberId); } + + public List getTripReportImageUrlsByMemberId(Long memberId) { + return tripReportQueryRepository.findImageUrlsByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java index 5689c4c..25e47d9 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java @@ -28,4 +28,8 @@ public void createTripReportStudyLogs(TripReport tripReport, List stud public long hardDeleteTripReportStudyLogsOwnedByDeletedMember() { return tripReportStudyLogQueryRepository.deleteAllByDeletedMemberOwner(); } + + public long hardDeleteTripReportStudyLogsByMember(Long memberId) { + return tripReportStudyLogQueryRepository.deleteAllByMemberId(memberId); + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalQueryRepository.java index 6173c58..8590631 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalQueryRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalQueryRepository.java @@ -4,4 +4,6 @@ public interface DailyGoalQueryRepository { long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedTripOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java index c3612c8..331ac17 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java @@ -14,4 +14,6 @@ Slice findSliceByMemberIdAndCompletedFalseAndDeletedAtIsNull( long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedMemberOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportQueryRepository.java index 1a7bb2d..7ea31e0 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportQueryRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportQueryRepository.java @@ -1,7 +1,13 @@ package com.ject.studytrip.trip.domain.repository; +import java.util.List; + public interface TripReportQueryRepository { long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedMemberOwner(); + + List findImageUrlsByMemberId(Long memberId); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogQueryRepository.java index 26d597e..f6e6a04 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogQueryRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogQueryRepository.java @@ -2,4 +2,6 @@ public interface TripReportStudyLogQueryRepository { long deleteAllByDeletedMemberOwner(); + + long deleteAllByMemberId(Long memberId); } diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/DailyGoalQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/DailyGoalQueryRepositoryAdapter.java index eb4bcf5..8d0f856 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/querydsl/DailyGoalQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/DailyGoalQueryRepositoryAdapter.java @@ -5,6 +5,7 @@ import com.ject.studytrip.trip.domain.repository.DailyGoalQueryRepository; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -31,4 +32,19 @@ public long deleteAllByDeletedTripOwner() { .where(trip.deletedAt.isNotNull()))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(dailyGoal.id) + .from(dailyGoal) + .join(dailyGoal.trip, trip) + .where(trip.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(dailyGoal).where(dailyGoal.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java index b1d3d2d..6ae1a02 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java @@ -76,4 +76,9 @@ public long deleteAllByDeletedMemberOwner() { .where(member.deletedAt.isNotNull()))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + return queryFactory.delete(trip).where(trip.member.id.eq(memberId)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportQueryRepositoryAdapter.java index cee985d..dd9308a 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportQueryRepositoryAdapter.java @@ -5,6 +5,7 @@ import com.ject.studytrip.trip.domain.repository.TripReportQueryRepository; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -31,4 +32,27 @@ public long deleteAllByDeletedMemberOwner() { .where(member.deletedAt.isNotNull()))) .execute(); } + + @Override + public List findImageUrlsByMemberId(Long memberId) { + return queryFactory + .select(tripReport.imageUrl) + .from(tripReport) + .where(tripReport.member.id.eq(memberId)) + .fetch(); + } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(tripReport.id) + .from(tripReport) + .where(tripReport.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory.delete(tripReport).where(tripReport.id.in(ids)).execute(); + } } diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportStudyLogQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportStudyLogQueryRepositoryAdapter.java index 118bf41..3e6a63a 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportStudyLogQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripReportStudyLogQueryRepositoryAdapter.java @@ -7,6 +7,7 @@ import com.ject.studytrip.trip.domain.repository.TripReportStudyLogQueryRepository; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -40,4 +41,22 @@ public long deleteAllByDeletedMemberOwner() { .where(member.deletedAt.isNotNull())))) .execute(); } + + @Override + public long deleteAllByMemberId(Long memberId) { + List ids = + queryFactory + .select(tripReportStudyLog.id) + .from(tripReportStudyLog) + .join(tripReportStudyLog.tripReport, tripReport) + .where(tripReport.member.id.eq(memberId)) + .fetch(); + + if (ids.isEmpty()) return 0; + + return queryFactory + .delete(tripReportStudyLog) + .where(tripReportStudyLog.id.in(ids)) + .execute(); + } } diff --git a/src/main/resources/application-storage.yml b/src/main/resources/application-storage.yml index 0a28a75..1549aa6 100644 --- a/src/main/resources/application-storage.yml +++ b/src/main/resources/application-storage.yml @@ -7,4 +7,9 @@ aws: s3: bucket: ${S3_BUCKET_NAME:} region: ${AWS_REGION:} - presign-expires-in-minutes: ${PRESIGN_EXPIRES_IN_MINUTES:10} \ No newline at end of file + presign-expires-in-minutes: ${PRESIGN_EXPIRES_IN_MINUTES:10} + retry: + max-attempts: ${AWS_S3_MAX_ATTEMPTS:5} + timeout: + api-call-in-seconds: ${AWS_S3_API_CALL_TIMEOUT:10} + api-call-attempt-in-seconds: ${AWS_S3_API_CALL_ATTEMPT_TIMEOUT:5} \ No newline at end of file diff --git a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java index 997e3a5..7755dea 100644 --- a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java +++ b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java @@ -3,21 +3,26 @@ import static com.ject.studytrip.image.fixture.ImageTestConstants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import com.ject.studytrip.BaseUnitTest; import com.ject.studytrip.global.config.properties.CdnProperties; import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.image.application.dto.CleanupImagesResult; import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.application.event.ImageEventPublisher; import com.ject.studytrip.image.domain.error.ImageErrorCode; import com.ject.studytrip.image.fixture.ImageHeadInfoFixture; import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; import com.ject.studytrip.image.infra.s3.error.S3ErrorCode; import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -33,6 +38,7 @@ class ImageServiceTest extends BaseUnitTest { @Mock private S3ImageStorageProvider s3Provider; @Mock private TikaImageProbeProvider tikaProvider; @Mock private CdnProperties cdnProperties; + @Mock private ImageEventPublisher imageEventPublisher; @Nested @DisplayName("presign 메서드는") @@ -344,4 +350,183 @@ void shouldNotDeleteWhenUrlFormatIsInvalid() { verify(s3Provider, never()).deleteByKey(anyString()); } } + + @Nested + @DisplayName("cleanupBatch 메서드는") + class CleanupBatch { + private static final String IMAGE_BASE_URL = "https://test-cdn.cloudfront.net"; + private static final String VALID_IMAGE_URL1 = IMAGE_BASE_URL + "/members/1/image1.jpg"; + private static final String VALID_IMAGE_URL2 = IMAGE_BASE_URL + "/members/1/image2.jpg"; + private static final String VALID_KEY1 = "members/1/image1.jpg"; + private static final String VALID_KEY2 = "members/1/image2.jpg"; + + @Test + @DisplayName("빈 리스트로 호출하면 아무 작업도 수행하지 않는다") + void shouldDoNothingWhenListIsEmpty() { + // when + imageService.cleanupBatch(List.of()); + + // then + verify(s3Provider, never()).deleteByKeys(any()); + } + + @Test + @DisplayName("null 리스트로 호출하면 아무 작업도 수행하지 않는다") + void shouldDoNothingWhenListIsNull() { + // when + imageService.cleanupBatch(null); + + // then + verify(s3Provider, never()).deleteByKeys(any()); + } + + @Test + @DisplayName("유효한 이미지 URL 리스트로 배치 삭제를 수행한다") + void shouldDeleteImagesBatchWhenValidUrls() { + // given + List imageUrls = Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL2); + List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider).deleteByKeys(keys); + } + + @Test + @DisplayName("일부 이미지 삭제 실패 시 실패한 키를 수집한다") + void shouldCollectFailedKeysWhenSomeDeletionsFail() { + // given + List imageUrls = Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL2); + List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(keys)) + .willReturn(CleanupImagesResult.of(1, Arrays.asList(VALID_KEY2))); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider).deleteByKeys(keys); + } + + @Test + @DisplayName("1000개 이상의 이미지 URL이 들어오면 배치로 나누어 처리한다") + void shouldSplitBatchWhenUrlsExceedMaxBatch() { + // given + List imageUrls = new ArrayList<>(); + List keys = new ArrayList<>(); + for (int i = 0; i < 1500; i++) { + String url = IMAGE_BASE_URL + "/members/1/image" + i + ".jpg"; + String key = "members/1/image" + i + ".jpg"; + imageUrls.add(url); + keys.add(key); + } + + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(any())) + .willReturn(CleanupImagesResult.of(1000, List.of())); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider, atLeast(2)).deleteByKeys(any()); + } + + @Test + @DisplayName("null URL이 포함된 리스트는 필터링하여 처리한다") + void shouldFilterNullUrls() { + // given + List imageUrls = + Arrays.asList(VALID_IMAGE_URL1, null, VALID_IMAGE_URL2, "", "invalid-url"); + List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider).deleteByKeys(keys); + } + + @Test + @DisplayName("중복된 이미지 URL은 한 번만 처리한다") + void shouldRemoveDuplicateUrls() { + // given + List imageUrls = + Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL1, VALID_IMAGE_URL2); + List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider).deleteByKeys(keys); + } + + @Test + @DisplayName("잘못된 CDN 도메인을 가진 URL은 필터링하여 처리한다") + void shouldFilterInvalidCdnDomainUrls() { + // given + String wrongDomainUrl = "https://wrong-cdn.com/members/1/image1.jpg"; + List imageUrls = Arrays.asList(VALID_IMAGE_URL1, wrongDomainUrl); + List keys = Arrays.asList(VALID_KEY1); + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(1, List.of())); + + // when + imageService.cleanupBatch(imageUrls); + + // then + verify(s3Provider).deleteByKeys(keys); + } + } + + @Nested + @DisplayName("publishCleanupBatchEvent 메서드는") + class PublishCleanupBatchEvent { + + @Test + @DisplayName("유효한 이미지 URL 리스트로 이벤트를 발행한다") + void shouldPublishEventWhenImageUrlsAreValid() { + // given + List imageUrls = + Arrays.asList( + "https://cdn.example.com/members/1/image1.jpg", + "https://cdn.example.com/members/1/image2.jpg"); + + // when + imageService.publishCleanupBatchEvent(imageUrls); + + // then + verify(imageEventPublisher).publishCleanupBatch(imageUrls); + } + + @Test + @DisplayName("빈 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") + void shouldNotPublishEventWhenListIsEmpty() { + // when + imageService.publishCleanupBatchEvent(List.of()); + + // then + verify(imageEventPublisher).publishCleanupBatch(List.of()); + } + + @Test + @DisplayName("null 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") + void shouldNotPublishEventWhenListIsNull() { + // when + imageService.publishCleanupBatchEvent(null); + + // then + verify(imageEventPublisher).publishCleanupBatch(null); + } + } } diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java index 89af32e..399beb2 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import com.ject.studytrip.BaseUnitTest; import com.ject.studytrip.global.exception.CustomException; @@ -277,4 +278,22 @@ void shouldReturnCountWhenDeletedMembersExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteMemberById 메서드는") + class HardDeleteMemberById { + + @Test + @DisplayName("전달된 멤버 ID로 삭제를 수행한다") + void shouldDeleteById() { + // given + Long memberId = 123L; + + // when + memberCommandService.hardDeleteMemberById(memberId); + + // then + verify(memberRepository).deleteById(memberId); + } + } } diff --git a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java index 6773551..daffbd5 100644 --- a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java @@ -459,4 +459,69 @@ void shouldReturnBadRequestWhenTmpKeyIsEmpty() throws Exception { resultActions.andExpect(status().isBadRequest()); } } + + @Nested + @DisplayName("멤버 즉시 삭제 API") + class DeleteMemberHardDelete { + private ResultActions getResultActions(String accessToken) throws Exception { + return mockMvc.perform( + delete(BASE_MEMBER_URL + "/me/hard-delete") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = getResultActions(""); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("삭제된 멤버일 경우 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenMemberAlreadyDeleted() throws Exception { + // given + member.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 멤버 ID가 들어오면 멤버와 관련된 모든 데이터를 즉시 삭제한다.") + void shouldHardDeleteMemberAndAllRelatedDataWhenMemberIdIsValid() throws Exception { + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } } diff --git a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.java index 9ae0225..151d737 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.java @@ -178,4 +178,37 @@ void shouldReturnCountWhenDailyMissionsOwnedByDeletedDailyGoalExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteDailyMissionsByMember 메서드는") + class HardDeleteDailyMissionsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 데일리 미션이 없으면 0을 반환한다.") + void shouldReturnZeroWhenDailyMissionsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(dailyMissionQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = dailyMissionCommandService.hardDeleteDailyMissionsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 데일리 미션이 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenDailyMissionsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(dailyMissionQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = dailyMissionCommandService.hardDeleteDailyMissionsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.java index 0e0241d..b59e9f9 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.java @@ -306,4 +306,37 @@ void shouldReturnCountWhenMissionsOwnedByDeletedStampExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteMissionsByMember 메서드는") + class HardDeleteMissionsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 미션이 없으면 0을 반환한다.") + void shouldReturnZeroWhenMissionsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(missionQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = missionCommandService.hardDeleteMissionsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 미션이 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenMissionsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(missionQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = missionCommandService.hardDeleteMissionsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java index 21c86ea..58b7cf2 100644 --- a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java @@ -177,4 +177,37 @@ void shouldReturnCountWhenPomodorosOwnedByDeletedDailyGoal() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeletePomodorosByMember 메서드는") + class HardDeletePomodorosByMember { + + @Test + @DisplayName("특정 멤버가 소유한 뽀모도로가 없으면 0을 반환한다.") + void shouldReturnZeroWhenPomodorosOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(pomodoroQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = pomodoroCommandService.hardDeletePomodorosByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 뽀모도로가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenPomodorosOwnedByMemberExist() { + // given + Long memberId = 1L; + given(pomodoroQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = pomodoroCommandService.hardDeletePomodorosByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.java b/src/test/java/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.java index b58b60e..bc07180 100644 --- a/src/test/java/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.java @@ -575,4 +575,37 @@ void shouldIncreaseCompletedMissions() { .isEqualTo(initialCompletedMissions + increaseCount); } } + + @Nested + @DisplayName("hardDeleteStampsByMember 메서드는") + class HardDeleteStampsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 스탬프가 없으면 0을 반환한다.") + void shouldReturnZeroWhenStampsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(stampQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = stampCommandService.hardDeleteStampsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 스탬프가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenStampsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(stampQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = stampCommandService.hardDeleteStampsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java index 44c4358..8fb9809 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java @@ -202,4 +202,37 @@ void shouldUpdateImageUrlWhenStudyLogIsValid() { assertThat(studyLog.getImageUrl()).isNotEqualTo(oldImageUrl); } } + + @Nested + @DisplayName("hardDeleteStudyLogsByMember 메서드는") + class HardDeleteStudyLogsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 학습 로그가 없으면 0을 반환한다.") + void shouldReturnZeroWhenStudyLogsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(studyLogQueryRepository.deleteByMemberId(memberId)).willReturn(0L); + + // when + long result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 학습 로그가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenStudyLogsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(studyLogQueryRepository.deleteByMemberId(memberId)).willReturn(5L); + + // when + long result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java index 247aa80..46fdc33 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java @@ -186,4 +186,41 @@ void shouldReturnCountWhenStudyLogDailyMissionsOwnedByDeletedStudyLogExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteStudyLogDailyMissionsByMember 메서드는") + class HardDeleteStudyLogDailyMissionsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 학습 로그 데일리 미션이 없으면 0을 반환한다.") + void shouldReturnZeroWhenStudyLogDailyMissionsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(studyLogDailyMissionQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsByMember( + memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 학습 로그 데일리 미션이 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenStudyLogDailyMissionsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(studyLogDailyMissionQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsByMember( + memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java index 0c6f5dc..d978064 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java @@ -319,4 +319,42 @@ void shouldReturnStudyLogIdsWhenStudyLogExists() { assertThat(result.get(1)).isEqualTo(studyLogId2); } } + + @Nested + @DisplayName("getStudyLogImageUrlsByMemberId 메서드는") + class GetStudyLogImageUrlsByMemberId { + + @Test + @DisplayName("이미지가 없으면 빈 리스트를 반환한다") + void shouldReturnEmptyListWhenNoImages() { + // given + Long memberId = member.getId(); + given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(List.of()); + + // when + List result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("이미지가 존재하면 URL 리스트를 반환한다") + void shouldReturnImageUrlsWhenExist() { + // given + Long memberId = member.getId(); + List imageUrls = + List.of( + "https://cdn.example.com/studylogs/1.jpg", + "https://cdn.example.com/studylogs/2.jpg"); + given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(imageUrls); + + // when + List result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId); + + // then + assertThat(result).hasSize(2); + assertThat(result).isEqualTo(imageUrls); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.java index 37a34b5..a67641e 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.java @@ -133,4 +133,37 @@ void shouldReturnCountWhenDailyGoalsOwnedByDeletedTripExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteDailyGoalsByMember 메서드는") + class HardDeleteDailyGoalsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 데일리 목표가 없으면 0을 반환한다.") + void shouldReturnZeroWhenDailyGoalsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(dailyGoalQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = dailyGoalCommandService.hardDeleteDailyGoalsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 데일리 목표가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenDailyGoalsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(dailyGoalQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = dailyGoalCommandService.hardDeleteDailyGoalsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripCommandServiceTest.java index 9aaa144..75673af 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripCommandServiceTest.java @@ -323,4 +323,37 @@ void shouldReturnCountWhenTripsOwnedByDeletedMemberExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteTripsByMember 메서드는") + class HardDeleteTripsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 여행이 없으면 0을 반환한다.") + void shouldReturnZeroWhenTripsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(tripQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = tripCommandService.hardDeleteTripsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 여행이 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenTripsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(tripQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = tripCommandService.hardDeleteTripsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java index 8e69201..ddf098a 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java @@ -151,4 +151,37 @@ void shouldReturnCountWhenTripReportsOwnedByDeletedMemberExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteTripReportsByMember 메서드는") + class HardDeleteTripReportsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 여행 리포트가 없으면 0을 반환한다.") + void shouldReturnZeroWhenTripReportsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(tripReportQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = tripReportCommandService.hardDeleteTripReportsByMember(memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 여행 리포트가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenTripReportsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(tripReportQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = tripReportCommandService.hardDeleteTripReportsByMember(memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java index 480aac5..da45f8f 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java @@ -10,6 +10,7 @@ import com.ject.studytrip.member.fixture.MemberFixture; import com.ject.studytrip.trip.domain.error.TripReportErrorCode; import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportQueryRepository; import com.ject.studytrip.trip.domain.repository.TripReportRepository; import com.ject.studytrip.trip.fixture.TripReportFixture; import java.util.List; @@ -26,6 +27,7 @@ class TripReportQueryServiceTest extends BaseUnitTest { @InjectMocks private TripReportQueryService tripReportQueryService; @Mock private TripReportRepository tripReportRepository; + @Mock private TripReportQueryRepository tripReportQueryRepository; private Member member; private TripReport tripReport1; @@ -190,4 +192,44 @@ void shouldReturnTripReportsWhenTripReportExists() { assertThat(result.get(1).getId()).isEqualTo(tripReport2.getId()); } } + + @Nested + @DisplayName("getTripReportImageUrlsByMemberId 메서드는") + class GetTripReportImageUrlsByMemberId { + + @Test + @DisplayName("이미지가 없으면 빈 리스트를 반환한다") + void shouldReturnEmptyListWhenNoImages() { + // given + Long memberId = member.getId(); + given(tripReportQueryRepository.findImageUrlsByMemberId(memberId)) + .willReturn(List.of()); + + // when + List result = tripReportQueryService.getTripReportImageUrlsByMemberId(memberId); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("이미지가 존재하면 URL 리스트를 반환한다") + void shouldReturnImageUrlsWhenExist() { + // given + Long memberId = member.getId(); + List imageUrls = + List.of( + "https://cdn.example.com/reports/1.jpg", + "https://cdn.example.com/reports/2.jpg"); + given(tripReportQueryRepository.findImageUrlsByMemberId(memberId)) + .willReturn(imageUrls); + + // when + List result = tripReportQueryService.getTripReportImageUrlsByMemberId(memberId); + + // then + assertThat(result).hasSize(2); + assertThat(result).isEqualTo(imageUrls); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java index 437e892..ef7b24c 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java @@ -113,4 +113,41 @@ void shouldReturnCountWhenTripReportsOrStudyLogsOwnedByDeletedMemberExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("hardDeleteTripReportStudyLogsByMember 메서드는") + class HardDeleteTripReportStudyLogsByMember { + + @Test + @DisplayName("특정 멤버가 소유한 여행 리포트 학습 로그가 없으면 0을 반환한다.") + void shouldReturnZeroWhenTripReportStudyLogsOwnedByMemberDoNotExist() { + // given + Long memberId = 1L; + given(tripReportStudyLogQueryRepository.deleteAllByMemberId(memberId)).willReturn(0L); + + // when + long result = + tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsByMember( + memberId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("특정 멤버가 소유한 여행 리포트 학습 로그가 있으면 해당 개수를 반환한다.") + void shouldReturnCountWhenTripReportStudyLogsOwnedByMemberExist() { + // given + Long memberId = 1L; + given(tripReportStudyLogQueryRepository.deleteAllByMemberId(memberId)).willReturn(5L); + + // when + long result = + tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsByMember( + memberId); + + // then + assertThat(result).isEqualTo(5L); + } + } }