Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.back.domain.study.record.service.StudyRecordService;
import com.back.global.common.dto.RsData;
import com.back.global.security.user.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
Expand All @@ -18,12 +20,15 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/plans/records")
@Tag(name = "StudyRecord", description = "학습 조회 관련 API")
public class StudyRecordController {
private final StudyRecordService studyRecordService;

// ======================= 생성 ======================
// 학습 기록 생성
@PostMapping
@Operation( summary = "학습 기록 생성",
description = "기록을 생성합니다.")
public ResponseEntity<RsData<StudyRecordResponseDto>> createStudyRecord(
@AuthenticationPrincipal CustomUserDetails user,
@Valid @RequestBody StudyRecordRequestDto request
Expand All @@ -35,6 +40,8 @@ public ResponseEntity<RsData<StudyRecordResponseDto>> createStudyRecord(
// ======================= 조회 ======================
// 일별 학습 기록 조회
@GetMapping
@Operation( summary = "학습 기록 조회",
description = "날짜별 기록을 조회합니다.")
public ResponseEntity<RsData<List<StudyRecordResponseDto>>> getDailyStudyRecord(
@AuthenticationPrincipal CustomUserDetails user,
@RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.study.record.dto;

import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

Expand All @@ -10,10 +11,15 @@
@Getter
@NoArgsConstructor
public class StudyRecordRequestDto {
@NotNull(message = "계획 ID는 필수입니다.")
private Long planId;
@NotNull(message = "방 ID는 필수입니다.")
private Long roomId;
@NotNull(message = "시작 시간은 필수입니다.")
private LocalDateTime startTime;
@NotNull(message = "종료 시간은 필수입니다.")
private LocalDateTime endTime;

private Long duration;
private List<PauseInfoRequestDto> pauseInfos = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,20 @@ public class StudyRecord extends BaseEntity {
private StudyPlan studyPlan;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id")
@JoinColumn(name = "room_id", nullable = false)
private Room room;

// 초 단위
@Column(nullable = false)
private Long duration;

@Column(nullable = false)
private LocalDateTime startTime;

@OneToMany(mappedBy = "studyRecord", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PauseInfo> pauseInfos = new ArrayList<>();

@Column(nullable = false)
private LocalDateTime endTime;

public static StudyRecord create(User user, StudyPlan studyPlan, Room room,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,9 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD
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));
}
// 방 조회 (필수)
Room room = roomRepository.findById(request.getRoomId())
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));

// 일시정지 정보를 엔티티로 생성
List<PauseInfo> pauseInfos = request.getPauseInfos().stream()
Expand Down Expand Up @@ -125,9 +122,9 @@ public List<StudyRecordResponseDto> getStudyRecordsByDate(Long userId, LocalDate
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);
// 오전 0시 기준 하루의 시작과 끝 설정
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.plusDays(1).atStartOfDay();

// 시작~종료 시간을 포함하는 일자의 학습 기록 조회
List<StudyRecord> records = studyRecordRepository
Expand All @@ -151,8 +148,8 @@ private void checkAndNotifyDailyGoalAchievement(Long userId, LocalDate date) {

// 오늘 완료한 계획 개수
int completedCount = 0;
LocalDateTime startOfDay = date.atTime(4, 0);
LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0);
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.plusDays(1).atStartOfDay();

for (StudyPlan plan : todayPlans) {
boolean hasRecord = studyRecordRepository.existsByStudyPlanIdAndDate(
Expand Down Expand Up @@ -200,15 +197,15 @@ private List<StudyPlan> getTodayStudyPlans(Long userId, LocalDate date) {

// ===================== 유틸 =====================
// 시간 범위 검증
private void validateTimeRange(java.time.LocalDateTime startTime, java.time.LocalDateTime endTime) {
private void validateTimeRange(LocalDateTime startTime, LocalDateTime endTime) {
if (startTime.isAfter(endTime) || startTime.isEqual(endTime)) {
throw new CustomException(ErrorCode.INVALID_TIME_RANGE);
}
}

// 일시정지 시간이 학습 시간 내에 있는지 검증
private void validatePauseInStudyRange(java.time.LocalDateTime studyStart, java.time.LocalDateTime studyEnd,
java.time.LocalDateTime pauseStart, java.time.LocalDateTime pauseEnd) {
private void validatePauseInStudyRange(LocalDateTime studyStart, LocalDateTime studyEnd,
LocalDateTime pauseStart, LocalDateTime pauseEnd) {
if (pauseStart.isBefore(studyStart) || pauseEnd.isAfter(studyEnd)) {
throw new CustomException(ErrorCode.INVALID_TIME_RANGE);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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.studyroom.entity.Room;
import com.back.domain.studyroom.repository.RoomRepository;
import com.back.domain.user.entity.Role;
import com.back.domain.user.entity.User;
import com.back.domain.user.entity.UserStatus;
Expand Down Expand Up @@ -53,8 +55,11 @@ class StudyRecordControllerTest {
private StudyPlanRepository studyPlanRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private RoomRepository roomRepository;

private User testUser;
private Room testRoom;
private StudyPlan singlePlan;
private StudyPlan dailyPlan;

Expand All @@ -73,6 +78,7 @@ void setUp() {

singlePlan = createSinglePlan();
dailyPlan = createDailyPlan();
testRoom = createRoom(testUser);
}

private void setupJwtMock(User user) {
Expand Down Expand Up @@ -115,7 +121,23 @@ private StudyPlan createDailyPlan() {
plan.setRepeatRule(repeatRule);

return studyPlanRepository.save(plan);
}

private Room createRoom(User owner) {

testRoom = Room.create(
"코딩테스트 준비",
"알고리즘 문제 풀이 및 코드 리뷰",
false, // 공개방
null, // 비밀번호 없음
20, // 최대 20명
owner,
null, // 테마 없음
true // WebRTC 활성화
);

testRoom = roomRepository.save(testRoom);
return testRoom;
}

@Test
Expand All @@ -127,12 +149,13 @@ void t1() throws Exception {
.content("""
{
"planId": %d,
"roomId": %d,
"startTime": "2025-10-03T10:00:00",
"endTime": "2025-10-03T12:00:00",
"duration": 7200,
"pauseInfos": []
}
""".formatted(singlePlan.getId())))
""".formatted(singlePlan.getId(), testRoom.getId())))
.andDo(print());

resultActions
Expand All @@ -142,6 +165,7 @@ void t1() throws Exception {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("학습 기록이 생성되었습니다."))
.andExpect(jsonPath("$.data.planId").value(singlePlan.getId()))
.andExpect(jsonPath("$.data.roomId").value(testRoom.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))
Expand All @@ -156,6 +180,7 @@ void t2() throws Exception {
.content("""
{
"planId": %d,
"roomId": %d,
"startTime": "2025-10-03T14:00:00",
"endTime": "2025-10-03T17:00:00",
"duration": "7500",
Expand All @@ -172,7 +197,7 @@ void t2() throws Exception {
}
]
}
""".formatted(dailyPlan.getId())))
""".formatted(singlePlan.getId(), testRoom.getId())))
.andDo(print());

resultActions
Expand All @@ -192,6 +217,7 @@ void t2_1() throws Exception {
.content("""
{
"planId": %d,
"roomId": %d,
"startTime": "2025-10-03T14:00:00",
"endTime": "2025-10-03T17:00:00",
"duration": "7200",
Expand All @@ -207,7 +233,7 @@ void t2_1() throws Exception {
}
]
}
""".formatted(dailyPlan.getId())))
""".formatted(singlePlan.getId(), testRoom.getId())))
.andDo(print());

resultActions
Expand All @@ -227,12 +253,13 @@ void t3() throws Exception {
.content("""
{
"planId": %d,
"roomId": %d,
"startTime": "2025-10-03T10:00:00",
"endTime": "2025-10-03T12:00:00",
"duration": 7200,
"pauseInfos": []
}
""".formatted(singlePlan.getId())))
""".formatted(singlePlan.getId(), testRoom.getId())))
.andExpect(status().isOk());

// 조회
Expand All @@ -250,49 +277,21 @@ void t3() throws Exception {
}

@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"));
}

@Test
@DisplayName("학습 기록 조회 - 전날 밤~당일 오전 4시 이후 끝난 기록의 경우")
@DisplayName("학습 기록 조회 - 전날 밤 시작 ~ 당일 끝난 기록의 경우")
void t5() throws Exception {
mvc.perform(post("/api/plans/records")
.header("Authorization", "Bearer faketoken")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"planId": %d,
"roomId": %d,
"startTime": "2025-10-02T23:00:00",
"endTime": "2025-10-03T05:00:00",
"duration": 21600,
"pauseInfos": []
}
""".formatted(singlePlan.getId())))
""".formatted(singlePlan.getId(), testRoom.getId())))
.andExpect(status().isOk());
// 10월 2일 조회
ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-02")
Expand Down