From 3f0dc92ca6c5c235a169608bb5e93f94d64af188 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Wed, 24 Sep 2025 14:36:32 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=9D=BC=EC=9E=90=EB=B3=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/controller/StudyPlanController.java | 20 ++ .../study/plan/dto/StudyPlanListResponse.java | 19 ++ .../StudyPlanExceptionRepository.java | 30 +++ .../plan/repository/StudyPlanRepository.java | 6 +- .../study/plan/service/StudyPlanService.java | 183 +++++++++++++++++- 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java create mode 100644 src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index 1745ccd0..957cefa1 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -1,14 +1,19 @@ package com.back.domain.study.plan.controller; import com.back.domain.study.plan.dto.StudyPlanCreateRequest; +import com.back.domain.study.plan.dto.StudyPlanListResponse; import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.service.StudyPlanService; import com.back.global.common.dto.RsData; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/plans") @@ -25,6 +30,21 @@ public ResponseEntity> createStudyPlan( return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 생성되었습니다.", response)); } + // 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD + @GetMapping("/date/{date}") + public ResponseEntity> getStudyPlansForDate( + // @AuthenticationPrincipal CustomUserDetails user, + @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + // 유저 아이디 추출. 지금은 임시값 적용 + // Long userId = user.getId(); + Long userId = 1L; // 임시로 userId를 1로 설정 + + List plans = studyPlanService.getStudyPlansForDate(userId, date); + StudyPlanListResponse response = new StudyPlanListResponse(date, plans, plans.size()); + + return ResponseEntity.ok(RsData.success("해당 날짜의 계획을 조회했습니다.", response)); + } + @DeleteMapping("/{planId}") public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { //studyPlanService.deleteStudyPlan(planId); diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java new file mode 100644 index 00000000..cb7de60c --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanListResponse.java @@ -0,0 +1,19 @@ +package com.back.domain.study.plan.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StudyPlanListResponse { + private LocalDate date; + private List plans; + private int totalCount; +} diff --git a/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java new file mode 100644 index 00000000..2a1afdcc --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java @@ -0,0 +1,30 @@ +package com.back.domain.study.plan.repository; + +import com.back.domain.study.plan.entity.StudyPlanException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface StudyPlanExceptionRepository extends JpaRepository { + // FROM_THIS_DATE 범위의 예외들 중 특정 날짜 이전의 예외 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND spe.applyScope = :applyScope " + + "AND spe.exceptionDate <= :targetDate " + + "ORDER BY spe.exceptionDate DESC") + List findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( + @Param("planId") Long planId, + @Param("applyScope") StudyPlanException.ApplyScope applyScope, + @Param("targetDate") LocalDateTime targetDate); + //특정 계획의 특정 기간 예외 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND spe.exceptionDate BETWEEN :startDate AND :endDate " + + "ORDER BY spe.exceptionDate") + List findByStudyPlanIdAndExceptionDateBetween(@Param("planId") Long planId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); +} diff --git a/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java b/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java index 53c35c4c..9c04caa9 100644 --- a/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java @@ -4,7 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -@Repository -public interface StudyPlanRepository extends JpaRepository { +import java.util.List; +@Repository +public interface StudyPlanRepository extends JpaRepository { + List findByUserId(Long userId); } diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 306bf9d8..dce74c54 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -4,23 +4,39 @@ import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.RepeatRule; import com.back.domain.study.plan.entity.StudyPlan; +import com.back.domain.study.plan.entity.StudyPlanException; +import com.back.domain.study.plan.repository.StudyPlanExceptionRepository; import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class StudyPlanService{ private final StudyPlanRepository studyPlanRepository; + private final StudyPlanExceptionRepository studyPlanExceptionRepository; + //생성 @Transactional public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest request) { - + /*User user = UserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + */ StudyPlan studyPlan = new StudyPlan(); + //studyPlan.setUser(user); studyPlan.setSubject(request.getSubject()); studyPlan.setStartDate(request.getStartDate()); @@ -58,6 +74,171 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest req return rs; } + //특정 날짜 계획 조회 + public List getStudyPlansForDate(Long userId, LocalDate date) { + //원복 계획들 + 단발성 계획 조회 + List userPlans = studyPlanRepository.findByUserId(userId); + List result = new ArrayList<>(); + + for (StudyPlan plan : userPlans) { + // 단발성 계획인 경우 + if (plan.getRepeatRule() == null) { + //타겟 날짜와 시작 날짜가 같으면 추가 + if (plan.getStartDate().toLocalDate().isEqual(date)) { + result.add(new StudyPlanResponse(plan)); + } + } else { + // 반복성 계획인 경우 - 가상 계획 생성 + StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, date); + if (virtualPlan != null) { + result.add(virtualPlan); + } + } + } + + return result; + } + + // 반복 계획을 위한 가상 계획 생성 + private StudyPlanResponse createVirtualPlanForDate(StudyPlan originalPlan, LocalDate targetDate) { + RepeatRule repeatRule = originalPlan.getRepeatRule(); + LocalDate planStartDate = originalPlan.getStartDate().toLocalDate(); + + // 대상 날짜가 계획 시작일 이전이면 null 반환 + if (targetDate.isBefore(planStartDate)) { + return null; + } + + // untilDate 확인. 방어적 검증을 위해 null 체크 한번 더 + if (repeatRule.getUntilDate() != null && + targetDate.isAfter(repeatRule.getUntilDate().toLocalDate())) { + return null; + } + + // 반복 패턴 확인 후 타겟 날짜가 해당되는지 확인 + if (!shouldRepeatOnDate(originalPlan, targetDate)) { + return null; + } + + // 해당 날짜 계획의 예외 확인 + StudyPlanException exception = getEffectiveException(originalPlan.getId(), targetDate); + if (exception != null) { + // 삭제 타입은 null 반환 + if (exception.getExceptionType() == StudyPlanException.ExceptionType.DELETED) { + return null; + } + // 수정된 경우 수정된 내용으로 반환 + return createModifiedVirtualPlan(originalPlan, exception, targetDate); + } + + //예외 사항 없으면 기본 가상 계획 생성 + return createBasicVirtualPlan(originalPlan, targetDate); + } + + //해당 날짜에 반복이 되는지 확인 + private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) { + RepeatRule repeatRule = originalPlan.getRepeatRule(); + LocalDate startDate = originalPlan.getStartDate().toLocalDate(); + + switch (repeatRule.getFrequency()) { + case DAILY: + long daysBetween = ChronoUnit.DAYS.between(startDate, targetDate); + return daysBetween % repeatRule.getRepeatInterval() == 0; + + case WEEKLY: + // 요일 확인 + if (repeatRule.getByDay() != null && !repeatRule.getByDay().isEmpty()) { + String targetDayOfWeek = targetDate.getDayOfWeek().name().substring(0, 3); + if (!repeatRule.getByDay().contains(targetDayOfWeek)) { + return false; + } + } + long weeksBetween = ChronoUnit.WEEKS.between(startDate, targetDate); + return weeksBetween % repeatRule.getRepeatInterval() == 0; + + case MONTHLY: + long monthsBetween = ChronoUnit.MONTHS.between(startDate, targetDate); + return monthsBetween % repeatRule.getRepeatInterval() == 0 && + startDate.getDayOfMonth() == targetDate.getDayOfMonth(); + + default: + return false; + } + } + + //타켓 날짜에 적용될 예외 정보 가져오기 + private StudyPlanException getEffectiveException(Long planId, LocalDate targetDate) { + List exceptions = studyPlanExceptionRepository + .findByStudyPlanIdAndExceptionDateBetween( + planId, + targetDate.atStartOfDay(), + targetDate.atTime(23, 59, 59) + ); + + StudyPlan plan = studyPlanRepository.findById(planId).orElse(null); + + // 해당 날짜에 직접적인 예외가 있는지 확인 + for (StudyPlanException exception : exceptions) { + if (plan.getStartDate().toLocalDate().isEqual(targetDate)) { + return exception; + } + } + + // FROM_THIS_DATE 범위의 예외가 있는지 확인 + List scopeExceptions = studyPlanExceptionRepository + .findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( + planId, + StudyPlanException.ApplyScope.FROM_THIS_DATE, + targetDate.atTime(23, 59, 59) + ); + + return scopeExceptions.stream() + .max(Comparator.comparing(StudyPlanException::getExceptionDate)) + .orElse(null); + } + + private StudyPlanResponse createModifiedVirtualPlan(StudyPlan originalPlan, StudyPlanException exception, LocalDate targetDate) { + StudyPlanResponse response = createBasicVirtualPlan(originalPlan, targetDate); + + // 수정된 내용 적용 + if (exception.getModifiedSubject() != null) { + response.setSubject(exception.getModifiedSubject()); + } + if (exception.getModifiedStartDate() != null) { + response.setStartDate(adjustTimeForDate(exception.getModifiedStartDate(), targetDate)); + } + if (exception.getModifiedEndDate() != null) { + response.setEndDate(adjustTimeForDate(exception.getModifiedEndDate(), targetDate)); + } + if (exception.getModifiedColor() != null) { + response.setColor(exception.getModifiedColor()); + } + + return response; + } + + private StudyPlanResponse createBasicVirtualPlan(StudyPlan originalPlan, LocalDate targetDate) { + StudyPlanResponse response = new StudyPlanResponse(); + response.setId(originalPlan.getId()); + response.setSubject(originalPlan.getSubject()); + response.setColor(originalPlan.getColor()); + + // 시간은 유지하되 날짜만 변경 + response.setStartDate(adjustTimeForDate(originalPlan.getStartDate(), targetDate)); + response.setEndDate(adjustTimeForDate(originalPlan.getEndDate(), targetDate)); + + if (originalPlan.getRepeatRule() != null) { + response.setRepeatRule(new StudyPlanResponse.RepeatRuleResponse(originalPlan.getRepeatRule())); + } + + return response; + } + //시간은 유지, 날짜만 변경하는 메서드 + private LocalDateTime adjustTimeForDate(LocalDateTime originalDateTime, LocalDate targetDate) { + return LocalDateTime.of(targetDate, originalDateTime.toLocalTime()); + } + + } From bc9fbac6c1543b29159cb0f5ae354e28f2ebd315 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Wed, 24 Sep 2025 15:50:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B0=84=EB=B3=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/controller/StudyPlanController.java | 27 ++++++++++++++-- .../study/plan/service/StudyPlanService.java | 31 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index 957cefa1..2c2988e0 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -13,13 +13,15 @@ import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @RestController @RequiredArgsConstructor @RequestMapping("/api/plans") public class StudyPlanController { private final StudyPlanService studyPlanService; - + // ==================== 생성 =================== @PostMapping public ResponseEntity> createStudyPlan( // 로그인 유저 정보 받기 @AuthenticationPrincipal CustomUserDetails user, @@ -30,6 +32,7 @@ public ResponseEntity> createStudyPlan( return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 생성되었습니다.", response)); } + // ==================== 조회 =================== // 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD @GetMapping("/date/{date}") public ResponseEntity> getStudyPlansForDate( @@ -45,6 +48,27 @@ public ResponseEntity> getStudyPlansForDate( return ResponseEntity.ok(RsData.success("해당 날짜의 계획을 조회했습니다.", response)); } + // 기간별 계획 조회 + @GetMapping + public ResponseEntity>> getStudyPlansForPeriod( + // @AuthenticationPrincipal CustomUserDetails user, + @RequestParam("start") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam("end") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + // Long userId = user.getId(); + Long userId = 1L; // 임시 + + List plans = studyPlanService.getStudyPlansForPeriod(userId, startDate, endDate); + + return ResponseEntity.ok(RsData.success("기간별 계획을 조회했습니다.", plans)); + } + + + + // ==================== 수정 =================== + + + + // ==================== 삭제 =================== @DeleteMapping("/{planId}") public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { //studyPlanService.deleteStudyPlan(planId); @@ -53,5 +77,4 @@ public ResponseEntity> deleteStudyPlan(@PathVariable Long planId) { - } diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index dce74c54..7395585d 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -99,6 +100,36 @@ public List getStudyPlansForDate(Long userId, LocalDate date) return result; } + // 기간별 계획 조회 + public List getStudyPlansForPeriod(Long userId, LocalDate startDate, LocalDate endDate) { + List userPlans = studyPlanRepository.findByUserId(userId); + List result = new ArrayList<>(); + + LocalDate currentDate = startDate; + // 날짜 범위 내에서 반복 + while (!currentDate.isAfter(endDate)) { + for (StudyPlan plan : userPlans) { + if (plan.getRepeatRule() == null) { + // 단발성 계획은 그대로 추가 + if (plan.getStartDate().toLocalDate().isEqual(currentDate)) { + result.add(new StudyPlanResponse(plan)); + } + } else { + // 반복성 계획은 가상 계획화 후 추가 + StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, currentDate); + if (virtualPlan != null) { + result.add(virtualPlan); + } + } + } + currentDate = currentDate.plusDays(1); + } + + return result.stream() + .sorted(Comparator.comparing(StudyPlanResponse::getStartDate)) + .collect(Collectors.toList()); + } + // 반복 계획을 위한 가상 계획 생성 private StudyPlanResponse createVirtualPlanForDate(StudyPlan originalPlan, LocalDate targetDate) { RepeatRule repeatRule = originalPlan.getRepeatRule(); From 447228b542e8b4130d74a6376cbfbfc8527db470 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Wed, 24 Sep 2025 15:51:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B0=84=EB=B3=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=A1=B0=ED=9A=8C=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back/domain/study/plan/controller/StudyPlanController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index 2c2988e0..a57a43a3 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -48,7 +48,7 @@ public ResponseEntity> getStudyPlansForDate( return ResponseEntity.ok(RsData.success("해당 날짜의 계획을 조회했습니다.", response)); } - // 기간별 계획 조회 + // 기간별 계획 조회. start, end 형식: YYYY-MM-DD @GetMapping public ResponseEntity>> getStudyPlansForPeriod( // @AuthenticationPrincipal CustomUserDetails user, From 212bf2c18df4b5bd9f0be37712b7aa3210f64fca Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Thu, 25 Sep 2025 01:45:10 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=8B=A8=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=9D=B4=ED=9B=84=EB=8F=84=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan/controller/StudyPlanController.java | 25 ++- ...eateRequest.java => StudyPlanRequest.java} | 2 +- .../study/plan/dto/StudyPlanResponse.java | 3 - .../StudyPlanExceptionRepository.java | 8 +- .../study/plan/service/StudyPlanService.java | 167 +++++++++++++++++- 5 files changed, 189 insertions(+), 16 deletions(-) rename src/main/java/com/back/domain/study/plan/dto/{StudyPlanCreateRequest.java => StudyPlanRequest.java} (96%) diff --git a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java index a57a43a3..638d9e56 100644 --- a/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java +++ b/src/main/java/com/back/domain/study/plan/controller/StudyPlanController.java @@ -1,10 +1,12 @@ package com.back.domain.study.plan.controller; -import com.back.domain.study.plan.dto.StudyPlanCreateRequest; +import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanListResponse; import com.back.domain.study.plan.dto.StudyPlanResponse; +import com.back.domain.study.plan.entity.StudyPlanException; import com.back.domain.study.plan.service.StudyPlanService; import com.back.global.common.dto.RsData; +import com.back.global.security.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; @@ -13,8 +15,6 @@ import java.time.LocalDate; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @RestController @RequiredArgsConstructor @@ -25,7 +25,7 @@ public class StudyPlanController { @PostMapping public ResponseEntity> createStudyPlan( // 로그인 유저 정보 받기 @AuthenticationPrincipal CustomUserDetails user, - @RequestBody StudyPlanCreateRequest request) { + @RequestBody StudyPlanRequest request) { //커스텀 디테일 구현 시 사용 int userId = user.getId(); Long userId = 1L; // 임시로 userId를 1로 설정 StudyPlanResponse response = studyPlanService.createStudyPlan(userId, request); @@ -36,7 +36,7 @@ public ResponseEntity> createStudyPlan( // 특정 날짜의 계획들 조회. date 형식: YYYY-MM-DD @GetMapping("/date/{date}") public ResponseEntity> getStudyPlansForDate( - // @AuthenticationPrincipal CustomUserDetails user, + @AuthenticationPrincipal CustomUserDetails user, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { // 유저 아이디 추출. 지금은 임시값 적용 // Long userId = user.getId(); @@ -65,6 +65,21 @@ public ResponseEntity>> getStudyPlansForPeriod( // ==================== 수정 =================== + // 플랜 아이디는 원본의 아이디를 받음 + // 가상인지 원본인지는 서비스에서 원본과 날짜를 대조해 판단 + // 수정 적용 범위를 쿼리 파라미터로 받음 (THIS_ONLY, FROM_THIS_DATE) + @PutMapping("/{planId}") + public ResponseEntity> updateStudyPlan( + // @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long planId, + @RequestBody StudyPlanRequest request, + @RequestParam(required = false, defaultValue = "THIS_ONLY") StudyPlanException.ApplyScope applyScope) { + // Long userId = user.getId(); + Long userId = 1L; // 임시 + + StudyPlanResponse response = studyPlanService.updateStudyPlan(userId, planId, request, applyScope); + return ResponseEntity.ok(RsData.success("학습 계획이 성공적으로 수정되었습니다.", response)); + } diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java similarity index 96% rename from src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java rename to src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java index 5b5bfbcd..f0c8b8cd 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanCreateRequest.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanRequest.java @@ -14,7 +14,7 @@ @Setter @NoArgsConstructor @AllArgsConstructor -public class StudyPlanCreateRequest { +public class StudyPlanRequest { private String subject; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java index 1f204e29..0c5e29a0 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java @@ -31,9 +31,6 @@ public class StudyPlanResponse { private Color color; - private Long parentPlanId; - private List childPlans; - // RepeatRule 정보 private RepeatRuleResponse repeatRule; diff --git a/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java index 2a1afdcc..1a634b79 100644 --- a/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java +++ b/src/main/java/com/back/domain/study/plan/repository/StudyPlanExceptionRepository.java @@ -8,6 +8,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository public interface StudyPlanExceptionRepository extends JpaRepository { @@ -20,11 +21,16 @@ List findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( @Param("planId") Long planId, @Param("applyScope") StudyPlanException.ApplyScope applyScope, @Param("targetDate") LocalDateTime targetDate); - //특정 계획의 특정 기간 예외 조회 +// 특정 계획의 특정 기간 동안(start~end)의 예외를 조회 @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + "AND spe.exceptionDate BETWEEN :startDate AND :endDate " + "ORDER BY spe.exceptionDate") List findByStudyPlanIdAndExceptionDateBetween(@Param("planId") Long planId, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + // 특정 계획의 특정 날짜 예외 조회 + @Query("SELECT spe FROM StudyPlanException spe WHERE spe.studyPlan.id = :planId " + + "AND DATE(spe.exceptionDate) = DATE(:targetDate)") + Optional findByPlanIdAndDate(@Param("planId") Long planId, + @Param("targetDate") LocalDateTime targetDate); } diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 7395585d..fbe345ba 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -1,14 +1,12 @@ package com.back.domain.study.plan.service; -import com.back.domain.study.plan.dto.StudyPlanCreateRequest; +import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.RepeatRule; import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.study.plan.entity.StudyPlanException; import com.back.domain.study.plan.repository.StudyPlanExceptionRepository; import com.back.domain.study.plan.repository.StudyPlanRepository; -import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -21,6 +19,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -30,9 +29,9 @@ public class StudyPlanService{ private final StudyPlanRepository studyPlanRepository; private final StudyPlanExceptionRepository studyPlanExceptionRepository; - //생성 + // ==================== 생성 =================== @Transactional - public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest request) { + public StudyPlanResponse createStudyPlan(Long userId, StudyPlanRequest request) { /*User user = UserRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); */ @@ -46,7 +45,7 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanCreateRequest req // 반복 규칙 설정 if (request.getRepeatRule() != null) { - StudyPlanCreateRequest.RepeatRuleRequest repeatRuleRequest = request.getRepeatRule(); + StudyPlanRequest.RepeatRuleRequest repeatRuleRequest = request.getRepeatRule(); RepeatRule repeatRule = new RepeatRule(); repeatRule.setStudyPlan(studyPlan); repeatRule.setFrequency(repeatRuleRequest.getFrequency()); @@ -269,6 +268,162 @@ private LocalDateTime adjustTimeForDate(LocalDateTime originalDateTime, LocalDat return LocalDateTime.of(targetDate, originalDateTime.toLocalTime()); } + // ==================== 수정 =================== + private enum UpdateType { + ORIGINAL_PLAN_UPDATE, // 원본 계획 수정 + REPEAT_INSTANCE_CREATE, // 새로운 예외 생성 + REPEAT_INSTANCE_UPDATE // 기존 예외 수정 + } + @Transactional + public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + StudyPlan originalPlan = studyPlanRepository.findById(planId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + validateUserAccess(originalPlan, userId); + + // 1. 단발성 계획인 경우 applyScope 무시하고 원본 수정 + if (originalPlan.getRepeatRule() == null) { + return updateOriginalPlan(originalPlan, request); + } + + // 2. 반복 계획인 경우 - 원본 계획과 요청 데이터 비교하여 수정 타입 판단 + UpdateType updateType = determineUpdateType(originalPlan, request); + + switch (updateType) { + case ORIGINAL_PLAN_UPDATE: + return updateOriginalPlan(originalPlan, request); + + case REPEAT_INSTANCE_CREATE: + return createRepeatException(originalPlan, request, applyScope); + + case REPEAT_INSTANCE_UPDATE: + return updateExistingException(originalPlan, request, applyScope); + + default: + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + // 원본과 요청을 비교 + private UpdateType determineUpdateType(StudyPlan originalPlan, StudyPlanRequest request) { + // 1. 반복 룰 없음 -> 단발성 이므로 원본 수정 + if (originalPlan.getRepeatRule() == null) { + return UpdateType.ORIGINAL_PLAN_UPDATE; + } + + // 2-1. 반복 계획에서 요청 날짜가 원본 날짜와 같음 -> 원본이므로 원본 수정 + LocalDate requestDate = request.getStartDate().toLocalDate(); + LocalDate originalDate = originalPlan.getStartDate().toLocalDate(); + + if (requestDate.equals(originalDate)) { + return UpdateType.ORIGINAL_PLAN_UPDATE; + } + + // 2-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인 + Optional existingException = studyPlanExceptionRepository + .findByPlanIdAndDate(originalPlan.getId(), requestDate.atStartOfDay()); + + if (existingException.isPresent()) { + return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정 + } else { + return UpdateType.REPEAT_INSTANCE_CREATE; // 새 예외 생성 + } + } + + // 원본 계획 수정 + private StudyPlanResponse updateOriginalPlan(StudyPlan originalPlan, StudyPlanRequest request) { + // 원본 계획 직접 수정 + if (request.getSubject() != null) originalPlan.setSubject(request.getSubject()); + if (request.getStartDate() != null) originalPlan.setStartDate(request.getStartDate()); + if (request.getEndDate() != null) originalPlan.setEndDate(request.getEndDate()); + if (request.getColor() != null) originalPlan.setColor(request.getColor()); + + // 반복 규칙 수정 + if (request.getRepeatRule() != null && originalPlan.getRepeatRule() != null) { + updateRepeatRule(originalPlan.getRepeatRule(), request.getRepeatRule()); + } + + StudyPlan savedPlan = studyPlanRepository.save(originalPlan); + return new StudyPlanResponse(savedPlan); + } + + // 새로운 예외 추가 + private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + LocalDate exceptionDate = request.getStartDate().toLocalDate(); + + // 해당 날짜에 실제로 반복 계획이 있는지 확인 + if (!shouldRepeatOnDate(originalPlan, exceptionDate)) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + StudyPlanException exception = new StudyPlanException(); + exception.setStudyPlan(originalPlan); + exception.setExceptionDate(exceptionDate.atStartOfDay()); + exception.setExceptionType(StudyPlanException.ExceptionType.MODIFIED); + exception.setApplyScope(applyScope); // 파라미터로 받은 applyScope + + // 수정된 내용 설정 + if (request.getSubject() != null) exception.setModifiedSubject(request.getSubject()); + if (request.getStartDate() != null) exception.setModifiedStartDate(request.getStartDate()); + if (request.getEndDate() != null) exception.setModifiedEndDate(request.getEndDate()); + if (request.getColor() != null) exception.setModifiedColor(request.getColor()); + + studyPlanExceptionRepository.save(exception); + + // 수정된 가상 계획 반환 + return createVirtualPlanForDate(originalPlan, exceptionDate); + } + + // 기존 예외 수정 + private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyPlanRequest request, StudyPlanException.ApplyScope applyScope) { + LocalDate exceptionDate = request.getStartDate().toLocalDate(); + + StudyPlanException existingException = studyPlanExceptionRepository + .findByPlanIdAndDate(originalPlan.getId(), exceptionDate.atStartOfDay()) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND)); + + // 기존 예외 정보 업데이트 + if (request.getSubject() != null) existingException.setModifiedSubject(request.getSubject()); + if (request.getStartDate() != null) existingException.setModifiedStartDate(request.getStartDate()); + if (request.getEndDate() != null) existingException.setModifiedEndDate(request.getEndDate()); + if (request.getColor() != null) existingException.setModifiedColor(request.getColor()); + + // ApplyScope도 업데이트 (사용자가 범위를 변경할 수 있음) + existingException.setApplyScope(applyScope); + + studyPlanExceptionRepository.save(existingException); + + // 수정된 가상 계획 반환 + return createVirtualPlanForDate(originalPlan, exceptionDate); + } + + + // 반복 룰 수정 + private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRuleRequest request) { + if (request.getFrequency() != null) repeatRule.setFrequency(request.getFrequency()); + if (request.getIntervalValue() != null) repeatRule.setRepeatInterval(request.getIntervalValue()); + if (request.getByDay() != null) repeatRule.setByDay(request.getByDay()); + + if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { + try { + LocalDateTime untilDateTime = LocalDateTime.parse(request.getUntilDate() + "T23:59:59"); + repeatRule.setUntilDate(untilDateTime); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + } + + // ==================== 삭제 =================== + + + // ==================== 유틸 =================== + private void validateUserAccess(StudyPlan studyPlan, Long userId) { + if (!studyPlan.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + } + From b58d1368f8dcd2ce9a382998ec50b9722492b1af Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Thu, 25 Sep 2025 11:43:16 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20exception=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EB=A3=B0=20embeddable=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=9D=EC=84=B1=EC=A1=B0=ED=9A=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/plan/dto/StudyPlanResponse.java | 5 +- .../domain/study/plan/entity/RepeatRule.java | 7 +- .../plan/entity/RepeatRuleEmbeddable.java | 25 +++++ .../study/plan/entity/StudyPlanException.java | 10 ++ .../study/plan/service/StudyPlanService.java | 98 ++++++++++--------- 5 files changed, 92 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java diff --git a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java index 0c5e29a0..8f4e1cf2 100644 --- a/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java +++ b/src/main/java/com/back/domain/study/plan/dto/StudyPlanResponse.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; @@ -44,14 +45,14 @@ public static class RepeatRuleResponse { private String byDay; // "MON" 형태의 문자열 @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") - private LocalDateTime until; + private LocalDate untilDate; public RepeatRuleResponse(com.back.domain.study.plan.entity.RepeatRule repeatRule) { if (repeatRule != null) { this.frequency = repeatRule.getFrequency(); this.repeatInterval = repeatRule.getRepeatInterval(); this.byDay = repeatRule.getByDay(); - this.until = repeatRule.getUntilDate(); + this.untilDate = repeatRule.getUntilDate(); } } diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java index 1d7c7120..fe1d7268 100644 --- a/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRule.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; +import java.time.LocalDate; @Entity @Getter @@ -25,10 +25,9 @@ public class RepeatRule extends BaseEntity { @Column(name = "interval_value", nullable = false) private int RepeatInterval; - //필요 시 요일 지정. 여러 요일 지정 시 ,로 구분 - //현재는 요일 하나만 지정하는 형태로 구현 + //요일은 계획 날짜에 따라 자동 설정 @Column(name = "by_day") private String byDay; - private LocalDateTime untilDate; + private LocalDate untilDate; } diff --git a/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java new file mode 100644 index 00000000..e480d588 --- /dev/null +++ b/src/main/java/com/back/domain/study/plan/entity/RepeatRuleEmbeddable.java @@ -0,0 +1,25 @@ +package com.back.domain.study.plan.entity; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RepeatRuleEmbeddable { + @Enumerated(EnumType.STRING) + private Frequency frequency; + + private Integer intervalValue; + private String byDay; + private LocalDate untilDate; // LocalDateTime → LocalDate 변경 +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java index 5c94a3c1..d150cd5c 100644 --- a/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java +++ b/src/main/java/com/back/domain/study/plan/entity/StudyPlanException.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.time.LocalDate; import java.time.LocalDateTime; @Entity @@ -60,4 +61,13 @@ public enum ApplyScope { THIS_ONLY, // 이 날짜만 FROM_THIS_DATE // 이 날짜부터 이후 모든 날짜 } + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "frequency", column = @Column(name = "modified_frequency")), + @AttributeOverride(name = "intervalValue", column = @Column(name = "modified_repeat_interval")), + @AttributeOverride(name = "byDay", column = @Column(name = "modified_by_day")), + @AttributeOverride(name = "untilDate", column = @Column(name = "modified_until_date")) + }) + private RepeatRuleEmbeddable modifiedRepeatRule; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index fbe345ba..21bfa3e0 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -3,6 +3,7 @@ import com.back.domain.study.plan.dto.StudyPlanRequest; import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.RepeatRule; +import com.back.domain.study.plan.entity.RepeatRuleEmbeddable; import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.study.plan.entity.StudyPlanException; import com.back.domain.study.plan.repository.StudyPlanExceptionRepository; @@ -45,35 +46,36 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanRequest request) // 반복 규칙 설정 if (request.getRepeatRule() != null) { - StudyPlanRequest.RepeatRuleRequest repeatRuleRequest = request.getRepeatRule(); - RepeatRule repeatRule = new RepeatRule(); - repeatRule.setStudyPlan(studyPlan); - repeatRule.setFrequency(repeatRuleRequest.getFrequency()); - repeatRule.setRepeatInterval(repeatRuleRequest.getIntervalValue() != null ? repeatRuleRequest.getIntervalValue() : 1); - - // byDay 문자열 그대로 저장 - if (repeatRuleRequest.getByDay() != null && !repeatRuleRequest.getByDay().isEmpty()) { - repeatRule.setByDay(repeatRuleRequest.getByDay()); - } + RepeatRule repeatRule = createRepeatRule(request.getRepeatRule(), studyPlan); + studyPlan.setRepeatRule(repeatRule); + } - // untilDate 문자열을 LocalDateTime으로 변환 - if (repeatRuleRequest.getUntilDate() != null && !repeatRuleRequest.getUntilDate().isEmpty()) { - try { - LocalDateTime untilDateTime = LocalDateTime.parse(repeatRuleRequest.getUntilDate() + "T23:59:59"); - repeatRule.setUntilDate(untilDateTime); - } catch (Exception e) { - // 날짜 파싱 실패 시 무시하거나 예외 처리 - } - } + StudyPlan savedPlan = studyPlanRepository.save(studyPlan); + return new StudyPlanResponse(savedPlan); + } + private RepeatRule createRepeatRule(StudyPlanRequest.RepeatRuleRequest request, StudyPlan studyPlan) { + RepeatRule repeatRule = new RepeatRule(); + repeatRule.setStudyPlan(studyPlan); + repeatRule.setFrequency(request.getFrequency()); + repeatRule.setRepeatInterval(request.getIntervalValue() != null ? request.getIntervalValue() : 1); + + if (request.getByDay() != null && !request.getByDay().isEmpty()) { + repeatRule.setByDay(request.getByDay()); + } - studyPlan.setRepeatRule(repeatRule); + if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getUntilDate()); + repeatRule.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } } - //추후 변수명이나 리턴 형식은 수정 예정 - StudyPlanResponse rs =new StudyPlanResponse(studyPlanRepository.save(studyPlan)); - return rs; + return repeatRule; } + // ==================== 조회 =================== //특정 날짜 계획 조회 public List getStudyPlansForDate(Long userId, LocalDate date) { //원복 계획들 + 단발성 계획 조회 @@ -81,14 +83,14 @@ public List getStudyPlansForDate(Long userId, LocalDate date) List result = new ArrayList<>(); for (StudyPlan plan : userPlans) { - // 단발성 계획인 경우 if (plan.getRepeatRule() == null) { - //타겟 날짜와 시작 날짜가 같으면 추가 + // 단발성 계획 또는 원본(시작 날짜가 타겟 날짜랑 일치) + // 바로 추가 if (plan.getStartDate().toLocalDate().isEqual(date)) { result.add(new StudyPlanResponse(plan)); } } else { - // 반복성 계획인 경우 - 가상 계획 생성 + // 반복성 계획 - 가상 계획 생성 후 추가 StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, date); if (virtualPlan != null) { result.add(virtualPlan); @@ -100,13 +102,13 @@ public List getStudyPlansForDate(Long userId, LocalDate date) } // 기간별 계획 조회 - public List getStudyPlansForPeriod(Long userId, LocalDate startDate, LocalDate endDate) { + public List getStudyPlansForPeriod(Long userId, LocalDate start, LocalDate end) { List userPlans = studyPlanRepository.findByUserId(userId); List result = new ArrayList<>(); - LocalDate currentDate = startDate; + LocalDate currentDate = start; // 날짜 범위 내에서 반복 - while (!currentDate.isAfter(endDate)) { + while (!currentDate.isAfter(end)) { for (StudyPlan plan : userPlans) { if (plan.getRepeatRule() == null) { // 단발성 계획은 그대로 추가 @@ -141,7 +143,7 @@ private StudyPlanResponse createVirtualPlanForDate(StudyPlan originalPlan, Local // untilDate 확인. 방어적 검증을 위해 null 체크 한번 더 if (repeatRule.getUntilDate() != null && - targetDate.isAfter(repeatRule.getUntilDate().toLocalDate())) { + targetDate.isAfter(repeatRule.getUntilDate())) { return null; } @@ -153,11 +155,11 @@ private StudyPlanResponse createVirtualPlanForDate(StudyPlan originalPlan, Local // 해당 날짜 계획의 예외 확인 StudyPlanException exception = getEffectiveException(originalPlan.getId(), targetDate); if (exception != null) { - // 삭제 타입은 null 반환 + //삭제 타입의 경우 null if (exception.getExceptionType() == StudyPlanException.ExceptionType.DELETED) { return null; } - // 수정된 경우 수정된 내용으로 반환 + // 수정 타입의 경우 수정된 내용으로 가상 정보 생성 후 반환 return createModifiedVirtualPlan(originalPlan, exception, targetDate); } @@ -176,7 +178,6 @@ private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) return daysBetween % repeatRule.getRepeatInterval() == 0; case WEEKLY: - // 요일 확인 if (repeatRule.getByDay() != null && !repeatRule.getByDay().isEmpty()) { String targetDayOfWeek = targetDate.getDayOfWeek().name().substring(0, 3); if (!repeatRule.getByDay().contains(targetDayOfWeek)) { @@ -198,20 +199,11 @@ private boolean shouldRepeatOnDate(StudyPlan originalPlan, LocalDate targetDate) //타켓 날짜에 적용될 예외 정보 가져오기 private StudyPlanException getEffectiveException(Long planId, LocalDate targetDate) { - List exceptions = studyPlanExceptionRepository - .findByStudyPlanIdAndExceptionDateBetween( - planId, - targetDate.atStartOfDay(), - targetDate.atTime(23, 59, 59) - ); - - StudyPlan plan = studyPlanRepository.findById(planId).orElse(null); - // 해당 날짜에 직접적인 예외가 있는지 확인 - for (StudyPlanException exception : exceptions) { - if (plan.getStartDate().toLocalDate().isEqual(targetDate)) { - return exception; - } + Optional directException = studyPlanExceptionRepository + .findByPlanIdAndDate(planId, targetDate.atStartOfDay()); + if (directException.isPresent()) { + return directException.get(); } // FROM_THIS_DATE 범위의 예외가 있는지 확인 @@ -219,7 +211,7 @@ private StudyPlanException getEffectiveException(Long planId, LocalDate targetDa .findByStudyPlanIdAndApplyScopeAndExceptionDateBefore( planId, StudyPlanException.ApplyScope.FROM_THIS_DATE, - targetDate.atTime(23, 59, 59) + targetDate.atStartOfDay() ); return scopeExceptions.stream() @@ -244,6 +236,18 @@ private StudyPlanResponse createModifiedVirtualPlan(StudyPlan originalPlan, Stud response.setColor(exception.getModifiedColor()); } + // 반복 규칙 수정 적용 + if (exception.getModifiedRepeatRule() != null) { + RepeatRuleEmbeddable modifiedRule = exception.getModifiedRepeatRule(); + StudyPlanResponse.RepeatRuleResponse newRepeatRule = new StudyPlanResponse.RepeatRuleResponse(); + newRepeatRule.setFrequency(modifiedRule.getFrequency()); + newRepeatRule.setRepeatInterval(modifiedRule.getIntervalValue()); + newRepeatRule.setByDay(modifiedRule.getByDay()); + newRepeatRule.setUntilDate(modifiedRule.getUntilDate()); + + response.setRepeatRule(newRepeatRule); + } + return response; } From cdd9bb5f35c3d5a2ba6d8fae4e0510ae8fc36665 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Thu, 25 Sep 2025 12:22:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20exception=EC=9D=98=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EB=A3=B0=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=88=98=EC=A0=95=EA=B8=B0=EB=8A=A5=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/plan/service/StudyPlanService.java | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 21bfa3e0..418df4a3 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -308,31 +308,26 @@ public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequ } } - // 원본과 요청을 비교 + // 원본과 요청(가상)을 비교 private UpdateType determineUpdateType(StudyPlan originalPlan, StudyPlanRequest request) { - // 1. 반복 룰 없음 -> 단발성 이므로 원본 수정 - if (originalPlan.getRepeatRule() == null) { - return UpdateType.ORIGINAL_PLAN_UPDATE; - } - - // 2-1. 반복 계획에서 요청 날짜가 원본 날짜와 같음 -> 원본이므로 원본 수정 LocalDate requestDate = request.getStartDate().toLocalDate(); LocalDate originalDate = originalPlan.getStartDate().toLocalDate(); + // 1-1. 반복 계획에서 요청 날짜가 원본 날짜와 같음 -> 원본이므로 원본 수정 if (requestDate.equals(originalDate)) { return UpdateType.ORIGINAL_PLAN_UPDATE; } - // 2-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인 + // 1-2. 반복 계획에서 다른 날짜인 경우 -> 기존 예외 확인 Optional existingException = studyPlanExceptionRepository .findByPlanIdAndDate(originalPlan.getId(), requestDate.atStartOfDay()); if (existingException.isPresent()) { - return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정 - } else { - return UpdateType.REPEAT_INSTANCE_CREATE; // 새 예외 생성 - } + return UpdateType.REPEAT_INSTANCE_UPDATE; // 기존 예외 수정 + } else { + return UpdateType.REPEAT_INSTANCE_CREATE; // 새 예외 생성 } +} // 원본 계획 수정 private StudyPlanResponse updateOriginalPlan(StudyPlan originalPlan, StudyPlanRequest request) { @@ -372,9 +367,27 @@ private StudyPlanResponse createRepeatException(StudyPlan originalPlan, StudyPla if (request.getEndDate() != null) exception.setModifiedEndDate(request.getEndDate()); if (request.getColor() != null) exception.setModifiedColor(request.getColor()); - studyPlanExceptionRepository.save(exception); + // 반복 규칙 수정. 요청에 반복 규칙이 있으면 설정 + if (request.getRepeatRule() != null) { + RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); + embeddable.setFrequency(request.getRepeatRule().getFrequency()); + embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); + embeddable.setByDay(request.getRepeatRule().getByDay()); + + if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); + embeddable.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + exception.setModifiedRepeatRule(embeddable); + } - // 수정된 가상 계획 반환 + + studyPlanExceptionRepository.save(exception); return createVirtualPlanForDate(originalPlan, exceptionDate); } @@ -395,14 +408,31 @@ private StudyPlanResponse updateExistingException(StudyPlan originalPlan, StudyP // ApplyScope도 업데이트 (사용자가 범위를 변경할 수 있음) existingException.setApplyScope(applyScope); - studyPlanExceptionRepository.save(existingException); + // 반복 규칙 수정사항 있으면 예외 안에 추가 (embeddable) + if (request.getRepeatRule() != null) { + RepeatRuleEmbeddable embeddable = new RepeatRuleEmbeddable(); + embeddable.setFrequency(request.getRepeatRule().getFrequency()); + embeddable.setIntervalValue(request.getRepeatRule().getIntervalValue()); + embeddable.setByDay(request.getRepeatRule().getByDay()); + + if (request.getRepeatRule().getUntilDate() != null && !request.getRepeatRule().getUntilDate().isEmpty()) { + try { + LocalDate untilDate = LocalDate.parse(request.getRepeatRule().getUntilDate()); + embeddable.setUntilDate(untilDate); + } catch (Exception e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } - // 수정된 가상 계획 반환 + existingException.setModifiedRepeatRule(embeddable); + } + + studyPlanExceptionRepository.save(existingException); return createVirtualPlanForDate(originalPlan, exceptionDate); } - // 반복 룰 수정 + // 원본의 반복 룰 수정 (엔티티) private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRuleRequest request) { if (request.getFrequency() != null) repeatRule.setFrequency(request.getFrequency()); if (request.getIntervalValue() != null) repeatRule.setRepeatInterval(request.getIntervalValue()); @@ -410,8 +440,8 @@ private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRule if (request.getUntilDate() != null && !request.getUntilDate().isEmpty()) { try { - LocalDateTime untilDateTime = LocalDateTime.parse(request.getUntilDate() + "T23:59:59"); - repeatRule.setUntilDate(untilDateTime); + LocalDate untilDate = LocalDate.parse(request.getUntilDate()); + repeatRule.setUntilDate(untilDate); } catch (Exception e) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -422,6 +452,7 @@ private void updateRepeatRule(RepeatRule repeatRule, StudyPlanRequest.RepeatRule // ==================== 유틸 =================== + // 인가 (작성자 일치 확인) private void validateUserAccess(StudyPlan studyPlan, Long userId) { if (!studyPlan.getUser().getId().equals(userId)) { throw new CustomException(ErrorCode.FORBIDDEN);