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
@@ -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;
Expand Down Expand Up @@ -30,7 +33,7 @@
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs,
TimerActiveControllerDocs {
TimerActiveControllerDocs, TimerCompleteControllerDocs, TimerStopControllerDocs {

private final TimerService timerService;

Expand Down Expand Up @@ -82,4 +85,30 @@ public ResponseEntity<BaseResponse<TimerActiveResponse>> getActiveTimer(
return ResponseEntity.ok()
.body(BaseResponse.onSuccess(successCode, response));
}

@Override
@PatchMapping("/timers/{timerId}/complete")
public ResponseEntity<BaseResponse<TimerFinishResponse>> 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<BaseResponse<TimerFinishResponse>> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<TimerFinishResponse>> completeTimer(
@Parameter(description = "타이머 기록 ID", example = "10")
@PathVariable Long timerId,
@Parameter(hidden = true) CustomUserDetails userDetails
);
}
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<TimerFinishResponse>> stopTimer(
@Parameter(description = "타이머 기록 ID", example = "10")
@PathVariable Long timerId,
@Parameter(hidden = true) CustomUserDetails userDetails
);
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ public interface TimerRecordRepository extends JpaRepository<TimerRecord, Long>

Optional<TimerRecord> findByUserIdAndStatusIn(Long userId, List<TimerStatus> statuses);

boolean existsByTodo_IdAndStatusIn(Long todoId, List<TimerStatus> statuses);

@Lock(LockModeType.PESSIMISTIC_WRITE)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

락 좋습니다아

@Query("select t from TimerRecord t where t.id = :id")
Optional<TimerRecord> findByIdForUpdate(@Param("id") Long id);

boolean existsByTodo_IdAndStatusIn(Long todoId, List<TimerStatus> statuses);

@Query("""
select
coalesce(sum(tr.actualSeconds), 0) as totalRecordSeconds,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -143,4 +144,37 @@ private TodoInstance getOrCreateInstance(Todo todo, LocalDate date) {
public boolean hasActiveTimer(Long todoId) {
return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS);
}
}

@Transactional
public TimerFinishResponse completeTimer(Long userId, Long timerId) {
return finishTimer(userId, timerId, TimerStatus.COMPLETED);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

완료와 수동 종료의 공통 로직을 finishTimer()로 묶고 최종 상태만 전달하도록 구성 조은것 같아용

}

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3) RUNNING 상태의 타이머를 완료하거나 종료할 때 활성 TimerSession의 pausedAt은 갱신되지 않는 것 같아용
그러면 TimerRecord는 종료 상태인데 세션은 여전히 활성 상태(pausedAt=null)로 남고 이후 활성 세션 조회나 경과 시간 재계산 시 종료 이후 시간까지 포함될 수 있지 않을까 싶습니당
그래서 타이머 종료 시 활성 세션도 now로 닫아주면 좋을 것 같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀주신대로 actualSeconds 계산 직후, 활성 세션이 있으면 종료 시각(now)으로 pausedAt을 채우도록 수정했습니다!! PAUSED 상태에서 종료하는 경우는 이미 활성 세션이 없는 상태라 별도 처리 없이 자연스럽게 넘어가게 하였습니다!!

감사합니다아:)


TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate());
instance.stopTimer();
instance.markCompleted();

return TimerFinishResponse.of(timerRecord);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}