Skip to content

Commit 7e25ee2

Browse files
committed
2 parents 6d12d86 + e216eb2 commit 7e25ee2

File tree

9 files changed

+353
-4
lines changed

9 files changed

+353
-4
lines changed

src/main/java/com/back/domain/study/plan/repository/StudyPlanRepository.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,32 @@
22

33
import com.back.domain.study.plan.entity.StudyPlan;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
56
import org.springframework.stereotype.Repository;
67

8+
import java.time.LocalDateTime;
79
import java.util.List;
810

911
@Repository
1012
public interface StudyPlanRepository extends JpaRepository<StudyPlan, Long> {
1113
List<StudyPlan> findByUserId(Long userId);
14+
/* 시간 겹침 조건:
15+
새 계획 시작 시간보다 기존 계획 종료 시간이 늦고 (p.endDate > :newStart),
16+
새 계획 종료 시간보다 기존 계획 시작 시간이 빨라야 한다 (p.startDate < :newEnd).
17+
(종료 시간 == 새 시작 시간)은 허용
18+
*/
19+
@Query("""
20+
SELECT p
21+
FROM StudyPlan p
22+
WHERE p.user.id = :userId
23+
AND (:planIdToExclude IS NULL OR p.id != :planIdToExclude)
24+
AND p.endDate > :newStart
25+
AND p.startDate < :newEnd
26+
""")
27+
List<StudyPlan> findByUserIdAndNotIdAndOverlapsTime(
28+
Long userId,
29+
Long planIdToExclude,
30+
LocalDateTime newStart,
31+
LocalDateTime newEnd
32+
);
1233
}

src/main/java/com/back/domain/study/plan/service/StudyPlanService.java

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import java.time.LocalDate;
1818
import java.time.LocalDateTime;
19+
import java.time.LocalTime;
1920
import java.time.format.DateTimeFormatter;
2021
import java.time.temporal.ChronoUnit;
2122
import java.util.ArrayList;
@@ -42,6 +43,8 @@ public StudyPlanResponse createStudyPlan(Long userId, StudyPlanRequest request)
4243
// 날짜/시간 검증
4344
validateDateTime(request.getStartDate(), request.getEndDate());
4445

46+
// 시간 겹침 검증
47+
validateTimeConflict(userId, null, request.getStartDate(), request.getEndDate());
4548

4649
StudyPlan studyPlan = new StudyPlan();
4750

@@ -311,6 +314,10 @@ public StudyPlanResponse updateStudyPlan(Long userId, Long planId, StudyPlanRequ
311314
.orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND));
312315

313316
validateUserAccess(originalPlan, userId);
317+
// 날짜/시간 검증
318+
validateDateTime(request.getStartDate(), request.getEndDate());
319+
// 시간 겹침 검증 (원본 계획 ID 제외)
320+
validateTimeConflict(userId, originalPlan.getId(), request.getStartDate(), request.getEndDate());
314321

