diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java index 9f5f985..48d7c8f 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java @@ -11,6 +11,7 @@ public enum SuccessCode implements SuccessResultCode { ACTIVITY_COMPLETED(HttpStatus.OK, "활동 기록 저장에 성공했습니다."), + ACTIVITY_DELETED(HttpStatus.OK, "활동 기록 삭제에 성공했습니다."), ACTIVITY_EDIT_LIST_GET_SUCCESS(HttpStatus.OK, "활동 수정 목록 조회에 성공했습니다."), ACTIVITY_RECAP_GET_SUCCESS(HttpStatus.OK, "활동 리캡 조회에 성공했습니다."), ACTIVITY_STATISTICS_GET_SUCCESS(HttpStatus.OK, "활동 통계 기록 조회에 성공했습니다."), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java index 4908ea9..4d5c695 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java @@ -10,21 +10,21 @@ import org.sopt.routee.activity.internal.controller.dto.request.ActivityStatusUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.request.ActivityTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.request.ImageUrlRequest; +import org.sopt.routee.activity.internal.controller.dto.response.ActivitiesByDateResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityEditListResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityRecapResponse; -import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatusResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatisticsResponse; -import org.sopt.routee.activity.internal.controller.dto.response.ActivitiesByDateResponse; +import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatusResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityTitleResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityTrackResponse; import org.sopt.routee.activity.internal.controller.dto.response.ImageUrlResponse; import org.sopt.routee.activity.internal.service.ActivityService; import org.sopt.routee.activity.internal.service.dto.command.GetActivityRecapCommand; +import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityEditListResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityRecapResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityStatisticsResult; -import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityTrackResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; import org.sopt.routee.activity.internal.service.dto.result.ImageUrlResult; @@ -39,9 +39,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -140,6 +141,17 @@ public ResponseEntity> complete( .body(ApiResponse.success(SuccessCode.ACTIVITY_COMPLETED)); } + @DeleteMapping("/activity/{activityId}") + public ResponseEntity> delete( + @AuthenticationPrincipal Long memberId, + @PathVariable(name = "activityId") Long activityId + ) { + activityService.delete(activityId, memberId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.ACTIVITY_DELETED)); + } + @GetMapping("/activity/{activityId}/statistics") public ResponseEntity> getStatistics( @AuthenticationPrincipal Long memberId, @@ -149,7 +161,8 @@ public ResponseEntity> getStatistics ActivityStatisticsResult result = activityService.getStatistics(activityId, memberId, timeZone); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ACTIVITY_STATISTICS_GET_SUCCESS, ActivityStatisticsResponse.from(result))); + .body(ApiResponse.success(SuccessCode.ACTIVITY_STATISTICS_GET_SUCCESS, + ActivityStatisticsResponse.from(result))); } @GetMapping("/activity/{activityId}/track") @@ -186,7 +199,8 @@ public ResponseEntity> getActivityEdit ); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ACTIVITY_EDIT_LIST_GET_SUCCESS, ActivityEditListResponse.from(result))); + .body( + ApiResponse.success(SuccessCode.ACTIVITY_EDIT_LIST_GET_SUCCESS, ActivityEditListResponse.from(result))); } @GetMapping("/archive/activity") @@ -198,6 +212,7 @@ public ResponseEntity> getActivitiesBy ActivitiesByDateResult result = activityService.getActivitiesByDate(memberId, date, timeZone); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ARCHIVE_ACTIVITY_LIST_GET_SUCCESS, ActivitiesByDateResponse.from(result))); + .body(ApiResponse.success(SuccessCode.ARCHIVE_ACTIVITY_LIST_GET_SUCCESS, + ActivitiesByDateResponse.from(result))); } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index 9f7919f..b15ac3d 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java @@ -256,6 +256,23 @@ ResponseEntity> complete( @Valid @RequestBody ActivityCompleteRequest request ); + @Operation(summary = "활동 삭제", description = "인증된 사용자의 활동 기록을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "활동 삭제 성공", + content = @Content( + examples = @ExampleObject(value = "{\"status\":200,\"code\":\"ACTIVITY_DELETED\",\"message\":\"활동 기록 삭제에 성공했습니다.\",\"data\":null}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "404", description = "활동 기록이 존재하지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "ACTIVITY_NOT_FOUND", + value = "{\"status\":404,\"code\":\"ACTIVITY_NOT_FOUND\",\"message\":\"활동 기록이 존재하지 않습니다.\"}"))) + }) + ResponseEntity> delete( + Long memberId, + @PathVariable(name = "activityId") Long activityId + ); + @Operation(summary = "활동 통계 기록 조회", description = "인증된 사용자의 활동 통계 기록을 조회합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "활동 통계 기록 조회 성공", diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java index 3eb8e90..3f33fe3 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java @@ -46,12 +46,29 @@ void upsertDailySummary( @Modifying @Query(""" UPDATE ActivityDailySummary ads SET ads.coverActivityId = :coverActivityId, ads.coverImageObjectKey = :coverImageObjectKey - WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate + WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate AND ads.coverActivityId = :previousCoverActivityId """) void updateCoverImage( @Param("memberId") Long memberId, @Param("activityDate") LocalDate activityDate, @Param("coverActivityId") Long coverActivityId, - @Param("coverImageObjectKey") String coverImageObjectKey + @Param("coverImageObjectKey") String coverImageObjectKey, + @Param("previousCoverActivityId") Long previousCoverActivityId + ); + + @Modifying + @Query(""" + UPDATE ActivityDailySummary ads + SET ads.totalDurationSec = ads.totalDurationSec - :durationSec, ads.activityCount = ads.activityCount - 1 + WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate + """) + void decrementDailySummary( + @Param("memberId") Long memberId, + @Param("activityDate") LocalDate activityDate, + @Param("durationSec") Integer durationSec ); + + @Modifying + @Query("DELETE FROM ActivityDailySummary ads WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate AND ads.activityCount <= 0") + void deleteIfEmpty(@Param("memberId") Long memberId, @Param("activityDate") LocalDate activityDate); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java index 3ee492c..4b2e398 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java @@ -3,10 +3,14 @@ import java.time.LocalDate; import java.time.YearMonth; import java.util.List; +import java.util.Optional; +import org.sopt.routee.activity.internal.entity.activity.Activity; +import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.summary.ActivityDailySummary; import org.sopt.routee.activity.internal.mapper.ActivityDailySummaryMapper; import org.sopt.routee.activity.internal.repository.ActivityDailySummaryRepository; +import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.service.dto.result.ActivityDailySummaryResult; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; @@ -23,6 +27,7 @@ public class ActivityDailySummaryService { private final ActivityDailySummaryRepository activityDailySummaryRepository; + private final ActivityRepository activityRepository; private final FileImageAccessUrlPort fileImageAccessUrlPort; @Transactional(readOnly = true) @@ -50,8 +55,22 @@ public void deleteActivityDailySummariesByMemberId(long memberId) { } @Transactional - public void refreshCoverImage(Long memberId, LocalDate activityDate, Long coverActivityId, String coverImageObjectKey) { - activityDailySummaryRepository.updateCoverImage(memberId, activityDate, coverActivityId, coverImageObjectKey); + public void refreshCoverAfterActivityChanged(Long memberId, LocalDate activityDate, Long previousCoverActivityId) { + Optional replacementCover = activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + memberId, activityDate, ActivityStatus.ACTIVITY_COMPLETED); + + activityDailySummaryRepository.updateCoverImage( + memberId, activityDate, + replacementCover.map(Activity::getId).orElse(null), + replacementCover.map(Activity::getCoverImageObjectKey).orElse(null), + previousCoverActivityId); + } + + @Transactional + public void removeActivity(Long memberId, LocalDate activityDate, Integer durationSec) { + activityDailySummaryRepository.decrementDailySummary(memberId, activityDate, durationSec); + activityDailySummaryRepository.deleteIfEmpty(memberId, activityDate); } private String generateCoverImageUrl(Long memberId, ActivityDailySummary summary) { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index f5bc6ac..f9aff13 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -373,6 +373,36 @@ public void deleteActivitiesByMemberId(long memberId) { activityRepository.deleteByMemberId(memberId); } + public void delete(Long activityId, Long memberId) { + Activity deletedActivity = transactionTemplate.execute(status -> { + Activity activity = activityRepository.findByIdAndMemberId(activityId, memberId) + .orElseThrow(ActivityNotFoundException::new); + + routeRepository.deleteByActivityIdIn(List.of(activityId)); + timelineRepository.deleteByActivityIdIn(List.of(activityId)); + activityRepository.delete(activity); + + if (activity.getActivityStatus().isCompleted()) { + refreshDailySummaryAfterDelete(activity); + } + + return activity; + }); + + log.info("Activity deleted. activityId={}, memberId={}", activityId, memberId); + Thread.startVirtualThread(() -> deleteActivityImageDirectories(memberId, List.of(deletedActivity.getId()))); + } + + private void refreshDailySummaryAfterDelete(Activity activity) { + LocalDate activityDate = activity.getActivityDateWithTimezone(); + if (activityDate == null) { + return; + } + + activityDailySummaryService.removeActivity(activity.getMemberId(), activityDate, activity.getDurationSec()); + activityDailySummaryService.refreshCoverAfterActivityChanged(activity.getMemberId(), activityDate, activity.getId()); + } + private String generateTimelineImageUrl(Long memberId, Long activityId, Timeline timeline, FileUploadImageSize imageSize) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 032caba..c785cbf 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -5,7 +5,6 @@ import java.util.List; import org.sopt.routee.activity.internal.entity.activity.Activity; -import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; @@ -64,7 +63,8 @@ public List getTimelines(Long activityId, Long memberId) { } return timelineRepository.findByActivityIdOrderByCreatedAtAsc(activityId).stream() - .map(timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(memberId, activityId, timeline))) + .map( + timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(memberId, activityId, timeline))) .toList(); } @@ -131,15 +131,7 @@ private void refreshDailySummaryCoverIfNeeded(Activity activity) { return; } - Activity firstActivityWithCover = activityRepository - .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( - activity.getMemberId(), activityDate, ActivityStatus.ACTIVITY_COMPLETED) - .orElse(null); - - Long coverActivityId = firstActivityWithCover == null ? null : firstActivityWithCover.getId(); - String coverImageObjectKey = firstActivityWithCover == null ? null : firstActivityWithCover.getCoverImageObjectKey(); - - activityDailySummaryService.refreshCoverImage(activity.getMemberId(), activityDate, coverActivityId, coverImageObjectKey); + activityDailySummaryService.refreshCoverAfterActivityChanged(activity.getMemberId(), activityDate, activity.getId()); } private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long memberId) { @@ -152,7 +144,8 @@ private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long member private void deleteTimelineImage(Long memberId, Long activityId, String objectKey) { try { fileDeletePort.deleteImage( - new FileDeleteCommand(FileUploadDirectory.TIMELINE, memberId.toString(), activityId.toString(), objectKey)); + new FileDeleteCommand(FileUploadDirectory.TIMELINE, memberId.toString(), activityId.toString(), + objectKey)); } catch (BaseException e) { log.warn("Timeline image delete failed. activityId={}, objectKey={}", activityId, objectKey, e); } diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java new file mode 100644 index 0000000..ad79ef3 --- /dev/null +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java @@ -0,0 +1,75 @@ +package org.sopt.routee.activity.internal.service; + +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.internal.entity.activity.Activity; +import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; +import org.sopt.routee.activity.internal.repository.ActivityDailySummaryRepository; +import org.sopt.routee.activity.internal.repository.ActivityRepository; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; + +@ExtendWith(MockitoExtension.class) +class ActivityDailySummaryServiceTest { + + private static final Long MEMBER_ID = 1L; + private static final LocalDate ACTIVITY_DATE = LocalDate.of(2026, 7, 7); + private static final Long DELETED_COVER_ACTIVITY_ID = 10L; + + @Mock + private ActivityDailySummaryRepository activityDailySummaryRepository; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + private ActivityDailySummaryService activityDailySummaryService; + + @BeforeEach + void setUp() { + activityDailySummaryService = new ActivityDailySummaryService( + activityDailySummaryRepository, + activityRepository, + fileImageAccessUrlPort + ); + } + + @Test + void refreshCoverAfterActivityChanged_대표_커버_삭제_후_남은_활동에_이미지가_있으면_다음_활동을_새_커버로_갱신한다() { + Activity nextActivity = Activity.builder() + .id(20L) + .coverImageObjectKey("next-cover.jpg") + .build(); + when(activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + MEMBER_ID, ACTIVITY_DATE, ActivityStatus.ACTIVITY_COMPLETED)) + .thenReturn(Optional.of(nextActivity)); + + activityDailySummaryService.refreshCoverAfterActivityChanged(MEMBER_ID, ACTIVITY_DATE, DELETED_COVER_ACTIVITY_ID); + + verify(activityDailySummaryRepository).updateCoverImage( + MEMBER_ID, ACTIVITY_DATE, 20L, "next-cover.jpg", DELETED_COVER_ACTIVITY_ID); + } + + @Test + void refreshCoverAfterActivityChanged_대표_커버_삭제_후_남은_활동에_이미지가_없으면_커버를_null로_갱신한다() { + when(activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + MEMBER_ID, ACTIVITY_DATE, ActivityStatus.ACTIVITY_COMPLETED)) + .thenReturn(Optional.empty()); + + activityDailySummaryService.refreshCoverAfterActivityChanged(MEMBER_ID, ACTIVITY_DATE, DELETED_COVER_ACTIVITY_ID); + + verify(activityDailySummaryRepository).updateCoverImage( + MEMBER_ID, ACTIVITY_DATE, null, null, DELETED_COVER_ACTIVITY_ID); + } +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index 1be6922..775d65b 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.*; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.EnumSet; @@ -288,4 +289,43 @@ private void completeWithSynchronizationActive(CompleteActivityCommand command) TransactionSynchronizationManager.clearSynchronization(); } } + + @Test + void delete_완료된_활동을_삭제하면_일별_요약_감소와_커버_재계산을_요청한다() { + Long activityId = 30L; + LocalDate activityDate = LocalDate.of(2026, 7, 7); + Activity activity = Activity.builder() + .id(activityId) + .memberId(MEMBER_ID) + .activityStatus(ActivityStatus.ACTIVITY_COMPLETED) + .durationSec(1800) + .activityDateWithTimezone(activityDate) + .build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + stubTransactionTemplateToRunCallback(); + + activityService.delete(activityId, MEMBER_ID); + + InOrder inOrder = inOrder(activityDailySummaryService); + inOrder.verify(activityDailySummaryService).removeActivity(MEMBER_ID, activityDate, 1800); + inOrder.verify(activityDailySummaryService).refreshCoverAfterActivityChanged(MEMBER_ID, activityDate, activityId); + } + + @Test + void delete_진행중인_활동을_삭제하면_일별_요약에_영향을_주지_않는다() { + Long activityId = 31L; + Activity activity = Activity.builder() + .id(activityId) + .memberId(MEMBER_ID) + .activityStatus(ActivityStatus.ACTIVITY_IN_PROGRESS) + .build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + stubTransactionTemplateToRunCallback(); + + activityService.delete(activityId, MEMBER_ID); + + verifyNoInteractions(activityDailySummaryService); + } }