From 183f8690bb5e1a325111f27973c491410dac741a Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Thu, 2 Oct 2025 14:22:38 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20record=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0,=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudyRecordController.java | 7 +++++ .../record/dto/StudyRecordRequestDto.java | 4 +++ .../record/dto/StudyRecordResponseDto.java | 26 +++++++++++++++++ .../domain/study/record/entity/PauseInfo.java | 29 +++++++++++++++++++ .../study/record/entity/StudyRecord.java | 12 ++++---- .../repository/StudyRecordRepository.java | 9 ++++++ .../record/service/StudyPlanService.java | 7 +++++ 7 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/back/domain/study/record/controller/StudyRecordController.java create mode 100644 src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java create mode 100644 src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java create mode 100644 src/main/java/com/back/domain/study/record/entity/PauseInfo.java create mode 100644 src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java create mode 100644 src/main/java/com/back/domain/study/record/service/StudyPlanService.java diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java new file mode 100644 index 00000000..792163ed --- /dev/null +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -0,0 +1,7 @@ +package com.back.domain.study.record.controller; + +import org.springframework.stereotype.Controller; + +@Controller +public class StudyRecordController { +} diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java new file mode 100644 index 00000000..8f7dbaf4 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java @@ -0,0 +1,4 @@ +package com.back.domain.study.record.dto; + +public class StudyRecordRequestDto { +} diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java new file mode 100644 index 00000000..63c4673b --- /dev/null +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java @@ -0,0 +1,26 @@ +package com.back.domain.study.record.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +public class StudyRecordResponseDto { + private Long planId; + private Long roomId; + private LocalDateTime startTime; + private List pauseInfos = new ArrayList<>(); + private LocalDateTime endTime; + private int duration; + + @Getter + @NoArgsConstructor + public static class PauseInfoDto { + private LocalDateTime pausedAt; + private LocalDateTime restartAt; + } +} diff --git a/src/main/java/com/back/domain/study/record/entity/PauseInfo.java b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java new file mode 100644 index 00000000..f62f465e --- /dev/null +++ b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java @@ -0,0 +1,29 @@ +package com.back.domain.study.record.entity; + + +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "pause_infos") +public class PauseInfo { + // 일시정지 정보에 생성, 수정일은 필요 없을 것 같아서 + // id만 별도로 생성 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_record_id", nullable = false) + private StudyRecord studyRecord; + + @Column(name = "paused_at", nullable = false) + private LocalDateTime pausedAt; + + @Column(name = "restart_at") + private LocalDateTime restartAt; +} diff --git a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java index 100a0110..87aa075f 100644 --- a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java +++ b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java @@ -3,14 +3,13 @@ import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.studyroom.entity.Room; import com.back.global.entity.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @NoArgsConstructor @@ -24,9 +23,12 @@ public class StudyRecord extends BaseEntity { @JoinColumn(name = "room_id") private Room room; - private int duration; + private Long duration; private LocalDateTime startTime; + @OneToMany(mappedBy = "studyRecord", cascade = CascadeType.ALL, orphanRemoval = true) + private List pauseInfos = new ArrayList<>(); + private LocalDateTime endTime; } diff --git a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java new file mode 100644 index 00000000..c7b5847e --- /dev/null +++ b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.study.record.repository; + +import com.back.domain.study.record.entity.StudyRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface StudyRecordRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/domain/study/record/service/StudyPlanService.java b/src/main/java/com/back/domain/study/record/service/StudyPlanService.java new file mode 100644 index 00000000..1a033554 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/service/StudyPlanService.java @@ -0,0 +1,7 @@ +package com.back.domain.study.record.service; + +import org.springframework.stereotype.Service; + +@Service +public class StudyPlanService { +} From 87530dc153e4061a984db963899cd007d50994d6 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Thu, 2 Oct 2025 17:44:47 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudyRecordController.java | 26 +++++++ .../record/dto/StudyRecordRequestDto.java | 21 ++++++ .../record/dto/StudyRecordResponseDto.java | 31 +++++++- .../domain/study/record/entity/PauseInfo.java | 13 ++++ .../study/record/entity/StudyRecord.java | 47 ++++++++++++ .../record/service/StudyPlanService.java | 7 -- .../record/service/StudyRecordService.java | 72 +++++++++++++++++++ 7 files changed, 207 insertions(+), 10 deletions(-) delete mode 100644 src/main/java/com/back/domain/study/record/service/StudyPlanService.java create mode 100644 src/main/java/com/back/domain/study/record/service/StudyRecordService.java diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java index 792163ed..b6fe187f 100644 --- a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -1,7 +1,33 @@ package com.back.domain.study.record.controller; +import com.back.domain.study.record.dto.StudyRecordRequestDto; +import com.back.domain.study.record.dto.StudyRecordResponseDto; +import com.back.domain.study.record.service.StudyRecordService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; @Controller +@RequiredArgsConstructor +@RequestMapping("/api/records") public class StudyRecordController { + private final StudyRecordService studyRecordService; + + // 학습 기록 생성 + @PostMapping + public ResponseEntity> createStudyRecord( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody StudyRecordRequestDto request + ) { + Long userId = user.getUserId(); + StudyRecordResponseDto response = studyRecordService.createStudyRecord(userId, request); + return ResponseEntity.ok(RsData.success("학습 기록이 생성되었습니다.", response)); + } } diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java index 8f7dbaf4..0ca12225 100644 --- a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java @@ -1,4 +1,25 @@ package com.back.domain.study.record.dto; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor public class StudyRecordRequestDto { + private Long planId; + private Long roomId; + private LocalDateTime startTime; + private LocalDateTime endTime; + private List pauseInfos = new ArrayList<>(); + + @Getter + @NoArgsConstructor + public static class PauseInfoRequestDto { + private LocalDateTime pausedAt; + private LocalDateTime restartAt; + } } diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java index 63c4673b..04b65a46 100644 --- a/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java @@ -1,26 +1,51 @@ package com.back.domain.study.record.dto; +import com.back.domain.study.record.entity.PauseInfo; +import com.back.domain.study.record.entity.StudyRecord; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Getter @NoArgsConstructor public class StudyRecordResponseDto { + private Long id; private Long planId; private Long roomId; private LocalDateTime startTime; - private List pauseInfos = new ArrayList<>(); + private List pauseInfos = new ArrayList<>(); private LocalDateTime endTime; - private int duration; + private Long duration; + + public static StudyRecordResponseDto from(StudyRecord studyRecord) { + StudyRecordResponseDto dto = new StudyRecordResponseDto(); + dto.id = studyRecord.getId(); + dto.planId = studyRecord.getStudyPlan() != null ? studyRecord.getStudyPlan().getId() : null; + dto.roomId = studyRecord.getRoom() != null ? studyRecord.getRoom().getId() : null; + dto.startTime = studyRecord.getStartTime(); + dto.endTime = studyRecord.getEndTime(); + dto.duration = studyRecord.getDuration(); + dto.pauseInfos = studyRecord.getPauseInfos().stream() + .map(PauseInfoResponseDto::from) + .collect(Collectors.toList()); + return dto; + } @Getter @NoArgsConstructor - public static class PauseInfoDto { + public static class PauseInfoResponseDto { private LocalDateTime pausedAt; private LocalDateTime restartAt; + + public static PauseInfoResponseDto from(PauseInfo pauseInfo) { + PauseInfoResponseDto dto = new PauseInfoResponseDto(); + dto.pausedAt = pauseInfo.getPausedAt(); + dto.restartAt = pauseInfo.getRestartAt(); + return dto; + } } } diff --git a/src/main/java/com/back/domain/study/record/entity/PauseInfo.java b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java index f62f465e..7500352c 100644 --- a/src/main/java/com/back/domain/study/record/entity/PauseInfo.java +++ b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java @@ -26,4 +26,17 @@ public class PauseInfo { @Column(name = "restart_at") private LocalDateTime restartAt; + + public static PauseInfo of(LocalDateTime pausedAt, LocalDateTime restartAt) { + PauseInfo pauseInfo = new PauseInfo(); + pauseInfo.pausedAt = pausedAt; + pauseInfo.restartAt = restartAt; + return pauseInfo; + } + + // 양방향 연관관계 설정 + void assignStudyRecord(StudyRecord studyRecord) { + this.studyRecord = studyRecord; + } + } diff --git a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java index 87aa075f..c54f0b59 100644 --- a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java +++ b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java @@ -2,11 +2,13 @@ import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -15,6 +17,11 @@ @NoArgsConstructor @Getter public class StudyRecord extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "plan_id") private StudyPlan studyPlan; @@ -31,4 +38,44 @@ public class StudyRecord extends BaseEntity { private List pauseInfos = new ArrayList<>(); private LocalDateTime endTime; + + public static StudyRecord create(User user, StudyPlan studyPlan, Room room, + LocalDateTime startTime, LocalDateTime endTime, + List pauseInfos) { + StudyRecord record = new StudyRecord(); + record.user = user; + record.studyPlan = studyPlan; + record.room = room; + record.startTime = startTime; + record.endTime = endTime; + + // 일시정지 정보 추가 + if (pauseInfos != null && !pauseInfos.isEmpty()) { + pauseInfos.forEach(record::addPauseInfo); + } + // 총 학습 시간 계산 + record.calculateDuration(); + + return record; + } + + // 일시정지 정보 추가 + private void addPauseInfo(PauseInfo pauseInfo) { + this.pauseInfos.add(pauseInfo); + pauseInfo.assignStudyRecord(this); + } + + // 실제 학습 시간 계산 (전체 시간 - 일시정지 시간) + private void calculateDuration() { + // 전체 시간 계산 (초) + long totalSeconds = Duration.between(startTime, endTime).getSeconds(); + + // 일시정지 시간 제외 + long pausedSeconds = pauseInfos.stream() + .filter(pause -> pause.getPausedAt() != null && pause.getRestartAt() != null) + .mapToLong(pause -> Duration.between(pause.getPausedAt(), pause.getRestartAt()).getSeconds()) + .sum(); + + this.duration = totalSeconds - pausedSeconds; + } } diff --git a/src/main/java/com/back/domain/study/record/service/StudyPlanService.java b/src/main/java/com/back/domain/study/record/service/StudyPlanService.java deleted file mode 100644 index 1a033554..00000000 --- a/src/main/java/com/back/domain/study/record/service/StudyPlanService.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.back.domain.study.record.service; - -import org.springframework.stereotype.Service; - -@Service -public class StudyPlanService { -} diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java new file mode 100644 index 00000000..6612b19c --- /dev/null +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -0,0 +1,72 @@ +package com.back.domain.study.record.service; + +import com.back.domain.study.plan.entity.StudyPlan; +import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.study.record.dto.StudyRecordRequestDto; +import com.back.domain.study.record.dto.StudyRecordResponseDto; +import com.back.domain.study.record.entity.PauseInfo; +import com.back.domain.study.record.entity.StudyRecord; +import com.back.domain.study.record.repository.StudyRecordRepository; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomRepository; +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.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StudyRecordService { + private final StudyRecordRepository studyRecordRepository; + private final StudyPlanRepository studyPlanRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + // 학습 기록 생성 (종료 시 한 번에 기록) + @Transactional + public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestDto request) { + // 계획 조회 + StudyPlan studyPlan = studyPlanRepository.findById(request.getPlanId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); + // 유저 조회 및 권한 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + if (!studyPlan.getUser().equals(user)) { + throw new CustomException(ErrorCode.PLAN_FORBIDDEN); + } + + // 방 조회 (우선은 옵셔널로 설정) + Room room = null; + if (request.getRoomId() != null) { + room = roomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + } + + // 일시정지 정보를 엔티티로 생성 + List pauseInfos = request.getPauseInfos().stream() + .map(dto -> PauseInfo.of(dto.getPausedAt(), dto.getRestartAt())) + .collect(Collectors.toList()); + + // 학습 기록 생성 (시작, 종료, 일시정지 정보 모두 포함) + StudyRecord record = StudyRecord.create( + user, + studyPlan, + room, + request.getStartTime(), + request.getEndTime(), + pauseInfos + ); + + // 저장 + StudyRecord saved = studyRecordRepository.save(record); + StudyRecordResponseDto response = StudyRecordResponseDto.from(saved); + + return response; + } +} From 66535d6bca01f9ec755052460799e52d86cfc65a Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Fri, 3 Oct 2025 21:34:30 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20duration=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80+=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/plan/service/StudyPlanService.java | 2 +- .../controller/StudyRecordController.java | 4 +-- .../record/dto/StudyRecordRequestDto.java | 1 + .../study/record/entity/StudyRecord.java | 21 ++++++++--- .../record/service/StudyRecordService.java | 35 +++++++++++++++++-- .../com/back/global/exception/ErrorCode.java | 5 ++- 6 files changed, 56 insertions(+), 12 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 10efea91..5b1c0e97 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 @@ -557,7 +557,7 @@ private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) { } if (!startDate.isBefore(endDate)) { - throw new CustomException(ErrorCode.PLAN_INVALID_TIME_RANGE); + throw new CustomException(ErrorCode.INVALID_TIME_RANGE); } } //시간 겹침 검증 메서드 (최적화된 DB 쿼리 + 가상 인스턴스 검증 조합) diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java index b6fe187f..a0006fa2 100644 --- a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -9,12 +9,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -@Controller +@RestController @RequiredArgsConstructor @RequestMapping("/api/records") public class StudyRecordController { diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java index 0ca12225..6d3f6d27 100644 --- a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java @@ -14,6 +14,7 @@ public class StudyRecordRequestDto { private Long roomId; private LocalDateTime startTime; private LocalDateTime endTime; + private Long duration; private List pauseInfos = new ArrayList<>(); @Getter diff --git a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java index c54f0b59..8810b036 100644 --- a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java +++ b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java @@ -4,6 +4,8 @@ import com.back.domain.studyroom.entity.Room; import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -23,13 +25,15 @@ public class StudyRecord extends BaseEntity { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "plan_id") + @JoinColumn(name = "plan_id", nullable = false) private StudyPlan studyPlan; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "room_id") private Room room; + // 초 단위 + @Column(nullable = false) private Long duration; private LocalDateTime startTime; @@ -41,6 +45,7 @@ public class StudyRecord extends BaseEntity { public static StudyRecord create(User user, StudyPlan studyPlan, Room room, LocalDateTime startTime, LocalDateTime endTime, + Long providedDuration, List pauseInfos) { StudyRecord record = new StudyRecord(); record.user = user; @@ -53,8 +58,8 @@ public static StudyRecord create(User user, StudyPlan studyPlan, Room room, if (pauseInfos != null && !pauseInfos.isEmpty()) { pauseInfos.forEach(record::addPauseInfo); } - // 총 학습 시간 계산 - record.calculateDuration(); + // 총 학습 시간 계산 및 검증 + record.calculateDuration(providedDuration); return record; } @@ -66,7 +71,7 @@ private void addPauseInfo(PauseInfo pauseInfo) { } // 실제 학습 시간 계산 (전체 시간 - 일시정지 시간) - private void calculateDuration() { + private void calculateDuration(Long providedDuration) { // 전체 시간 계산 (초) long totalSeconds = Duration.between(startTime, endTime).getSeconds(); @@ -76,6 +81,12 @@ private void calculateDuration() { .mapToLong(pause -> Duration.between(pause.getPausedAt(), pause.getRestartAt()).getSeconds()) .sum(); - this.duration = totalSeconds - pausedSeconds; + Long calculatedDuration = totalSeconds - pausedSeconds; + // 제공된 duration과 계산된 duration이 5초 이상 차이나면 예외 처리 + if ( Math.abs(calculatedDuration - providedDuration) > 5) { + throw new CustomException(ErrorCode.DURATION_MISMATCH); + } + + this.duration = calculatedDuration; } } diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 6612b19c..0c5bcbbe 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -28,6 +28,7 @@ public class StudyRecordService { private final RoomRepository roomRepository; private final UserRepository userRepository; + // ===================== 생성 ===================== // 학습 기록 생성 (종료 시 한 번에 기록) @Transactional public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestDto request) { @@ -50,7 +51,18 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD // 일시정지 정보를 엔티티로 생성 List pauseInfos = request.getPauseInfos().stream() - .map(dto -> PauseInfo.of(dto.getPausedAt(), dto.getRestartAt())) + .map(dto -> { + // 일시정지 시간 범위 검증 + validateTimeRange(dto.getPausedAt(), dto.getRestartAt()); + // 일시정지가 학습 시간 내에 있는지 검증 + validatePauseInStudyRange( + request.getStartTime(), + request.getEndTime(), + dto.getPausedAt(), + dto.getRestartAt() + ); + return PauseInfo.of(dto.getPausedAt(), dto.getRestartAt()); + }) .collect(Collectors.toList()); // 학습 기록 생성 (시작, 종료, 일시정지 정보 모두 포함) @@ -60,13 +72,30 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD room, request.getStartTime(), request.getEndTime(), + request.getDuration(), pauseInfos ); // 저장 StudyRecord saved = studyRecordRepository.save(record); - StudyRecordResponseDto response = StudyRecordResponseDto.from(saved); + return StudyRecordResponseDto.from(saved); + } + // ===================== 조회 ===================== + + + // ===================== 유틸 ===================== + // 시간 범위 검증 + private void validateTimeRange(java.time.LocalDateTime startTime, java.time.LocalDateTime endTime) { + if (startTime.isAfter(endTime) || startTime.isEqual(endTime)) { + throw new CustomException(ErrorCode.INVALID_TIME_RANGE); + } + } - return response; + // 일시정지 시간이 학습 시간 내에 있는지 검증 + private void validatePauseInStudyRange(java.time.LocalDateTime studyStart, java.time.LocalDateTime studyEnd, + java.time.LocalDateTime pauseStart, java.time.LocalDateTime pauseEnd) { + if (pauseStart.isBefore(studyStart) || pauseEnd.isAfter(studyEnd)) { + throw new CustomException(ErrorCode.INVALID_TIME_RANGE); + } } } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 67266f4a..9d189c2f 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -43,7 +43,7 @@ public enum ErrorCode { PLAN_EXCEPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_003", "학습 계획의 예외가 존재하지 않습니다."), PLAN_ORIGINAL_REPEAT_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_004", "해당 날짜에 원본 반복 계획을 찾을 수 없습니다."), INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "PLAN_005", "날짜 형식이 올바르지 않습니다. (YYYY-MM-DD 형식을 사용해주세요)"), - PLAN_INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "PLAN_006", "시작 시간은 종료 시간보다 빨라야 합니다."), + INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "PLAN_006", "시작 시간은 종료 시간보다 빨라야 합니다."), PLAN_TIME_CONFLICT(HttpStatus.CONFLICT, "PLAN_007", "이미 존재하는 학습 계획과 시간이 겹칩니다. 기존 종료 시간과 겹치는 경우는 제외됩니다."), PLAN_CANNOT_UPDATE(HttpStatus.BAD_REQUEST, "PLAN_008", "수정 스위치 로직 탈출. 어떤 경우인지 파악이 필요합니다."), REPEAT_INVALID_UNTIL_DATE(HttpStatus.BAD_REQUEST, "REPEAT_001", "반복 계획의 종료 날짜는 시작 날짜 이전일 수 없습니다."), @@ -53,6 +53,9 @@ public enum ErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_001", "존재하지 않는 할 일입니다."), TODO_FORBIDDEN(HttpStatus.FORBIDDEN, "TODO_002", "할 일에 대한 접근 권한이 없습니다."), + // ======================== 학습 기록 관련 ======================== + DURATION_MISMATCH(HttpStatus.BAD_REQUEST, "RECORD_001", "받은 duration과 계산된 duration이 5초 이상 차이납니다."), + // ======================== 알림 관련 ======================== NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_001", "존재하지 않는 알림입니다."), NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_002", "알림에 대한 접근 권한이 없습니다."), From 639df865bf048cdf0018d57736f55a25a2b16c2f Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Fri, 3 Oct 2025 21:45:02 +0900 Subject: [PATCH 04/13] =?UTF-8?q?refact:=20=EA=B2=80=EC=A6=9D=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/study/record/entity/StudyRecord.java | 15 ++++----------- .../study/record/service/StudyRecordService.java | 10 +++++++++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java index 8810b036..756af318 100644 --- a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java +++ b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java @@ -45,7 +45,6 @@ public class StudyRecord extends BaseEntity { public static StudyRecord create(User user, StudyPlan studyPlan, Room room, LocalDateTime startTime, LocalDateTime endTime, - Long providedDuration, List pauseInfos) { StudyRecord record = new StudyRecord(); record.user = user; @@ -58,8 +57,8 @@ public static StudyRecord create(User user, StudyPlan studyPlan, Room room, if (pauseInfos != null && !pauseInfos.isEmpty()) { pauseInfos.forEach(record::addPauseInfo); } - // 총 학습 시간 계산 및 검증 - record.calculateDuration(providedDuration); + // 총 학습 시간 계산 (검증은 서비스에서) + record.calculateDuration(); return record; } @@ -71,7 +70,7 @@ private void addPauseInfo(PauseInfo pauseInfo) { } // 실제 학습 시간 계산 (전체 시간 - 일시정지 시간) - private void calculateDuration(Long providedDuration) { + private void calculateDuration() { // 전체 시간 계산 (초) long totalSeconds = Duration.between(startTime, endTime).getSeconds(); @@ -81,12 +80,6 @@ private void calculateDuration(Long providedDuration) { .mapToLong(pause -> Duration.between(pause.getPausedAt(), pause.getRestartAt()).getSeconds()) .sum(); - Long calculatedDuration = totalSeconds - pausedSeconds; - // 제공된 duration과 계산된 duration이 5초 이상 차이나면 예외 처리 - if ( Math.abs(calculatedDuration - providedDuration) > 5) { - throw new CustomException(ErrorCode.DURATION_MISMATCH); - } - - this.duration = calculatedDuration; + this.duration = totalSeconds - pausedSeconds;; } } diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 0c5bcbbe..809719d0 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -72,7 +72,6 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD room, request.getStartTime(), request.getEndTime(), - request.getDuration(), pauseInfos ); @@ -98,4 +97,13 @@ private void validatePauseInStudyRange(java.time.LocalDateTime studyStart, java. throw new CustomException(ErrorCode.INVALID_TIME_RANGE); } } + // 프론트에서 계산한 학습 시간과 백엔드에서 계산한 학습 시간의 차이가 5초 이상이면 예외 발생 + private void validateDurationDifference(Long frontDuration, Long backendDuration) { + long difference = Math.abs(frontDuration - backendDuration); + + // 5초 이상 차이나면 예외 발생 + if (difference > 5) { + throw new CustomException(ErrorCode.DURATION_MISMATCH); + } + } } From 4631bba929527e622b0068906a2809e65cfa0372 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Fri, 3 Oct 2025 22:13:12 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=EB=B3=84=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StudyRecordRepository.java | 5 +++++ .../record/service/StudyRecordService.java | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java index c7b5847e..062ff2b8 100644 --- a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java +++ b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java @@ -4,6 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + @Repository public interface StudyRecordRepository extends JpaRepository { + List findByUserIdAndStartTimeBetween( + Long userId, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 809719d0..92505cc1 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -17,6 +17,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -80,7 +82,24 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD return StudyRecordResponseDto.from(saved); } // ===================== 조회 ===================== + // 날짜별 학습 기록 조회 + public List getStudyRecordsByDate(Long userId, LocalDate date) { + // 유저 조회 및 권한 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 오전 4시 기준 하루의 시작과 끝 설정 + LocalDateTime startOfDay = date.atTime(4, 0, 0); + LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0, 0); + // startTime이 해당 날짜 범위 내에 있는 기록 조회 + List records = studyRecordRepository + .findByUserIdAndStartTimeBetween(userId, startOfDay, endOfDay); + + return records.stream() + .map(StudyRecordResponseDto::from) + .collect(Collectors.toList()); + } // ===================== 유틸 ===================== // 시간 범위 검증 From 5d63c518a5c7b4f0a6c6c792a4fd46291914f906 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Fri, 3 Oct 2025 23:50:19 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refact:=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91,=EC=A2=85=EB=A3=8C=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8A=94=20=EC=9D=BC?= =?UTF-8?q?=EC=9E=90=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StudyRecordRepository.java | 17 +++++++++++++++++ .../record/service/StudyRecordService.java | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java index 062ff2b8..fdac8fd2 100644 --- a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java +++ b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java @@ -2,6 +2,8 @@ import com.back.domain.study.record.entity.StudyRecord; 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; @@ -9,6 +11,21 @@ @Repository public interface StudyRecordRepository extends JpaRepository { + // 시작 시간을 기준으로만 조회 List findByUserIdAndStartTimeBetween( Long userId, LocalDateTime start, LocalDateTime end); + + // 시작 시간과 종료 시간을 둘 다 고려하여 조회 + @Query("SELECT sr FROM StudyRecord sr " + + "WHERE sr.user.id = :userId " + + "AND (" + + " (sr.startTime >= :startOfDay AND sr.startTime < :endOfDay) OR " + + " (sr.endTime > :startOfDay AND sr.endTime <= :endOfDay) OR " + + " (sr.startTime < :startOfDay AND sr.endTime > :endOfDay)" + + ") " + + "ORDER BY sr.startTime DESC") + List findByUserIdAndDateRange( + @Param("userId") Long userId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); } diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 92505cc1..f04cc72d 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -92,9 +92,9 @@ public List getStudyRecordsByDate(Long userId, LocalDate LocalDateTime startOfDay = date.atTime(4, 0, 0); LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0, 0); - // startTime이 해당 날짜 범위 내에 있는 기록 조회 + // 시작~종료 시간을 포함하는 일자의 학습 기록 조회 List records = studyRecordRepository - .findByUserIdAndStartTimeBetween(userId, startOfDay, endOfDay); + .findByUserIdAndDateRange(userId, startOfDay, endOfDay); return records.stream() .map(StudyRecordResponseDto::from) From 85242ed516cf9924a0305728aae6f5a6aba5b0f2 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 00:37:35 +0900 Subject: [PATCH 07/13] =?UTF-8?q?test:=20=EC=83=9D=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudyRecordControllerTest.java | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java new file mode 100644 index 00000000..f7ce1335 --- /dev/null +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -0,0 +1,184 @@ +package com.back.domain.study.record.controller; + +import com.back.domain.study.plan.entity.Color; +import com.back.domain.study.plan.entity.Frequency; +import com.back.domain.study.plan.entity.RepeatRule; +import com.back.domain.study.plan.entity.StudyPlan; +import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.security.jwt.JwtTokenProvider; +import com.back.global.security.user.CustomUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("StudyRecordController 테스트") +class StudyRecordControllerTest { + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private MockMvc mvc; + + @Autowired + private StudyPlanRepository studyPlanRepository; + @Autowired + private UserRepository userRepository; + + private User testUser; + private StudyPlan singlePlan; + private StudyPlan dailyPlan; + + @BeforeEach + void setUp() { + testUser = User.builder() + .email("test@example.com") + .username("testuser") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + testUser = userRepository.save(testUser); + + setupJwtMock(testUser); + + singlePlan = createSinglePlan(); + dailyPlan = createDailyPlan(); + } + + private void setupJwtMock(User user) { + given(jwtTokenProvider.validateAccessToken(anyString())).willReturn(true); + + CustomUserDetails userDetails = CustomUserDetails.builder() + .userId(user.getId()) + .username(user.getUsername()) + .role(user.getRole()) + .build(); + + given(jwtTokenProvider.getAuthentication(anyString())) + .willReturn(new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + )); + } + + private StudyPlan createSinglePlan() { + StudyPlan plan = new StudyPlan(); + plan.setUser(testUser); + plan.setSubject("단발성 계획"); + plan.setStartDate(LocalDateTime.of(2025, 10, 1, 10, 0)); + plan.setEndDate(LocalDateTime.of(2025, 10, 1, 12, 0)); + plan.setColor(Color.RED); + return studyPlanRepository.save(plan); + } + private StudyPlan createDailyPlan() { + StudyPlan plan = new StudyPlan(); + plan.setUser(testUser); + plan.setSubject("매일 반복 계획"); + plan.setStartDate(LocalDateTime.of(2025, 10, 1, 12, 0)); + plan.setEndDate(LocalDateTime.of(2025, 10, 1, 13, 0)); + plan.setColor(Color.BLUE); + + RepeatRule repeatRule = new RepeatRule(); + repeatRule.setFrequency(Frequency.DAILY); + repeatRule.setRepeatInterval(1); + repeatRule.setUntilDate(LocalDateTime.of(2025, 12, 31, 0, 0).toLocalDate()); + repeatRule.setStudyPlan(plan); + plan.setRepeatRule(repeatRule); + + return studyPlanRepository.save(plan); + + } + + @Test + @DisplayName("학습 기록 생성 - 단발성 계획") + void t1() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T10:00:00", + "endTime": "2025-10-03T12:00:00", + "duration": 7200, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(handler().handlerType(StudyRecordController.class)) + .andExpect(handler().methodName("createStudyRecord")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("학습 기록이 생성되었습니다.")) + .andExpect(jsonPath("$.data.planId").value(singlePlan.getId())) + .andExpect(jsonPath("$.data.startTime").value("2025-10-03T10:00:00")) + .andExpect(jsonPath("$.data.endTime").value("2025-10-03T12:00:00")) + .andExpect(jsonPath("$.data.duration").value(7200)) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(0))); + } + @Test + @DisplayName("학습 기록 생성 - 일시정지 포함") + void t2() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T14:00:00", + "endTime": "2025-10-03T17:00:00", + "duration": 7200", + "pauseInfos": [ + { + "pausedAt": "2025-10-03T15:00:00", + "restartAt": "2025-10-03T15:30:00" + },{ + "pausedAt": "2025-10-03T15:50:00", + "restartAt": "2025-10-03T16:10:00" + },{ + "pausedAt": "2025-10-03T16:50:00", + "restartAt": "2025-10-03T17:00:00" + } + ] + } + """.formatted(dailyPlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.startTime").value("2025-10-03T14:00:00")) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))); + } + +} \ No newline at end of file From af1bf97f9952302e78a1708f9c54a96e8713fc78 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 00:56:13 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20record=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9D=BC=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StudyRecordController.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java index a0006fa2..b6275658 100644 --- a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -9,14 +9,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; @RestController @RequiredArgsConstructor -@RequestMapping("/api/records") +@RequestMapping("/api/plans/records") public class StudyRecordController { private final StudyRecordService studyRecordService; @@ -30,4 +30,14 @@ public ResponseEntity> createStudyRecord( StudyRecordResponseDto response = studyRecordService.createStudyRecord(userId, request); return ResponseEntity.ok(RsData.success("학습 기록이 생성되었습니다.", response)); } + + @GetMapping("date/{date}") + public ResponseEntity>> getDailyStudyRecord( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable("date") LocalDate date + ) { + Long userId = user.getUserId(); + List response = studyRecordService.getStudyRecordsByDate(userId, date); + return ResponseEntity.ok(RsData.success("일별 학습 기록 조회 성공", response)); + } } From 5ab204aa23a1d5443044ef5ca03f8ef426bdef34 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 15:38:22 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refact:=20=EB=A7=A4=ED=95=91=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95(todo,=20record)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/record/controller/StudyRecordController.java | 9 ++++++--- .../domain/study/todo/controller/TodoController.java | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java index b6275658..6977a3a0 100644 --- a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -7,6 +7,7 @@ import com.back.global.security.user.CustomUserDetails; import jakarta.validation.Valid; 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.*; @@ -20,6 +21,7 @@ public class StudyRecordController { private final StudyRecordService studyRecordService; + // ======================= 생성 ====================== // 학습 기록 생성 @PostMapping public ResponseEntity> createStudyRecord( @@ -30,11 +32,12 @@ public ResponseEntity> createStudyRecord( StudyRecordResponseDto response = studyRecordService.createStudyRecord(userId, request); return ResponseEntity.ok(RsData.success("학습 기록이 생성되었습니다.", response)); } - - @GetMapping("date/{date}") + // ======================= 조회 ====================== + // 일별 학습 기록 조회 + @GetMapping public ResponseEntity>> getDailyStudyRecord( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable("date") LocalDate date + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date ) { Long userId = user.getUserId(); List response = studyRecordService.getStudyRecordsByDate(userId, date); diff --git a/src/main/java/com/back/domain/study/todo/controller/TodoController.java b/src/main/java/com/back/domain/study/todo/controller/TodoController.java index 58d0697c..691ff0dc 100644 --- a/src/main/java/com/back/domain/study/todo/controller/TodoController.java +++ b/src/main/java/com/back/domain/study/todo/controller/TodoController.java @@ -42,7 +42,7 @@ public ResponseEntity> createTodo( "date만 제공시 해당 날짜, startDate와 endDate 제공시 기간별, 아무것도 없으면 전체 조회") public ResponseEntity>> getTodos( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + @RequestParam(required = true) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date ) { List response = todoService.getTodosByDate(userDetails.getUserId(), date); From 9d5369a9cdc8fb14bd96e7e31a5558d754fec50c Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 19:30:16 +0900 Subject: [PATCH 10/13] =?UTF-8?q?test:=20record=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../record/service/StudyRecordService.java | 16 ++- .../controller/StudyRecordControllerTest.java | 112 ++++++++++++++++-- 2 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index f04cc72d..c77a8215 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -54,16 +54,24 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD // 일시정지 정보를 엔티티로 생성 List pauseInfos = request.getPauseInfos().stream() .map(dto -> { + LocalDateTime pausedAt = dto.getPausedAt(); + LocalDateTime restartAt = dto.getRestartAt(); + + // 재시작 안 했으면 학습 종료 시간을 재시작 시간으로 간주 + if (restartAt == null) { + restartAt = request.getEndTime(); + } + // 일시정지 시간 범위 검증 - validateTimeRange(dto.getPausedAt(), dto.getRestartAt()); + validateTimeRange(pausedAt, restartAt); // 일시정지가 학습 시간 내에 있는지 검증 validatePauseInStudyRange( request.getStartTime(), request.getEndTime(), - dto.getPausedAt(), - dto.getRestartAt() + pausedAt, + restartAt ); - return PauseInfo.of(dto.getPausedAt(), dto.getRestartAt()); + return PauseInfo.of(pausedAt, restartAt); }) .collect(Collectors.toList()); diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index f7ce1335..2630299d 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -33,6 +33,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -118,9 +119,9 @@ private StudyPlan createDailyPlan() { } @Test - @DisplayName("학습 기록 생성 - 단발성 계획") + @DisplayName("학습 기록 생성 - 일시정지 없음") void t1() throws Exception { - ResultActions resultActions = mvc.perform(post("/api/records") + ResultActions resultActions = mvc.perform(post("/api/plans/records") .header("Authorization", "Bearer faketoken") .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -147,9 +148,9 @@ void t1() throws Exception { .andExpect(jsonPath("$.data.pauseInfos", hasSize(0))); } @Test - @DisplayName("학습 기록 생성 - 일시정지 포함") + @DisplayName("학습 기록 생성 - 일시정지 있음") void t2() throws Exception { - ResultActions resultActions = mvc.perform(post("/api/records") + ResultActions resultActions = mvc.perform(post("/api/plans/records") .header("Authorization", "Bearer faketoken") .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -157,7 +158,7 @@ void t2() throws Exception { "planId": %d, "startTime": "2025-10-03T14:00:00", "endTime": "2025-10-03T17:00:00", - "duration": 7200", + "duration": "7500", "pauseInfos": [ { "pausedAt": "2025-10-03T15:00:00", @@ -167,7 +168,7 @@ void t2() throws Exception { "restartAt": "2025-10-03T16:10:00" },{ "pausedAt": "2025-10-03T16:50:00", - "restartAt": "2025-10-03T17:00:00" + "restartAt": "2025-10-03T16:55:00" } ] } @@ -178,7 +179,102 @@ void t2() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.startTime").value("2025-10-03T14:00:00")) - .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))); + .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))) + .andExpect(jsonPath("$.data.duration").value("7500")); } - + + @Test + @DisplayName("학습 기록 생성 - 일시정지 + 마지막 재시작 없음") + void t2_1() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T14:00:00", + "endTime": "2025-10-03T17:00:00", + "duration": "7200", + "pauseInfos": [ + { + "pausedAt": "2025-10-03T15:00:00", + "restartAt": "2025-10-03T15:30:00" + },{ + "pausedAt": "2025-10-03T15:50:00", + "restartAt": "2025-10-03T16:10:00" + },{ + "pausedAt": "2025-10-03T16:50:00" + } + ] + } + """.formatted(dailyPlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.startTime").value("2025-10-03T14:00:00")) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))) + .andExpect(jsonPath("$.data.duration").value("7200")); + } + + @Test + @DisplayName("학습 기록 조회 - 일시정지 없음") + void t3() throws Exception { + mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T10:00:00", + "endTime": "2025-10-03T12:00:00", + "duration": 7200, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andExpect(status().isOk()); + + // 조회 + ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-03") + .header("Authorization", "Bearer faketoken")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("일별 학습 기록 조회 성공")) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].planId").value(singlePlan.getId())) + .andExpect(jsonPath("$.data[0].duration").value(7200)); + } + + @Test + @DisplayName("학습 기록 조회 - 전날 밤~당일 새벽 기록의 경우") + void t4() throws Exception { + mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-02T23:00:00", + "endTime": "2025-10-03T02:00:00", + "duration": 10800, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andExpect(status().isOk()); + + // 10월 2일로 조회 (04:00 기준이므로 이 기록이 포함되어야 함) + ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-02") + .header("Authorization", "Bearer faketoken")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) + .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T02:00:00")); + } } \ No newline at end of file From b72d5f4e88acb3847c4b93ebffd195d62a82d758 Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 19:54:08 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat,test:=20duration=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80=20+=20=EC=9D=B4=ED=8B=80=20?= =?UTF-8?q?=EA=B1=B8=EC=B9=9C=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../record/service/StudyRecordService.java | 4 ++ .../controller/StudyRecordControllerTest.java | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index c77a8215..008985e3 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -64,6 +64,7 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD // 일시정지 시간 범위 검증 validateTimeRange(pausedAt, restartAt); + // 일시정지가 학습 시간 내에 있는지 검증 validatePauseInStudyRange( request.getStartTime(), @@ -85,6 +86,9 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD pauseInfos ); + // 프론트 Duration과 백엔드 Duration 비교 검증 + validateDurationDifference(request.getDuration(), record.getDuration()); + // 저장 StudyRecord saved = studyRecordRepository.save(record); return StudyRecordResponseDto.from(saved); diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index 2630299d..92b51656 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -277,4 +277,46 @@ void t4() throws Exception { .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T02:00:00")); } + + @Test + @DisplayName("학습 기록 조회 - 전날 밤~당일 오전 4시 이후 끝난 기록의 경우") + void t5() throws Exception { + mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-02T23:00:00", + "endTime": "2025-10-03T05:00:00", + "duration": 25200, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andExpect(status().isOk()); + // 10월 2일 조회 + ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-02") + .header("Authorization", "Bearer faketoken")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) + .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T05:00:00")) + .andExpect(jsonPath("$.data[0].duration").value(21600)); + + // 10월 3일 조회 + resultActions = mvc.perform(get("/api/plans/records?date=2025-10-03") + .header("Authorization", "Bearer faketoken")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) + .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T02:00:00")) + .andExpect(jsonPath("$.data[0].duration").value(21600)); + } + } \ No newline at end of file From cb6409b33170e8df2df38ddea42d9964b4feefdb Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 19:54:51 +0900 Subject: [PATCH 12/13] =?UTF-8?q?test:=20=EC=98=A4=ED=83=80=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/record/controller/StudyRecordControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index 92b51656..ad1aca5f 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -289,7 +289,7 @@ void t5() throws Exception { "planId": %d, "startTime": "2025-10-02T23:00:00", "endTime": "2025-10-03T05:00:00", - "duration": 25200, + "duration": 21600, "pauseInfos": [] } """.formatted(singlePlan.getId()))) From 33dfc47d33d54180a92bd89b42be127546fc92cc Mon Sep 17 00:00:00 2001 From: KSH0326 Date: Sat, 4 Oct 2025 20:04:51 +0900 Subject: [PATCH 13/13] =?UTF-8?q?test:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=952..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/record/controller/StudyRecordControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index ad1aca5f..906572f9 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -315,7 +315,7 @@ void t5() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data", hasSize(1))) .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) - .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T02:00:00")) + .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T05:00:00")) .andExpect(jsonPath("$.data[0].duration").value(21600)); }