315322
// 1. 단발성 계획인 경우
316323
if (originalPlan.getRepeatRule() == null) {
@@ -548,7 +555,7 @@ private void validateUserAccess(StudyPlan studyPlan, Long userId) {
548555
throw new CustomException(ErrorCode.FORBIDDEN);
549556
}
550557
}
551-
558+
// 시작, 종료 날짜 검증
552559
private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) {
553560
if (startDate == null || endDate == null) {
554561
throw new CustomException(ErrorCode.BAD_REQUEST);
@@ -558,6 +565,49 @@ private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) {
558565
throw new CustomException(ErrorCode.PLAN_INVALID_TIME_RANGE);
559566
}
560567
}
568+
//시간 겹침 검증 메서드 (최적화된 DB 쿼리 + 가상 인스턴스 검증 조합)
569+
private void validateTimeConflict(Long userId, Long planIdToExclude, LocalDateTime newStart, LocalDateTime newEnd) {
570+
LocalDate newPlanDate = newStart.toLocalDate();
571+
572+
// 1. DB 쿼리를 통해 요청 시간과 원본 시간대가 겹칠 가능성이 있는 계획들만 로드 (최적화)
573+
// 기존 조회 코드를 이용하려 했으나 성능 문제로 인해 쿼리 작성.
574+
// 조회기능도 리펙토링 예정
575+
List<StudyPlan> conflictingOriginalPlans = studyPlanRepository.findByUserIdAndNotIdAndOverlapsTime(
576+
userId, planIdToExclude, newStart, newEnd
577+
);
578+
579+
if (conflictingOriginalPlans.isEmpty()) {
580+
return;
581+
}
582+
583+
for (StudyPlan plan : conflictingOriginalPlans) {
584+
if (plan.getRepeatRule() == null) {
585+
// 2-1. 단발성 계획 -> 쿼리에서 이미 시간 범위가 겹친다고 걸러졌지만 재확인
586+
if (isOverlapping(plan.getStartDate(), plan.getEndDate(), newStart, newEnd)) {
587+
throw new CustomException(ErrorCode.PLAN_TIME_CONFLICT);
588+
}
589+
} else {
590+
// 2-2. 반복 계획 -> 기존 메서드를 사용해 요청 날짜의 가상 인스턴스를 생성하고 검사
591+
StudyPlanResponse virtualPlan = createVirtualPlanForDate(plan, newPlanDate);
592+
593+
if (virtualPlan != null) {
594+
// 가상 인스턴스가 존재하고
595+
// 해당 인스턴스의 확정된 시간이 새 계획과 겹치는지 최종 확인
596+
if (isOverlapping(virtualPlan.getStartDate(), virtualPlan.getEndDate(), newStart, newEnd)) {
597+
throw new CustomException(ErrorCode.PLAN_TIME_CONFLICT);
598+
}
599+
}
600+
}
601+
}
602+
}
603+
/*
604+
* 두 시간 범위의 겹침을 확인하는 메서드
605+
* 겹치는 조건: (새로운 시작 시각 < 기존 종료 시각) && (새로운 종료 시각 > 기존 시작 시각)
606+
* (기존 종료 시각 == 새로운 시작 시각)은 겹치지 않는 것으로 간주
607+
*/
608+
private boolean isOverlapping(LocalDateTime existingStart, LocalDateTime existingEnd, LocalDateTime newStart, LocalDateTime newEnd) {
609+
return newStart.isBefore(existingEnd) && newEnd.isAfter(existingStart);
610+
}
561611

