diff --git a/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java index 1c45bed..fe226f8 100644 --- a/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java +++ b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java @@ -9,6 +9,7 @@ import com.ject.studytrip.mission.domain.model.Mission; import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.stamp.application.service.StampCommandService; import com.ject.studytrip.stamp.application.service.StampQueryService; import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.trip.application.service.TripQueryService; @@ -28,6 +29,7 @@ public class MissionFacade { private final StampQueryService stampQueryService; private final MissionQueryService missionQueryService; + private final StampCommandService stampCommandService; private final MissionCommandService missionCommandService; @Caching( @@ -47,6 +49,7 @@ public MissionInfo createMission( Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); Mission mission = missionCommandService.createMission(stamp, request); + stampCommandService.increaseTotalMissions(stamp); return MissionInfo.from(mission); } @@ -91,6 +94,7 @@ public void deleteMission(Long memberId, Long tripId, Long stampId, Long mission Mission mission = missionQueryService.getValidMission(stamp.getId(), missionId); missionCommandService.deleteMission(mission); + stampCommandService.decreaseTotalMissions(stamp); } @Cacheable( diff --git a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionQueryService.java b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionQueryService.java index 7818a6b..4d4dc2d 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionQueryService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionQueryService.java @@ -17,13 +17,16 @@ public class DailyMissionQueryService { public List getValidDailyMissionsByIds( Long dailyGoalId, List dailyMissionIds) { List dailyMissions = dailyMissionRepository.findAllByIdIn(dailyMissionIds); + validateDailyMissions(dailyMissions, dailyMissionIds, dailyGoalId); - DailyMissionPolicy.validateExistAll(dailyMissions, dailyMissionIds); - dailyMissions.forEach( - dailyMission -> { - DailyMissionPolicy.validateBelongsToDailyGoal(dailyMission, dailyGoalId); - DailyMissionPolicy.validateNotDeleted(dailyMission); - }); + return dailyMissions; + } + + public List getValidDailyMissionsWithMissionAndStampByIds( + Long dailyGoalId, List dailyMissionIds) { + List dailyMissions = + dailyMissionQueryRepository.findAllWithMissionAndStampByIds(dailyMissionIds); + validateDailyMissions(dailyMissions, dailyMissionIds, dailyGoalId); return dailyMissions; } @@ -31,4 +34,14 @@ public List getValidDailyMissionsByIds( public List getDailyMissionsByDailyGoal(Long dailyGoalId) { return dailyMissionQueryRepository.findAllByDailyGoalIdFetchJoinMission(dailyGoalId); } + + private void validateDailyMissions( + List dailyMissions, List dailyMissionIds, Long dailyGoalId) { + DailyMissionPolicy.validateExistAll(dailyMissions, dailyMissionIds); + dailyMissions.forEach( + dailyMission -> { + DailyMissionPolicy.validateBelongsToDailyGoal(dailyMission, dailyGoalId); + DailyMissionPolicy.validateNotDeleted(dailyMission); + }); + } } 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 ab7441d..7ae8a8f 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 @@ -6,6 +6,8 @@ public interface DailyMissionQueryRepository { List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId); + List findAllWithMissionAndStampByIds(List ids); + long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedMissionOwner(); 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 cf66ea4..ecc78a9 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 @@ -4,6 +4,7 @@ import com.ject.studytrip.mission.domain.model.QDailyMission; import com.ject.studytrip.mission.domain.model.QMission; 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.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -16,6 +17,7 @@ public class DailyMissionQueryRepositoryAdapter implements DailyMissionQueryRepository { private final JPAQueryFactory queryFactory; private final QDailyMission dailyMission = QDailyMission.dailyMission; + private final QStamp stamp = QStamp.stamp; private final QMission mission = QMission.mission; private final QDailyGoal dailyGoal = QDailyGoal.dailyGoal; @@ -29,6 +31,18 @@ public List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId) .fetch(); } + @Override + public List findAllWithMissionAndStampByIds(List ids) { + return queryFactory + .selectFrom(dailyMission) + .join(dailyMission.mission, mission) + .fetchJoin() + .join(mission.stamp, stamp) + .fetchJoin() + .where(dailyMission.id.in(ids)) + .fetch(); + } + @Override public long deleteAllByDeletedAtIsNotNull() { return queryFactory diff --git a/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java b/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java index 7681f98..a4b3190 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java +++ b/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java @@ -7,6 +7,9 @@ public record StampInfo( Long stampId, String stampName, int stampOrder, + String endDate, + int totalMissions, + int completedMissions, boolean completed, String createdAt, String updatedAt, @@ -16,6 +19,9 @@ public static StampInfo from(Stamp stamp) { stamp.getId(), stamp.getName(), stamp.getStampOrder(), + DateUtil.formatDate(stamp.getEndDate()), + stamp.getTotalMissions(), + stamp.getCompletedMissions(), stamp.isCompleted(), DateUtil.formatDateTime(stamp.getCreatedAt()), DateUtil.formatDateTime(stamp.getUpdatedAt()), diff --git a/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java b/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java index ec76c08..78e8144 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java +++ b/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java @@ -73,7 +73,7 @@ public void updateStamp(Long memberId, Long tripId, Long stampId, UpdateStampReq Trip trip = tripQueryService.getValidTrip(memberId, tripId); Stamp stamp = stampQueryService.getValidStamp(trip.getId(), stampId); - stampCommandService.updateStampName(stamp, request); + stampCommandService.updateStamp(trip, stamp, request); } @Caching( 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 4aa9cf6..618b585 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 @@ -10,6 +10,7 @@ import com.ject.studytrip.stamp.presentation.dto.request.UpdateStampRequest; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; +import java.time.LocalDate; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -23,7 +24,8 @@ public class StampCommandService { private final StampQueryRepository stampQueryRepository; public Stamp createStamp(Trip trip, CreateStampRequest request) { - Stamp newStamp = StampFactory.create(trip, request.name(), request.order()); + Stamp newStamp = + StampFactory.create(trip, request.name(), request.order(), request.endDate()); List existingStamps = stampRepository.findAllByTripIdAndDeletedAtIsNull(trip.getId()); @@ -31,6 +33,7 @@ public Stamp createStamp(Trip trip, CreateStampRequest request) { combinedStamps.add(newStamp); StampPolicy.validateStampOrders(trip.getCategory(), combinedStamps); + StampPolicy.validateEndDate(trip.getEndDate(), newStamp.getEndDate()); return stampRepository.save(newStamp); } @@ -38,7 +41,10 @@ public Stamp createStamp(Trip trip, CreateStampRequest request) { public void createStamps(Trip trip, List requests) { List stamps = requests.stream() - .map(stamp -> StampFactory.create(trip, stamp.name(), stamp.order())) + .map( + stamp -> + StampFactory.create( + trip, stamp.name(), stamp.order(), stamp.endDate())) .toList(); StampPolicy.validateStampOrders(trip.getCategory(), stamps); @@ -46,8 +52,12 @@ public void createStamps(Trip trip, List requests) { stampRepository.saveAll(stamps); } - public void updateStampName(Stamp stamp, UpdateStampRequest request) { + public void updateStamp(Trip trip, Stamp stamp, UpdateStampRequest request) { stamp.updateName(request.name()); + + LocalDate endDate = request.endDate(); + StampPolicy.validateEndDate(trip.getEndDate(), endDate); + stamp.updateEndDate(endDate); } public void updateStampOrders(Trip trip, UpdateStampOrderRequest request) { @@ -129,4 +139,16 @@ private void shiftStampOrdersAfterDeleted(Long tripId, int deletedStampOrder) { stamp.updateStampOrder(stamp.getStampOrder() - 1); } } + + public void increaseTotalMissions(Stamp stamp) { + stamp.increaseTotalMissions(); + } + + public void decreaseTotalMissions(Stamp stamp) { + stamp.decreaseTotalMissions(); + } + + public void increaseCompletedMissions(Stamp stamp, int count) { + stamp.increaseCompletedMissions(count); + } } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java index 563c6ac..08847a1 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java @@ -18,6 +18,9 @@ public enum StampErrorCode implements ErrorCode { STAMP_LIST_CANNOT_BE_EMPTY(HttpStatus.BAD_REQUEST, "스탬프 목록은 비어있을 수 없습니다."), STAMP_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 스탬프입니다."), ALL_STAMPS_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "모든 스탬프가 완료되지 않았습니다."), + STAMP_END_DATE_CANNOT_BE_IN_PAST(HttpStatus.BAD_REQUEST, "스탬프의 종료일은 과거일 수 없습니다."), + STAMP_END_DATE_AFTER_TRIP_END_DATE_NOT_ALLOWED( + HttpStatus.BAD_REQUEST, "스탬프의 종료일은 여행 종료일보다 이후일 수 없습니다."), // 403 STAMP_NOT_BELONG_TO_TRIP(HttpStatus.FORBIDDEN, "해당 스탬프는 요청한 여행에 속하지 않습니다."), diff --git a/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java b/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java index 96e342a..1e8a0b0 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java @@ -2,12 +2,13 @@ import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.trip.domain.model.Trip; +import java.time.LocalDate; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class StampFactory { - public static Stamp create(Trip trip, String name, int stampOrder) { - return Stamp.of(trip, name, stampOrder); + public static Stamp create(Trip trip, String name, int stampOrder, LocalDate endDate) { + return Stamp.of(trip, name, stampOrder, endDate); } } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java index 20cb278..0689014 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java @@ -5,6 +5,7 @@ import com.ject.studytrip.global.common.entity.BaseTimeEntity; import com.ject.studytrip.trip.domain.model.Trip; import jakarta.persistence.*; +import java.time.LocalDate; import java.time.LocalDateTime; import lombok.*; @@ -28,13 +29,22 @@ public class Stamp extends BaseTimeEntity { private int stampOrder; + private LocalDate endDate; + + private int totalMissions; + + private int completedMissions; + private boolean completed; - public static Stamp of(Trip trip, String name, int stampOrder) { + public static Stamp of(Trip trip, String name, int stampOrder, LocalDate endDate) { return Stamp.builder() .trip(trip) .name(name) .stampOrder(stampOrder) + .endDate(endDate) + .totalMissions(0) + .completedMissions(0) .completed(false) .build(); } @@ -47,6 +57,10 @@ public void updateStampOrder(int newOrder) { this.stampOrder = newOrder; } + public void updateEndDate(LocalDate endDate) { + this.endDate = endDate; + } + public void updateCompleted() { this.completed = true; } @@ -54,4 +68,16 @@ public void updateCompleted() { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public void increaseTotalMissions() { + this.totalMissions += 1; + } + + public void decreaseTotalMissions() { + this.totalMissions -= 1; + } + + public void increaseCompletedMissions(int count) { + this.completedMissions += count; + } } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java index fed6111..9abeeba 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java @@ -4,6 +4,7 @@ import com.ject.studytrip.stamp.domain.error.StampErrorCode; import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.trip.domain.model.TripCategory; +import java.time.LocalDate; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -72,4 +73,22 @@ public static void validateAllCompleted(boolean exists) { throw new CustomException(StampErrorCode.ALL_STAMPS_NOT_COMPLETED); } } + + public static void validateEndDate(LocalDate tripEndDate, LocalDate stampEndDate) { + if (stampEndDate == null) return; + + // 스탬프 종료일이 과거일 경우 + LocalDate today = LocalDate.now(); + if (stampEndDate.isBefore(today)) { + throw new CustomException(StampErrorCode.STAMP_END_DATE_CANNOT_BE_IN_PAST); + } + + if (tripEndDate == null) return; + + // 스탬프 종료일이 여행 종료일 이후일 경우 + if (stampEndDate.isAfter(tripEndDate)) { + throw new CustomException( + StampErrorCode.STAMP_END_DATE_AFTER_TRIP_END_DATE_NOT_ALLOWED); + } + } } diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java b/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java index f9435fd..5a3c18f 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java @@ -45,7 +45,15 @@ public ResponseEntity createStamp( HttpStatus.CREATED.value(), CreateStampResponse.of(result))); } - @Operation(summary = "스탬프 수정", description = "특정 스탬프의 이름을 수정합니다.") + @Operation( + summary = "스탬프 수정", + description = + """ + 특정 스탬프의 이름과 종료일을 수정합니다. + + - 이름과 종료일 중 변경하지 않는 필드는 요청 바디에서 생략해도 됩니다. + - 종료일을 '없음'으로 변경하려면 `endDate: null`로 명시적으로 전달해야 합니다. + """) @PatchMapping("/{tripId}/stamps/{stampId}") public ResponseEntity updateStamp( @AuthenticationPrincipal String memberId, diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java index 9482334..c653581 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java @@ -1,10 +1,14 @@ package com.ject.studytrip.stamp.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; +import java.time.LocalDate; public record CreateStampRequest( @Schema(description = "스탬프 이름") @NotEmpty(message = "스탬프 이름은 필수 요청 값입니다.") String name, @Schema(description = "스탬프 순서") @Min(value = 0, message = "스탬프 순서는 최소 0 이상이여야 합니다.") - int order) {} + int order, + @Schema(description = "스탬프 종료일") @FutureOrPresent(message = "스탬프 종료일은 현재 날짜보다 과거일 수 없습니다.") + LocalDate endDate) {} diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/UpdateStampRequest.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/UpdateStampRequest.java index 5ac3fc7..02428e7 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/UpdateStampRequest.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/UpdateStampRequest.java @@ -1,5 +1,11 @@ package com.ject.studytrip.stamp.presentation.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import java.time.LocalDate; -public record UpdateStampRequest(@Schema(description = "수정할 스탬프 이름") String name) {} +public record UpdateStampRequest( + @Schema(description = "수정할 스탬프 이름") String name, + @Schema(description = "수정할 스탬프 종료일") + @FutureOrPresent(message = "스탬프 종료일은 현재 날짜보다 과거일 수 없습니다.") + LocalDate endDate) {} diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java index 57b4869..29476e6 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java @@ -10,6 +10,9 @@ public record LoadStampDetailResponse( @Schema(description = "스탬프 ID") Long stampId, @Schema(description = "스탬프 이름") String stampName, @Schema(description = "스탬프 순서") int stampOrder, + @Schema(description = "스탬프 종료일") String endDate, + @Schema(description = "스탬프에 속한 총 미션 수") int totalMissions, + @Schema(description = "스탬프에 속한 완료된 미션 수") int completedMissions, @Schema(description = "스탬프 완료 여부") boolean completed, @Schema(description = "미션 목록") List missions) { public static LoadStampDetailResponse of(StampInfo stampInfo, List missionInfos) { @@ -17,6 +20,9 @@ public static LoadStampDetailResponse of(StampInfo stampInfo, List stampInfo.stampId(), stampInfo.stampName(), stampInfo.stampOrder(), + stampInfo.endDate(), + stampInfo.totalMissions(), + stampInfo.completedMissions(), stampInfo.completed(), missionInfos.stream().map(LoadMissionInfoResponse::of).toList()); } diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampInfoResponse.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampInfoResponse.java index 8d8eacd..3a425a4 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampInfoResponse.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampInfoResponse.java @@ -7,9 +7,18 @@ public record LoadStampInfoResponse( @Schema(description = "스탬프 ID") Long stampId, @Schema(description = "스탬프 이름") String stampName, @Schema(description = "스탬프 순서") int stampOrder, + @Schema(description = "스탬프 종료일") String endDate, + @Schema(description = "스탬프에 속한 총 미션 개수") int totalMissions, + @Schema(description = "스탬프에 속한 완료된 미션 개수") int completedMissions, @Schema(description = "스탬프 완료 여부") boolean completed) { public static LoadStampInfoResponse of(StampInfo info) { return new LoadStampInfoResponse( - info.stampId(), info.stampName(), info.stampOrder(), info.completed()); + info.stampId(), + info.stampName(), + info.stampOrder(), + info.endDate(), + info.totalMissions(), + info.completedMissions(), + info.completed()); } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java index 25bcd54..3999c24 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java +++ b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java @@ -8,9 +8,12 @@ import com.ject.studytrip.mission.application.service.DailyMissionQueryService; import com.ject.studytrip.mission.application.service.MissionCommandService; import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService; import com.ject.studytrip.pomodoro.application.service.PomodoroQueryService; import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.stamp.application.service.StampCommandService; +import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo; import com.ject.studytrip.studylog.application.dto.StudyLogDetail; import com.ject.studytrip.studylog.application.dto.StudyLogInfo; @@ -46,6 +49,7 @@ public class StudyLogFacade { private final DailyGoalQueryService dailyGoalQueryService; private final PomodoroQueryService pomodoroQueryService; + private final StampCommandService stampCommandService; private final MissionCommandService missionCommandService; private final StudyLogCommandService studyLogCommandService; private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService; @@ -65,7 +69,7 @@ public StudyLogInfo createStudyLog( Trip trip = tripQueryService.getValidTrip(memberId, tripId); DailyGoal dailyGoal = dailyGoalQueryService.getValidDailyGoal(trip.getId(), dailyGoalId); List selectedDailyMissions = - dailyMissionQueryService.getValidDailyMissionsByIds( + dailyMissionQueryService.getValidDailyMissionsWithMissionAndStampByIds( dailyGoal.getId(), request.selectedDailyMissionIds()); Pomodoro pomodoro = pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId); @@ -127,9 +131,36 @@ private void createStudyLogDailyMissionsAndCompleteMissions( studyLogDailyMissionCommandService.createStudyLogDailyMissions( studyLog, selectedDailyMissions); - // 미션 완료 처리 - selectedDailyMissions.forEach( - dailyMission -> missionCommandService.completeMission(dailyMission.getMission())); + List missions = + selectedDailyMissions.stream().map(DailyMission::getMission).toList(); + + // 스탬프 ID를 기준으로 Stamp 집계 + Map stampById = new HashMap<>(); + + // 스탬프 ID를 기준으로 완료된 미션 수 집계 + Map completeMissionCountByStampId = new HashMap<>(); + + missions.forEach( + mission -> { + Stamp stamp = mission.getStamp(); + stampById.putIfAbsent(stamp.getId(), stamp); + + // 미션 완료 처리 + missionCommandService.completeMission(mission); + + // 스탬프별 완료한 미션 개수 누적(없으면 1, 있으면 +1) + completeMissionCountByStampId.merge(stamp.getId(), 1, Integer::sum); + }); + + // 스탬프별 완료된 미션 수 증가 + completeMissionCountByStampId.forEach( + (stampId, completeMissions) -> { + Stamp stamp = stampById.get(stampId); + stampCommandService.increaseCompletedMissions(stamp, completeMissions); + }); + + // NOTE: 현재는 데이터/트래픽이 적어 단건씩 처리 + 증분 갱신 방법 사용 + // 동시성/규모가 커지면 벌크 완료 + 스탬프의 완료된 미션 수 재계산으로 리팩토링도 가능할 것 같음 } private StudyLogSliceInfo buildStudyLogDetailsSlice(Slice studyLogSlice) { diff --git a/src/main/resources/db/migration/V4__add_columns_to_stamp.sql b/src/main/resources/db/migration/V4__add_columns_to_stamp.sql new file mode 100644 index 0000000..51b14a2 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_columns_to_stamp.sql @@ -0,0 +1,5 @@ +-- stamp 테이블 : end_date, total_missions, completed_missions 필드 추가 +ALTER TABLE stamp + ADD COLUMN end_date DATETIME(6) NULL, + ADD COLUMN total_missions INT NOT NULL DEFAULT 0, + ADD COLUMN completed_missions INT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.java index 8282fa3..af7b439 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.java @@ -123,6 +123,87 @@ void shouldGetDailyMissionsByIds() { } } + @Nested + @DisplayName("getValidDailyMissionsWithMissionAndStampByIds 메서드는") + class GetValidDailyMissionsWithMissionAndStampByIds { + + @Test + @DisplayName("요청한 ID 개수와 조회된 데일리 미션 개수가 다르면 예외가 발생한다") + void shouldThrowExceptionWhenSomeDailyMissionsDoNotExist() { + // given + List ids = List.of(1L, 2L); + given(dailyMissionQueryRepository.findAllWithMissionAndStampByIds(ids)) + .willReturn(List.of(dailyMission)); + + // when & then + assertThatThrownBy( + () -> + dailyMissionQueryService + .getValidDailyMissionsWithMissionAndStampByIds( + dailyGoal.getId(), ids)) + .isInstanceOf(CustomException.class) + .hasMessage(DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("데일리 미션이 요청한 데일리 목표에 속하지 않으면 예외가 발생한다") + void shouldThrowExceptionWhenDailyMissionDoesNotBelongToDailyGoal() { + // given + DailyGoal otherGoal = DailyGoalFixture.createDailyGoalWithId(999L, dailyGoal.getTrip()); + List ids = List.of(dailyMission.getId()); + given(dailyMissionQueryRepository.findAllWithMissionAndStampByIds(ids)) + .willReturn(List.of(dailyMission)); + + // when & then + assertThatThrownBy( + () -> + dailyMissionQueryService + .getValidDailyMissionsWithMissionAndStampByIds( + otherGoal.getId(), ids)) + .isInstanceOf(CustomException.class) + .hasMessage( + DailyMissionErrorCode.DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL + .getMessage()); + } + + @Test + @DisplayName("데일리 미션이 이미 삭제된 경우 예외가 발생한다") + void shouldThrowExceptionWhenDailyMissionIsDeleted() { + // given + dailyMission.updateDeletedAt(); // deleted + List ids = List.of(dailyMission.getId()); + given(dailyMissionQueryRepository.findAllWithMissionAndStampByIds(ids)) + .willReturn(List.of(dailyMission)); + + // when & then + Assertions.assertThatThrownBy( + () -> + dailyMissionQueryService + .getValidDailyMissionsWithMissionAndStampByIds( + dailyGoal.getId(), ids)) + .isInstanceOf(CustomException.class) + .hasMessage(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("ID 리스트로 유효한 데일리 미션을 미션과 스탬프와 함께 조회해 반환한다") + void shouldGetDailyMissionsWithMissionAndStampByIds() { + // given + List ids = List.of(dailyMission.getId()); + List dailyMissions = List.of(dailyMission); + given(dailyMissionQueryRepository.findAllWithMissionAndStampByIds(ids)) + .willReturn(dailyMissions); + + // when + List result = + dailyMissionQueryService.getValidDailyMissionsWithMissionAndStampByIds( + dailyGoal.getId(), ids); + + // then + assertThat(result.isEmpty()).isFalse(); + } + } + @Nested @DisplayName("getDailyMissionsByDailyGoal 메서드는") class GetDailyMissionsByDailyGoal { 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 e3bd10d..5c49c3d 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 @@ -103,12 +103,38 @@ void shouldThrowExceptionWhenDuplicateOrderForCourseTrip() { .hasMessage(StampErrorCode.DUPLICATE_STAMP_ORDER_FOR_COURSE_TRIP.getMessage()); } + @Test + @DisplayName("스탬프 종료일이 과거 날짜라면 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsInPast() { + // given + CreateStampRequest request = fixture.withEndDateInPast().build(); + + // when & then + assertThatThrownBy(() -> stampCommandService.createStamp(courseTrip, request)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_END_DATE_CANNOT_BE_IN_PAST.getMessage()); + } + + @Test + @DisplayName("스탬프 종료일이 여행 종료일보다 이후라면 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsAfterTripEndDate() { + // given + CreateStampRequest request = fixture.withEndDateAfterTripEndDate().build(); + + // when & then + assertThatThrownBy(() -> stampCommandService.createStamp(courseTrip, request)) + .isInstanceOf(CustomException.class) + .hasMessage( + StampErrorCode.STAMP_END_DATE_AFTER_TRIP_END_DATE_NOT_ALLOWED + .getMessage()); + } + @Test @DisplayName("유효한 요청으로 스탬프를 생성하면 스탬프가 저장되고 반환된다") void shouldCreateValidStamp() { // given CreateStampRequest request = fixture.build(); - Stamp saved = Stamp.of(courseTrip, request.name(), request.order()); + Stamp saved = Stamp.of(courseTrip, request.name(), request.order(), request.endDate()); given(stampRepository.save(any())).willReturn(saved); // when @@ -192,26 +218,71 @@ void shouldCreateStampsForExploreTrip() { } @Nested - @DisplayName("updateStampName 메서드는") - class UpdateStampName { + @DisplayName("updateStamp 메서드는") + class UpdateStamp { private final UpdateStampRequestFixture fixture = new UpdateStampRequestFixture(); @Test @DisplayName("유효한 정보로 스탬프의 이름을 수정하면 스탬프가 업데이트된다") - void shouldUpdateStampNameOrDeadline() { + void shouldUpdateStampName() { // given UpdateStampRequest request = fixture.buildUpdateName(); // when - stampCommandService.updateStampName(courseStamp1, request); + stampCommandService.updateStamp(courseTrip, courseStamp1, request); // then assertThat(courseStamp1.getName()).isEqualTo(request.name()); } + + @Test + @DisplayName("유효한 정보로 스탬프의 종료일을 수정하면 스탬프가 업데이트된다") + void shouldUpdateStampEndDate() { + // given + UpdateStampRequest request = fixture.buildUpdateEndDate(); + + // when + stampCommandService.updateStamp(courseTrip, courseStamp1, request); + + // then + assertThat(courseStamp1.getEndDate()).isEqualTo(request.endDate()); + } + + @Test + @DisplayName("과거 날짜로 스탬프의 종료일을 수정하면 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsInPast() { + // given + UpdateStampRequest request = fixture.withEndDateInPast().buildUpdateEndDate(); + + // when & then + assertThatThrownBy( + () -> + stampCommandService.updateStamp( + courseTrip, courseStamp1, request)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_END_DATE_CANNOT_BE_IN_PAST.getMessage()); + } + + @Test + @DisplayName("여행 종료일보다 이후 날짜로 스탬프의 종료일을 수정하면 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsAfterTripEndDate() { + // given + UpdateStampRequest request = fixture.withEndDateAfterTripEndDate().buildUpdateEndDate(); + + // when & then + assertThatThrownBy( + () -> + stampCommandService.updateStamp( + courseTrip, courseStamp1, request)) + .isInstanceOf(CustomException.class) + .hasMessage( + StampErrorCode.STAMP_END_DATE_AFTER_TRIP_END_DATE_NOT_ALLOWED + .getMessage()); + } } @Nested - @DisplayName("updateStamp 메서드는") + @DisplayName("updateStampOrders 메서드는") class UpdateStampOrders { private final UpdateStampOrderRequestFixture fixture = new UpdateStampOrderRequestFixture(); @@ -487,4 +558,62 @@ void shouldReturnCountWhenStampsOwnedByDeletedTripExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("increaseTotalMissions 메서드는") + class IncreaseTotalMissions { + + @Test + @DisplayName("스탬프의 총 미션 수를 1 증가시킨다") + void shouldIncreaseTotalMissions() { + // given + int initialTotalMissions = courseStamp1.getTotalMissions(); + + // when + stampCommandService.increaseTotalMissions(courseStamp1); + + // then + assertThat(courseStamp1.getTotalMissions()).isEqualTo(initialTotalMissions + 1); + } + } + + @Nested + @DisplayName("decreaseTotalMissions 메서드는") + class DecreaseTotalMissions { + + @Test + @DisplayName("스탬프의 총 미션 수를 1 감소시킨다") + void shouldDecreaseTotalMissions() { + // given + courseStamp1.increaseTotalMissions(); + courseStamp1.increaseTotalMissions(); + int initialTotalMissions = courseStamp1.getTotalMissions(); + + // when + stampCommandService.decreaseTotalMissions(courseStamp1); + + // then + assertThat(courseStamp1.getTotalMissions()).isEqualTo(initialTotalMissions - 1); + } + } + + @Nested + @DisplayName("increaseCompletedMissions 메서드는") + class IncreaseCompletedMissions { + + @Test + @DisplayName("스탬프의 완료된 미션 수를 지정된 개수만큼 증가시킨다") + void shouldIncreaseCompletedMissions() { + // given + int initialCompletedMissions = courseStamp1.getCompletedMissions(); + int increaseCount = 3; + + // when + stampCommandService.increaseCompletedMissions(courseStamp1, increaseCount); + + // then + assertThat(courseStamp1.getCompletedMissions()) + .isEqualTo(initialCompletedMissions + increaseCount); + } + } } diff --git a/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java b/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java index 31fac5a..12102e4 100644 --- a/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java +++ b/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java @@ -1,11 +1,13 @@ package com.ject.studytrip.stamp.fixture; import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import java.time.LocalDate; public class CreateStampRequestFixture { private String name = "TEST STAMP"; private int stampOrder = 1; + private LocalDate endDate = LocalDate.now().plusDays(7); public CreateStampRequestFixture withName(String name) { this.name = name; @@ -17,7 +19,17 @@ public CreateStampRequestFixture withStampOrder(int stampOrder) { return this; } + public CreateStampRequestFixture withEndDateInPast() { + this.endDate = LocalDate.now().minusDays(1); + return this; + } + + public CreateStampRequestFixture withEndDateAfterTripEndDate() { + this.endDate = LocalDate.now().plusDays(100); + return this; + } + public CreateStampRequest build() { - return new CreateStampRequest(name, stampOrder); + return new CreateStampRequest(name, stampOrder, endDate); } } diff --git a/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java b/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java index 58737a2..c0dbd0d 100644 --- a/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java +++ b/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java @@ -3,17 +3,19 @@ import com.ject.studytrip.stamp.domain.factory.StampFactory; import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.trip.domain.model.Trip; +import java.time.LocalDate; import org.springframework.test.util.ReflectionTestUtils; public class StampFixture { private static final String STAMP_NAME = "TEST STAMP NAME"; + private static final LocalDate DEFAULT_END_DATE = LocalDate.now().plusDays(7); public static Stamp createStamp(Trip trip, int order) { - return StampFactory.create(trip, STAMP_NAME, order); + return StampFactory.create(trip, STAMP_NAME, order, DEFAULT_END_DATE); } public static Stamp createStampWithId(Long id, Trip trip, int order) { - Stamp stamp = StampFactory.create(trip, STAMP_NAME, order); + Stamp stamp = StampFactory.create(trip, STAMP_NAME, order, DEFAULT_END_DATE); ReflectionTestUtils.setField(stamp, "id", id); return stamp; diff --git a/src/test/java/com/ject/studytrip/stamp/fixture/UpdateStampRequestFixture.java b/src/test/java/com/ject/studytrip/stamp/fixture/UpdateStampRequestFixture.java index 9dddfa1..36a20e0 100644 --- a/src/test/java/com/ject/studytrip/stamp/fixture/UpdateStampRequestFixture.java +++ b/src/test/java/com/ject/studytrip/stamp/fixture/UpdateStampRequestFixture.java @@ -1,16 +1,32 @@ package com.ject.studytrip.stamp.fixture; import com.ject.studytrip.stamp.presentation.dto.request.UpdateStampRequest; +import java.time.LocalDate; public class UpdateStampRequestFixture { private String name = "TEST STAMP"; + private LocalDate endDate = LocalDate.now().plusDays(7); public UpdateStampRequestFixture withName(String name) { this.name = name; return this; } + public UpdateStampRequestFixture withEndDateInPast() { + this.endDate = LocalDate.now().minusDays(1); + return this; + } + + public UpdateStampRequestFixture withEndDateAfterTripEndDate() { + this.endDate = LocalDate.now().plusDays(100); + return this; + } + public UpdateStampRequest buildUpdateName() { - return new UpdateStampRequest(name); + return new UpdateStampRequest(name, null); + } + + public UpdateStampRequest buildUpdateEndDate() { + return new UpdateStampRequest(null, endDate); } } diff --git a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java index 305cef1..6a0dc95 100644 --- a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java @@ -1,5 +1,6 @@ package com.ject.studytrip.studylog.presentation.controller; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -119,6 +120,7 @@ void shouldCreateStudyLog() throws Exception { // given CreateStudyLogRequest request = fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId())).build(); + int initialCompletedMissions = stamp.getCompletedMissions(); // when ResultActions resultActions = @@ -129,6 +131,36 @@ void shouldCreateStudyLog() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.studyLogId").isNumber()); + + // 스탬프의 완료된 미션 수가 증가했는지 확인 + assertThat(stamp.getCompletedMissions()).isEqualTo(initialCompletedMissions + 1); + } + + @Test + @DisplayName("여러 미션을 선택한 경우 스탬프의 완료된 미션 수가 올바르게 증가한다") + void shouldUpdateCompletedMissionsWhenMultipleMissionsSelected() throws Exception { + // given + Mission mission2 = missionTestHelper.saveMission(stamp); + DailyMission dailyMission2 = + dailyMissionTestHelper.saveDailyMission(mission2, dailyGoal); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds( + List.of(dailyMission.getId(), dailyMission2.getId())) + .build(); + int initialCompletedMissions = stamp.getCompletedMissions(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogId").isNumber()); + + // 스탬프의 완료된 미션 수가 2개 증가했는지 확인 + assertThat(stamp.getCompletedMissions()).isEqualTo(initialCompletedMissions + 2); } @Test