diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java index 052584e5..e06bcf67 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,5 +1,8 @@ package com.Timo.Timo.domain.timer.controller; +import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.docs.TimerActiveControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; @@ -30,7 +33,7 @@ @RequestMapping("/api/v1") @RequiredArgsConstructor public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs, - TimerActiveControllerDocs { + TimerActiveControllerDocs, TimerCompleteControllerDocs, TimerStopControllerDocs { private final TimerService timerService; @@ -82,4 +85,30 @@ public ResponseEntity> getActiveTimer( return ResponseEntity.ok() .body(BaseResponse.onSuccess(successCode, response)); } + + @Override + @PatchMapping("/timers/{timerId}/complete") + public ResponseEntity> completeTimer( + @PathVariable Long timerId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerFinishResponse response = timerService.completeTimer(userId, timerId); + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_COMPLETED, response)); + } + + @Override + @PatchMapping("/timers/{timerId}/stop") + public ResponseEntity> stopTimer( + @PathVariable Long timerId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerFinishResponse response = timerService.stopTimer(userId, timerId); + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STOPPED, response)); + } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java new file mode 100644 index 00000000..7c83b01e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -0,0 +1,79 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerCompleteControllerDocs { + + @Operation( + summary = "타이머 시간 완료", + description = """ + 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. + 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) + 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 + aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타이머 완료 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 종료된 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> completeTimer( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java new file mode 100644 index 00000000..37566810 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java @@ -0,0 +1,79 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerStopControllerDocs { + + @Operation( + summary = "타이머 종료", + description = """ + 사용자의 요청으로 타이머를 종료합니다. + 종료 시각 기록, 실제 수행 시간 계산 (status → STOPPED) + 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 + aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타이머 종료 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 종료된 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> stopTimer( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java new file mode 100644 index 00000000..893a983d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; + +public record TimerFinishResponse( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds, + String aiFeedback +) { + public static TimerFinishResponse of(TimerRecord timerRecord) { + return new TimerFinishResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getActualSeconds(), + timerRecord.getAiFeedback() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java index c61f861c..3deeb35d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -95,6 +95,16 @@ public void resume() { this.status = TimerStatus.RUNNING; } + public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds, String aiFeedback) { + if (isFinished()) { + throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); + } + this.status = targetStatus; + this.endedAt = endedAt; + this.actualSeconds = actualSeconds; + this.aiFeedback = aiFeedback; + } + public boolean isRunning() { return this.status == TimerStatus.RUNNING; } diff --git a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java index 69549ed3..87eb7e89 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -11,7 +11,8 @@ public enum TimerErrorCode implements BaseErrorCode { TIMER_NOT_FOUND(HttpStatus.NOT_FOUND, "TIMER_404", "존재하지 않는 타이머입니다."), TIMER_ALREADY_RUNNING(HttpStatus.CONFLICT, "TIMER_409", "이미 실행 중인 타이머가 있습니다."), - TIMER_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "요청을 처리할 수 없는 타이머 상태입니다."); + TIMER_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "요청을 처리할 수 없는 타이머 상태입니다."), + TIMER_ALREADY_FINISHED(HttpStatus.CONFLICT, "TIMER_409", "이미 종료된 타이머입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java index a3ffaf3f..5f392476 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -13,7 +13,10 @@ public enum TimerSuccessCode implements BaseSuccessCode { TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), TIMER_ACTIVE_FOUND(HttpStatus.OK, "TIMER_200", "실행 중인 타이머를 조회했습니다."), TIMER_ACTIVE_NOT_FOUND(HttpStatus.OK, "TIMER_200", "실행 중인 타이머가 없습니다."), - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); + TIMER_COMPLETED(HttpStatus.OK, "TIMER_200", "타이머가 완료되었습니다."), + TIMER_STOPPED(HttpStatus.OK, "TIMER_200", "타이머가 종료되었습니다."), + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + ; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java index b0fe55f9..4385e53f 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -15,12 +15,12 @@ public interface TimerRecordRepository extends JpaRepository Optional findByUserIdAndStatusIn(Long userId, List statuses); + boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select t from TimerRecord t where t.id = :id") Optional findByIdForUpdate(@Param("id") Long id); - boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); - @Query(""" select coalesce(sum(tr.actualSeconds), 0) as totalRecordSeconds, diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index 3345cd13..60f69255 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.service; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; import com.Timo.Timo.domain.timer.entity.TimerRecord; @@ -143,4 +144,37 @@ private TodoInstance getOrCreateInstance(Todo todo, LocalDate date) { public boolean hasActiveTimer(Long todoId) { return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); } -} \ No newline at end of file + + @Transactional + public TimerFinishResponse completeTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.COMPLETED); + } + + @Transactional + public TimerFinishResponse stopTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.STOPPED); + } + + private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + LocalDateTime now = LocalDateTime.now(); + int actualSeconds = calculateElapsedSeconds(timerId, now); + + timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .ifPresent(activeSession -> activeSession.pause(now)); + + timerRecord.finish(targetStatus, now, actualSeconds, null); + + TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); + instance.stopTimer(); + instance.markCompleted(); + + return TimerFinishResponse.of(timerRecord); + } +}