562612
private void validateRepeatRuleDate(StudyPlan studyPlan, LocalDate untilDate) {
563613
LocalDate planStartDate = studyPlan.getStartDate().toLocalDate();
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.back.domain.study.todo.controller;
2+
3+
import com.back.domain.study.todo.dto.TodoRequestDto;
4+
import com.back.domain.study.todo.dto.TodoResponseDto;
5+
import com.back.domain.study.todo.service.TodoService;
6+
import com.back.global.common.dto.RsData;
7+
import com.back.global.security.user.CustomUserDetails;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.format.annotation.DateTimeFormat;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
import java.time.LocalDate;
18+
import java.util.List;
19+
20+
@RestController
21+
@RequiredArgsConstructor
22+
@RequestMapping("/api/todos")
23+
@Tag(name = "Todo", description = "할 일 관련 API")
24+
public class TodoController {
25+
private final TodoService todoService;
26+
27+
// ==================== 생성 ===================
28+
@PostMapping
29+
@Operation(summary = "할 일 생성", description = "새로운 할 일을 생성합니다.")
30+
public ResponseEntity<RsData<TodoResponseDto>> createTodo(
31+
@AuthenticationPrincipal CustomUserDetails userDetails,
32+
@Valid @RequestBody TodoRequestDto requestDto
33+
) {
34+
TodoResponseDto response = todoService.createTodo(userDetails.getUserId(), requestDto);
35+
return ResponseEntity.ok(RsData.success("할 일이 생성되었습니다.", response));
36+
}
37+
38+
// ==================== 조회 ===================
39+
// 특정 날짜 조회
40+
@GetMapping
41+
@Operation(summary = "할 일 목록 조회", description = "조건에 따라 할 일 목록을 조회합니다. " +
42+
"date만 제공시 해당 날짜, startDate와 endDate 제공시 기간별, 아무것도 없으면 전체 조회")
43+
public ResponseEntity<RsData<List<TodoResponseDto>>> getTodos(
44+
@AuthenticationPrincipal CustomUserDetails userDetails,
45+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
46+
) {
47+
List<TodoResponseDto> response = todoService.getTodosByDate(userDetails.getUserId(), date);
48+
49+
return ResponseEntity.ok(RsData.success("할 일 목록을 조회했습니다.", response));
50+
}
51+
52+
// 사용자의 모든 할 일 조회
53+
@GetMapping("/all")
54+
@Operation(summary = "모든 할 일 조회", description = "사용자의 모든 할 일을 조회합니다.")
55+
public ResponseEntity<RsData<List<TodoResponseDto>>> getAllTodos(
56+
@AuthenticationPrincipal CustomUserDetails userDetails
57+
) {
58+
List<TodoResponseDto> response = todoService.getAllTodos(userDetails.getUserId());
59+
return ResponseEntity.ok(RsData.success("모든 할 일을 조회했습니다.", response));
60+
}
61+
62+
// ==================== 수정 ===================
63+
// 할 일 내용 수정
64+
@PutMapping("/{todoId}")
65+
@Operation(summary = "할 일 수정", description = "할 일의 내용과 날짜를 수정합니다.")
66+
public ResponseEntity<RsData<TodoResponseDto>> updateTodo(
67+
@AuthenticationPrincipal CustomUserDetails userDetails,
68+
@PathVariable Long todoId,
69+
@Valid @RequestBody TodoRequestDto requestDto
70+
) {
71+
TodoResponseDto response = todoService.updateTodo(userDetails.getUserId(), todoId, requestDto);
72+
return ResponseEntity.ok(RsData.success("할 일이 수정되었습니다.", response));
73+
}
74+
75+
// 할 일 완료/미완료 토글
76+
@PutMapping("/{todoId}/complete")
77+
@Operation(summary = "할 일 완료 상태 토글", description = "할 일의 완료 상태를 변경합니다.")
78+
public ResponseEntity<RsData<TodoResponseDto>> toggleTodoComplete(
79+
@AuthenticationPrincipal CustomUserDetails userDetails,
80+
@PathVariable Long todoId
81+
) {
82+
TodoResponseDto response = todoService.toggleTodoComplete(userDetails.getUserId(), todoId);
83+
return ResponseEntity.ok(RsData.success("할 일 상태가 변경되었습니다.", response));
84+
}
85+
86+
// ==================== 삭제 ===================
87+
// 할 일 삭제
88+
@DeleteMapping("/{todoId}")
89+
@Operation(summary = "할 일 삭제", description = "할 일을 삭제합니다.")
90+
public ResponseEntity<RsData<Void>> deleteTodo(
91+
@AuthenticationPrincipal CustomUserDetails userDetails,
92+
@PathVariable Long todoId
93+
) {
94+
todoService.deleteTodo(userDetails.getUserId(), todoId);
95+
return ResponseEntity.ok(RsData.success("할 일이 삭제되었습니다."));
96+
}
97+
98+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.back.domain.study.todo.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
import java.time.LocalDate;
8+
9+
public record TodoRequestDto(
10+
@NotBlank(message = "할 일 설명은 필수입니다.")
11+
String description,
12+
@NotNull(message = "날짜는 필수입니다.")
13+
LocalDate date
14+
) {
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.back.domain.study.todo.dto;
2+
3+
import com.back.domain.study.todo.entity.Todo;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
6+
import java.time.LocalDate;
7+
8+
public record TodoResponseDto(
9+
Long id,
10+
String description,
11+
boolean isComplete,
12+
LocalDate date
13+
) {
14+
// entity -> DTO
15+
public static TodoResponseDto from(Todo todo) {
16+
return new TodoResponseDto(
17+
todo.getId(),
18+
todo.getDescription(),
19+
todo.isComplete(),
20+
todo.getDate()
21+
);
22+
}
23+
}

src/main/java/com/back/domain/study/todo/entity/Todo.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,36 @@
77
import lombok.Getter;
88
import lombok.NoArgsConstructor;
99

10+
import java.time.LocalDate;
1011
import java.time.LocalDateTime;
1112
import java.util.List;
1213

1314
@Entity
1415
@Getter
1516
@NoArgsConstructor
1617
public class Todo extends BaseEntity {
17-
@ManyToOne(fetch = FetchType.LAZY)
18+
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
1819
@JoinColumn(name = "user_id")
1920
private User user;
2021

2122
private boolean isComplete;
2223

2324
private String description;
2425

25-
private LocalDateTime date;
26+
private LocalDate date;
2627

28+
public Todo(User user, String description, LocalDate date) {
29+
this.user = user;
30+
this.description = description;
31+
this.date = date;
32+
this.isComplete = false;
33+
}
34+
35+
public void updateDescription(String description) {
36+
this.description = description;
37+
}
38+
39+
public void toggleComplete() {
40+
this.isComplete = !this.isComplete;
41+
}
2742
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.back.domain.study.todo.repository;
2+
3+
import com.back.domain.study.todo.entity.Todo;
4+
import com.back.domain.user.entity.User;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.time.LocalDate;
9+
import java.util.List;
10+
import java.util.Optional;
11+
12+
@Repository
13+
public interface TodoRepository extends JpaRepository<Todo, Long> {
14+
List<Todo> findByUserIdAndDate(Long userId, LocalDate date);
15+
List<Todo> findByUserId(Long userId);
16+
Todo findByIdAndUser(Long id, User user);
17+
}

0 commit comments

Comments
 (0)