From 4da12b6e1766bec8175ba5686657b75528efe7ee Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:51:29 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C/=EC=A2=85=EB=A3=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=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 --- .../Timo/domain/timer/controller/TimerController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 f19e1fb..c0ec681 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,16 +1,16 @@ package com.Timo.Timo.domain.timer.controller; -import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; -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.TimerCompleteControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; import com.Timo.Timo.domain.timer.dto.request.TimerExtendRequest; import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; 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.exception.TimerSuccessCode; From f989816c67f13c0143997a6e6a8f1e959aebf9d6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 17:29:30 +0900 Subject: [PATCH 02/21] chore: trigger CodeRabbit review From fa801801668c67387ae0b6ebd6a03b06e1130b74 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 12:58:54 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/docs/AiFeedbackDocs.java | 93 +++++++++++++++++++ .../Timo/Timo/domain/ai/docs/AiTodoDocs.java | 1 - 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java new file mode 100644 index 0000000..9b7c132 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java @@ -0,0 +1,93 @@ +package com.Timo.Timo.domain.ai.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; +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; + +public interface AiFeedbackDocs { + + @Operation( + tags = "AI Feedback", + summary = "AI 투두 수행 피드백 생성", + description = """ + 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. + + 서버는 투두 ID로 투두 정보와 가장 최근 실제 소요시간을 조회합니다. + 1. 이번 태스크의 예상 소요 시간과 실제 소요 시간 + 2. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 + 3. 같은 태그의 최근 실제 소요시간 기록 + + Gemini는 현재 결과 관찰, 과거 패턴 해석, 다음 행동 추천을 1~2문장으로 압축해 반환합니다. + 비슷한 투두명 기록을 우선 참고하고, 없으면 같은 태그 기록을 참고합니다. + 둘 다 없으면 이번 태스크의 연장 또는 조기 종료 여부를 기준으로 피드백합니다. + RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "AI 투두 수행 피드백 생성 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "투두 ID가 누락되었거나 형식이 올바르지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + 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 = "429", + description = "AI 피드백 요청 횟수 제한을 초과한 경우", + 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> createFeedback( + @Parameter(hidden = true) CustomUserDetails userDetails, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "피드백을 생성할 투두 ID", + content = @Content(schema = @Schema(implementation = CreateTodoFeedbackRequest.class)) + ) + CreateTodoFeedbackRequest request + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java index 1972f93..7dcf167 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -28,7 +28,6 @@ public interface AiTodoDocs { 3. 기록이 없으면 현재 투두명 기준 Gemini는 위 기록을 종합해 예상 소요 시간을 생성합니다. - 호출량과 토큰 사용량을 줄이기 위해 각 기록은 최근 순 최대 5개씩만 전달합니다. RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. """ ) From b08937b0bc136acc2d9b42d9833964047ad3eb2f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 12:59:04 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20AI=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/exception/AiSuccessCode.java | 5 +++-- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java index 0be3341..9f12217 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java @@ -11,8 +11,9 @@ @RequiredArgsConstructor public enum AiSuccessCode implements BaseSuccessCode { - DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."); + DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."), + TODO_FEEDBACK_CREATED(HttpStatus.OK, "AI 투두 피드백을 생성했습니다."); private final HttpStatus httpStatus; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java index 8673ab4..533d105 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,7 +16,7 @@ public enum TodoErrorCode implements BaseErrorCode { NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "TODO_400", "수정할 필드가 없습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "TODO_400", "isCompleted는 필수입니다."), INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다."), TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBTASK_404", "존재하지 않는 하위 태스크입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), From 836667fa18aa75a9b9ea0ad00ab312827e631643 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:00:03 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20request&response=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 --- .../ai/dto/request/CreateTodoFeedbackRequest.java | 12 ++++++++++++ .../ai/dto/response/CreateTodoFeedbackResponse.java | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java new file mode 100644 index 0000000..d505db7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.ai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record CreateTodoFeedbackRequest( + @NotNull + @Positive + @Schema(description = "피드백을 생성할 투두 ID") + Long todoId +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java new file mode 100644 index 0000000..00a5abf --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateTodoFeedbackResponse( + @Schema(description = "AI 투두 수행 피드백") + String feedback +) {} \ No newline at end of file From 373f094972e8454113015d7f65ca860590285b5f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:00:36 +0900 Subject: [PATCH 06/21] feat: prompt --- .../ai/prompt/TodoFeedbackPromptBuilder.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java new file mode 100644 index 0000000..19dcdbd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java @@ -0,0 +1,106 @@ +package com.Timo.Timo.domain.ai.prompt; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; + +@Component +public class TodoFeedbackPromptBuilder { + + public String build( + TodoFeedbackSource source, + List similarTitleHistories, + List recentTagHistories + ) { + return """ + 너는 Timo 투두 앱에서 사용자의 실제 작업 시간을 분석해 짧은 피드백을 작성하는 시간 계획 코치야. + 이번 태스크의 예상 소요 시간과 실제 소요 시간을 비교하고, 과거 기록을 참고해 다음 계획을 제안해. + + 피드백 판단 구조: + 1. 현재 결과 관찰 + - 이번 태스크가 예상 시간보다 길어졌는지, 거의 맞았는지, 일찍 끝났는지 짧게 분석해. + 2. 패턴 해석 + - 패턴 근거는 아래 우선순위로 선택해. + - 1순위: 비슷한 투두명 실제 소요시간 기록 + - 2순위: 같은 태그의 최근 실제 소요시간 기록 + - 3순위: 기록이 없으면 이번 태스크의 연장 또는 조기 종료 여부 + 3. 다음 행동 추천 + - 다음에 예상 시간을 어떻게 잡으면 좋을지 제안해. + + 규칙: + - 응답은 반드시 JSON 객체 하나만 반환해. + - feedback은 한국어 1~2문장으로 자연스럽게 작성해. + - feedback은 현재 결과 관찰, 패턴 해석, 다음 행동 추천을 압축해서 포함해. + - 실제 기록에 없는 패턴은 만들지 마. + - 기록이 부족하면 부족하다고 길게 말하지 말고, 이번 결과 기준으로만 제안해. + - 다음 예상 시간은 분 단위로 제안해. + + 반환해야 할 응답 JSON 형식: + { + "feedback": "이번 작업은 예상보다 조금 길어졌어요. 비슷한 Work 태그의 작업들도 살짝 길어지는 편이라, 다음에는 60분 정도로 잡아보면 좋아요." + } + + 아래 데이터는 응답에 포함할 값이 아니라 피드백 생성에만 참고할 입력 데이터야. + + 입력 데이터 - 이번 태스크: + { + "title": "%s", + "tagName": "%s", + "estimatedMinutes": %d, + "actualMinutes": %d + } + + 입력 데이터 - 비슷한 투두명 실제 소요시간 기록: + %s + + 입력 데이터 - 같은 태그의 최근 실제 소요시간 기록: + %s + """.formatted( + escapeJsonString(source.title()), + escapeJsonString(source.tagName()), + toMinutes(source.estimatedSeconds()), + toMinutes(source.actualSeconds()), + formatHistories(similarTitleHistories), + formatHistories(recentTagHistories) + ); + } + + private String formatHistories(List histories) { + if (histories == null || histories.isEmpty()) { + return "[]"; + } + + return histories.stream() + .map(history -> """ + {"title":"%s","date":"%s","actualMinutes":%d} + """.formatted( + escapeJsonString(history.title()), + history.date(), + toMinutes(history.actualSeconds()) + ).trim()) + .toList() + .toString(); + } + + private int toMinutes(Integer durationSeconds) { + if (durationSeconds == null || durationSeconds <= 0) { + return 0; + } + return Math.max(1, (int)Math.round(durationSeconds / 60.0)); + } + + private String escapeJsonString(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " "); + } +} \ No newline at end of file From 72eb2b8f729e8c65e850e874fcec8f82a91db404 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:01:13 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20AI=20feedback=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=8F=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=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 --- .../ai/controller/AiTodoController.java | 33 +++++++-- .../ai/repository/AiTodoQueryRepository.java | 42 +++++++++++- .../Timo/domain/ai/service/AiTodoService.java | 67 ++++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java index 661b4c2..ad0fa16 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -7,8 +7,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.Timo.Timo.domain.ai.docs.AiFeedbackDocs; import com.Timo.Timo.domain.ai.docs.AiTodoDocs; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; import com.Timo.Timo.domain.ai.exception.AiSuccessCode; import com.Timo.Timo.domain.ai.service.AiTodoService; @@ -22,15 +25,15 @@ @Slf4j @RestController -@RequestMapping("/api/v1/todos") +@RequestMapping("/api/v1") @RequiredArgsConstructor @Tag(name = "AI Todo", description = "투두 AI API") -public class AiTodoController implements AiTodoDocs { +public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/recommend-duration") + @PostMapping("/todos/recommend-duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request @@ -50,4 +53,26 @@ public ResponseEntity> recommendDuration BaseResponse.onSuccess(AiSuccessCode.DURATION_RECOMMENDED, response) ); } -} + + @Override + @PostMapping("/ai/feedback") + public ResponseEntity> createFeedback( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody CreateTodoFeedbackRequest request + ) { + log.info( + "AI todo feedback API called. userId={}, todoId={}", + userDetails.getUserId(), + request.todoId() + ); + + CreateTodoFeedbackResponse response = aiTodoService.createFeedback( + userDetails.getUserId(), + request + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(AiSuccessCode.TODO_FEEDBACK_CREATED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index 9f9cbfa..6002f00 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; @@ -19,6 +20,45 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; + public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { + List rows = entityManager.createNativeQuery(""" + select + t.title, + t.tag_id, + tag.name, + t.duration_seconds, + coalesce(( + select tr.actual_seconds + from timer_records tr + where tr.todo_id = t.id + and tr.user_id = :userId + and tr.actual_seconds is not null + order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + limit 1 + ), 0) + from todos t + left join tags tag on tag.id = t.tag_id + where t.id = :todoId + and t.user_id = :userId + """) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .getResultList(); + + if (rows.isEmpty()) { + return null; + } + + Object[] row = (Object[])rows.get(0); + return new TodoFeedbackSource( + (String)row[0], + toLong(row[1]), + (String)row[2], + toInteger(row[3]), + toInteger(row[4]) + ); + } + public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, @@ -123,4 +163,4 @@ private LocalDate toLocalDate(Object value) { } return LocalDate.parse(value.toString()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index dd4a415..c8acb06 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -5,10 +5,18 @@ import org.springframework.stereotype.Service; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; import com.Timo.Timo.domain.ai.dto.response.GeminiDurationRecommendation; +import com.Timo.Timo.domain.ai.dto.response.GeminiTodoFeedback; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; +import com.Timo.Timo.domain.ai.prompt.TodoFeedbackPromptBuilder; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -24,7 +32,9 @@ public class AiTodoService { private static final int TOKEN_ESTIMATE_CHAR_DIVISOR = 4; private final AiTodoHistoryService historyService; + private final AiTodoQueryRepository queryRepository; private final TodoDurationPromptBuilder promptBuilder; + private final TodoFeedbackPromptBuilder feedbackPromptBuilder; private final GeminiService geminiService; private final ObjectMapper objectMapper; private final AiRequestRateLimiter rateLimiter; @@ -58,6 +68,39 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio return validate(recommendation); } + public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedbackRequest request) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, request.todoId()); + if (source == null) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + AiTodoHistories histories = historyService.findHistories( + userId, + source.title(), + source.tagId(), + today, + HISTORY_LIMIT + ); + + String prompt = feedbackPromptBuilder.build( + source, + histories.similarTitleHistories(), + histories.recentTagHistories() + ); + rateLimiter.validate(userId, estimateTokenCost(prompt)); + + log.info( + "AI todo feedback histories loaded. similarTitle={}, recentTag={}", + histories.similarTitleHistories().size(), + histories.recentTagHistories().size() + ); + + String geminiJson = geminiService.generateJson(prompt); + GeminiTodoFeedback feedback = parseFeedback(geminiJson); + return validate(feedback); + } + private GeminiDurationRecommendation parseRecommendation(String geminiJson) { try { return objectMapper.readValue( @@ -69,6 +112,17 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { } } + private GeminiTodoFeedback parseFeedback(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiTodoFeedback.class + ); + } catch (Exception exception) { + throw new IllegalStateException("Failed to parse Gemini todo feedback.", exception); + } + } + private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) { if (recommendation == null || recommendation.recommendedMinutes() == null @@ -81,6 +135,17 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen return new RecommendDurationResponse(recommendedMinutes); } + private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { + if (feedback == null + || feedback.feedback() == null + || feedback.feedback().isBlank() + ) { + throw new IllegalArgumentException("Gemini feedback has missing fields."); + } + + return new CreateTodoFeedbackResponse(feedback.feedback().trim()); + } + private int normalizeMinutes(int minutes) { return Math.max(1, minutes); } @@ -99,4 +164,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} +} \ No newline at end of file From 34f894b1da7a14ec6ad42ed76b500808f7528642 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:01:30 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AF=B8=EB=82=98?= =?UTF-8?q?=EC=9D=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java | 9 +++++++++ .../Timo/domain/ai/dto/response/GeminiTodoFeedback.java | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java new file mode 100644 index 0000000..a1b99ef --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.dto; + +public record TodoFeedbackSource( + String title, + Long tagId, + String tagName, + Integer estimatedSeconds, + Integer actualSeconds +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java new file mode 100644 index 0000000..3dfe710 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java @@ -0,0 +1,3 @@ +package com.Timo.Timo.domain.ai.dto.response; + +public record GeminiTodoFeedback(String feedback) {} \ No newline at end of file From d346ba28032051cce59671a9a6107edc5bdcd1a6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 14:32:51 +0900 Subject: [PATCH 09/21] =?UTF-8?q?chore:=20=EA=B3=B5=EB=B0=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java | 2 +- .../Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java | 2 +- .../com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java | 2 +- .../java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java | 3 +-- .../com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java | 2 +- .../java/com/Timo/Timo/domain/ai/service/GeminiService.java | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java index 7dcf167..9a49e48 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -79,4 +79,4 @@ ResponseEntity> recommendDuration( ) RecommendDurationRequest request ); -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java index 5d791f4..b1a5fcb 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -89,4 +89,4 @@ private String escapeJsonString(String value) { .replace("\r", " ") .replace("\t", " "); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java index edc5410..e3a5a94 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java @@ -136,4 +136,4 @@ private long secondsUntilNextDay() { LocalDateTime nextDay = now.toLocalDate().plusDays(1).atStartOfDay(); return Math.max(1, Duration.between(now, nextDay).toSeconds()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java index 8b3cb07..8f90cc0 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -7,5 +7,4 @@ public record AiTodoHistories( List similarTitleHistories, List recentTagHistories -) { -} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java index f608a43..7e5da68 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -43,4 +43,4 @@ public AiTodoHistories findHistories( return new AiTodoHistories(similarTitleHistories, recentTagHistories); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java index add42a8..20b9527 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -92,4 +92,4 @@ private String extractText(String response) { throw new IllegalStateException("Failed to parse Gemini response.", exception); } } -} +} \ No newline at end of file From 31455a9e17602ca82d8cc762192e0a716ced23da Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:35:44 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20AI=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java | 2 +- src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java index 9b7c132..d33b25c 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java @@ -18,7 +18,7 @@ public interface AiFeedbackDocs { @Operation( - tags = "AI Feedback", + tags = "AI", summary = "AI 투두 수행 피드백 생성", description = """ 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java index 9a49e48..330d496 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -18,6 +18,7 @@ public interface AiTodoDocs { @Operation( + tags = "AI", summary = "AI 예상 소요 시간 추천", description = """ 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. From 9c7811fbdc01d683be2fa2056f32880127de5f4a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:36:30 +0900 Subject: [PATCH 11/21] =?UTF-8?q?docs:=20AI=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerCompleteControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7c83b01..bb601ee 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -21,7 +21,7 @@ public interface TimerCompleteControllerDocs { 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 """ ) @ApiResponses({ From 338226ef019021bae5843913c3cbc5036aff4370 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:37:11 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20ai=20todo=20=EC=86=8C=EC=9A=94?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java index ad0fa16..f59b0ac 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -18,7 +18,6 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,13 +26,12 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "AI Todo", description = "투두 AI API") public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/todos/recommend-duration") + @PostMapping("/ai/duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request From f186350c0203787ae69fcd27ff94b35ead3ee3b6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:37:49 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20AI=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20response=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 --- .../domain/timer/docs/TimerStopControllerDocs.java | 2 +- .../Timo/Timo/domain/timer/entity/TimerRecord.java | 5 ++++- .../Timo/domain/timer/service/TimerService.java | 13 ++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) 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 index 3756681..ff6bf60 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java @@ -21,7 +21,7 @@ public interface TimerStopControllerDocs { 사용자의 요청으로 타이머를 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → STOPPED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 """ ) @ApiResponses({ 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 e5d8ed4..a83e71b 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,13 +95,16 @@ public void resume() { this.status = TimerStatus.RUNNING; } - public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds, String aiFeedback) { + public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds) { if (isFinished()) { throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); } this.status = targetStatus; this.endedAt = endedAt; this.actualSeconds = actualSeconds; + } + + public void updateAiFeedback(String aiFeedback) { this.aiFeedback = aiFeedback; } 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 967a5ba..7ce0077 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,5 +1,8 @@ package com.Timo.Timo.domain.timer.service; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; +import com.Timo.Timo.domain.ai.service.AiTodoService; 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.TimerExtendResponse; @@ -42,6 +45,7 @@ public class TimerService { private final TodoRepository todoRepository; private final UserRepository userRepository; private final TodoInstanceRepository todoInstanceRepository; + private final AiTodoService aiTodoService; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -198,12 +202,19 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) .ifPresent(activeSession -> activeSession.pause(now)); - timerRecord.finish(targetStatus, now, actualSeconds, null); + timerRecord.finish(targetStatus, now, actualSeconds); TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); instance.stopTimer(); instance.markCompleted(); + timerRecordRepository.flush(); + CreateTodoFeedbackResponse feedback = aiTodoService.createFeedback( + userId, + new CreateTodoFeedbackRequest(timerRecord.getTodo().getId()) + ); + timerRecord.updateAiFeedback(feedback.feedback()); + return TimerFinishResponse.of(timerRecord); } } \ No newline at end of file From c89ffebeccd534ceafe418faefee4838305902ee Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:41:22 +0900 Subject: [PATCH 14/21] =?UTF-8?q?chore:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java | 2 +- .../Timo/domain/ai/dto/request/RecommendDurationRequest.java | 3 +-- .../java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java index b8c697f..aaa9133 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -6,4 +6,4 @@ public record TodoDurationHistory( String title, Integer actualSeconds, LocalDate date -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java index 56ed02f..a0cb334 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java @@ -12,5 +12,4 @@ public record RecommendDurationRequest( @Schema(description = "투두 태그 ID", example = "1", nullable = true) Long tagId -) { -} +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java index 9ff309a..ad97c78 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -16,4 +16,4 @@ public enum AiErrorCode implements BaseErrorCode { private final HttpStatus httpStatus; private final String code; private final String message; -} +} \ No newline at end of file From 6513ed616a005e227bc1b3cdcbc46540fd6c0a2a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:17:02 +0900 Subject: [PATCH 15/21] =?UTF-8?q?refactor:=20ai=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A4=91=EB=B3=B5=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20api=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/controller/AiTodoController.java | 28 +----- .../Timo/domain/ai/docs/AiFeedbackDocs.java | 93 ------------------- .../request/CreateTodoFeedbackRequest.java | 12 --- .../response/CreateTodoFeedbackResponse.java | 8 -- .../domain/ai/exception/AiSuccessCode.java | 5 +- .../Timo/domain/ai/service/AiTodoService.java | 30 +++--- .../domain/timer/service/TimerService.java | 8 +- 7 files changed, 26 insertions(+), 158 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java index f59b0ac..d9d1b5b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -7,11 +7,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.Timo.Timo.domain.ai.docs.AiFeedbackDocs; import com.Timo.Timo.domain.ai.docs.AiTodoDocs; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; import com.Timo.Timo.domain.ai.exception.AiSuccessCode; import com.Timo.Timo.domain.ai.service.AiTodoService; @@ -26,7 +23,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { +public class AiTodoController implements AiTodoDocs { private final AiTodoService aiTodoService; @@ -52,25 +49,4 @@ public ResponseEntity> recommendDuration ); } - @Override - @PostMapping("/ai/feedback") - public ResponseEntity> createFeedback( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody CreateTodoFeedbackRequest request - ) { - log.info( - "AI todo feedback API called. userId={}, todoId={}", - userDetails.getUserId(), - request.todoId() - ); - - CreateTodoFeedbackResponse response = aiTodoService.createFeedback( - userDetails.getUserId(), - request - ); - - return ResponseEntity.ok( - BaseResponse.onSuccess(AiSuccessCode.TODO_FEEDBACK_CREATED, response) - ); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java deleted file mode 100644 index d33b25c..0000000 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.Timo.Timo.domain.ai.docs; - -import org.springframework.http.ResponseEntity; - -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; -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; - -public interface AiFeedbackDocs { - - @Operation( - tags = "AI", - summary = "AI 투두 수행 피드백 생성", - description = """ - 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. - - 서버는 투두 ID로 투두 정보와 가장 최근 실제 소요시간을 조회합니다. - 1. 이번 태스크의 예상 소요 시간과 실제 소요 시간 - 2. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 - 3. 같은 태그의 최근 실제 소요시간 기록 - - Gemini는 현재 결과 관찰, 과거 패턴 해석, 다음 행동 추천을 1~2문장으로 압축해 반환합니다. - 비슷한 투두명 기록을 우선 참고하고, 없으면 같은 태그 기록을 참고합니다. - 둘 다 없으면 이번 태스크의 연장 또는 조기 종료 여부를 기준으로 피드백합니다. - RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. - """ - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "AI 투두 수행 피드백 생성 성공", - useReturnTypeSchema = true - ), - @ApiResponse( - responseCode = "400", - description = "투두 ID가 누락되었거나 형식이 올바르지 않은 경우", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorDto.class) - ) - ), - @ApiResponse( - responseCode = "401", - description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", - 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 = "429", - description = "AI 피드백 요청 횟수 제한을 초과한 경우", - 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> createFeedback( - @Parameter(hidden = true) CustomUserDetails userDetails, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - required = true, - description = "피드백을 생성할 투두 ID", - content = @Content(schema = @Schema(implementation = CreateTodoFeedbackRequest.class)) - ) - CreateTodoFeedbackRequest request - ); -} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java deleted file mode 100644 index d505db7..0000000 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.Timo.Timo.domain.ai.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record CreateTodoFeedbackRequest( - @NotNull - @Positive - @Schema(description = "피드백을 생성할 투두 ID") - Long todoId -) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java deleted file mode 100644 index 00a5abf..0000000 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.Timo.Timo.domain.ai.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record CreateTodoFeedbackResponse( - @Schema(description = "AI 투두 수행 피드백") - String feedback -) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java index 9f12217..0be3341 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java @@ -11,9 +11,8 @@ @RequiredArgsConstructor public enum AiSuccessCode implements BaseSuccessCode { - DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."), - TODO_FEEDBACK_CREATED(HttpStatus.OK, "AI 투두 피드백을 생성했습니다."); + DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."); private final HttpStatus httpStatus; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index c8acb06..c455092 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -1,14 +1,12 @@ package com.Timo.Timo.domain.ai.service; import java.time.LocalDate; -import java.time.ZoneOffset; +import java.time.ZoneId; import org.springframework.stereotype.Service; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; import com.Timo.Timo.domain.ai.dto.response.GeminiDurationRecommendation; import com.Timo.Timo.domain.ai.dto.response.GeminiTodoFeedback; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; @@ -16,6 +14,9 @@ import com.Timo.Timo.domain.ai.prompt.TodoFeedbackPromptBuilder; import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; import com.Timo.Timo.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +34,7 @@ public class AiTodoService { private final AiTodoHistoryService historyService; private final AiTodoQueryRepository queryRepository; + private final UserRepository userRepository; private final TodoDurationPromptBuilder promptBuilder; private final TodoFeedbackPromptBuilder feedbackPromptBuilder; private final GeminiService geminiService; @@ -40,7 +42,7 @@ public class AiTodoService { private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { - LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate today = getCurrentDate(userId); AiTodoHistories histories = historyService.findHistories( userId, @@ -68,9 +70,9 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio return validate(recommendation); } - public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedbackRequest request) { - LocalDate today = LocalDate.now(ZoneOffset.UTC); - TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, request.todoId()); + public String createFeedback(Long userId, Long todoId) { + LocalDate today = getCurrentDate(userId); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); if (source == null) { throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); } @@ -98,7 +100,13 @@ public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedback String geminiJson = geminiService.generateJson(prompt); GeminiTodoFeedback feedback = parseFeedback(geminiJson); - return validate(feedback); + return validateFeedback(feedback); + } + + private LocalDate getCurrentDate(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return LocalDate.now(ZoneId.of(user.getZoneId())); } private GeminiDurationRecommendation parseRecommendation(String geminiJson) { @@ -135,7 +143,7 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen return new RecommendDurationResponse(recommendedMinutes); } - private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { + private String validateFeedback(GeminiTodoFeedback feedback) { if (feedback == null || feedback.feedback() == null || feedback.feedback().isBlank() @@ -143,7 +151,7 @@ private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { throw new IllegalArgumentException("Gemini feedback has missing fields."); } - return new CreateTodoFeedbackResponse(feedback.feedback().trim()); + return feedback.feedback().trim(); } private int normalizeMinutes(int minutes) { @@ -164,4 +172,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} \ No newline at end of file +} 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 7ce0077..edbf19a 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,7 +1,5 @@ package com.Timo.Timo.domain.timer.service; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; import com.Timo.Timo.domain.ai.service.AiTodoService; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; @@ -209,11 +207,11 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - CreateTodoFeedbackResponse feedback = aiTodoService.createFeedback( + String feedback = aiTodoService.createFeedback( userId, - new CreateTodoFeedbackRequest(timerRecord.getTodo().getId()) + timerRecord.getTodo().getId() ); - timerRecord.updateAiFeedback(feedback.feedback()); + timerRecord.updateAiFeedback(feedback); return TimerFinishResponse.of(timerRecord); } From 56d289fe518ed119988d7c35e84794f0dd57892a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:35:29 +0900 Subject: [PATCH 16/21] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EC=A1=B4?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=ED=95=98=EC=97=AC=20=EC=98=A4=EB=8A=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 49 ++++++++++++------- .../ai/service/AiTodoHistoryService.java | 14 ++++-- .../Timo/domain/ai/service/AiTodoService.java | 26 +++++++--- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index 6002f00..03e1d77 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -1,8 +1,10 @@ package com.Timo.Timo.domain.ai.repository; -import java.sql.Date; import java.sql.Timestamp; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.List; import org.springframework.stereotype.Repository; @@ -62,20 +64,21 @@ order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { Query query = entityManager.createNativeQuery(""" select t.title, tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + coalesce(tr.ended_at, tr.started_at) as recorded_at from todos t join timer_records tr on tr.todo_id = t.id where t.user_id = :userId and tr.user_id = :userId and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and coalesce(tr.ended_at, tr.started_at) < :toExclusive and ( lower(t.title) like lower(concat('%', :title, '%')) or lower(:title) like lower(concat('%', t.title, '%')) @@ -92,47 +95,48 @@ when lower(:title) like lower(concat('%', t.title, '%')) then 2 """) .setParameter("userId", userId) .setParameter("title", title) - .setParameter("today", today) + .setParameter("toExclusive", toExclusive) .setMaxResults(limit); - return toHistories(query.getResultList()); + return toHistories(query.getResultList(), userZoneId); } public List findActualDurationHistoriesByTagId( Long userId, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { Query query = entityManager.createNativeQuery(""" select t.title, tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + coalesce(tr.ended_at, tr.started_at) as recorded_at from todos t join timer_records tr on tr.todo_id = t.id where t.user_id = :userId and tr.user_id = :userId and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and coalesce(tr.ended_at, tr.started_at) < :toExclusive and t.tag_id = :tagId order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc """) .setParameter("userId", userId) .setParameter("tagId", tagId) - .setParameter("today", today) + .setParameter("toExclusive", toExclusive) .setMaxResults(limit); - return toHistories(query.getResultList()); + return toHistories(query.getResultList(), userZoneId); } @SuppressWarnings("unchecked") - private List toHistories(List rows) { + private List toHistories(List rows, ZoneId userZoneId) { return ((List)rows).stream() .map(row -> new TodoDurationHistory( (String)row[0], toInteger(row[1]), - toLocalDate(row[2]) + toUserLocalDate(row[2], userZoneId) )) .toList(); } @@ -151,16 +155,23 @@ private Integer toInteger(Object value) { return ((Number)value).intValue(); } - private LocalDate toLocalDate(Object value) { + private LocalDate toUserLocalDate(Object value, ZoneId userZoneId) { if (value instanceof LocalDate localDate) { return localDate; } - if (value instanceof Date date) { - return date.toLocalDate(); + if (value instanceof LocalDateTime localDateTime) { + return toUserLocalDate(localDateTime, userZoneId); } if (value instanceof Timestamp timestamp) { - return timestamp.toLocalDateTime().toLocalDate(); + return toUserLocalDate(timestamp.toLocalDateTime(), userZoneId); } - return LocalDate.parse(value.toString()); + return toUserLocalDate(LocalDateTime.parse(value.toString()), userZoneId); } -} \ No newline at end of file + + private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDate(); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java index 7e5da68..ffdd362 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.ai.service; -import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; @@ -22,14 +23,16 @@ public AiTodoHistories findHistories( Long userId, String title, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { List similarTitleHistories = aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( userId, title, - today, + toExclusive, + userZoneId, limit ); List recentTagHistories = tagId == null @@ -37,10 +40,11 @@ public AiTodoHistories findHistories( : aiTodoQueryRepository.findActualDurationHistoriesByTagId( userId, tagId, - today, + toExclusive, + userZoneId, limit ); return new AiTodoHistories(similarTitleHistories, recentTagHistories); } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index c455092..0bb21e5 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -1,7 +1,9 @@ package com.Timo.Timo.domain.ai.service; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import org.springframework.stereotype.Service; @@ -42,13 +44,15 @@ public class AiTodoService { private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { - LocalDate today = getCurrentDate(userId); + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); AiTodoHistories histories = historyService.findHistories( userId, request.title(), request.tagId(), - today, + toExclusive, + userZoneId, HISTORY_LIMIT ); @@ -71,7 +75,8 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio } public String createFeedback(Long userId, Long todoId) { - LocalDate today = getCurrentDate(userId); + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); if (source == null) { throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); @@ -81,7 +86,8 @@ public String createFeedback(Long userId, Long todoId) { userId, source.title(), source.tagId(), - today, + toExclusive, + userZoneId, HISTORY_LIMIT ); @@ -103,10 +109,18 @@ public String createFeedback(Long userId, Long todoId) { return validateFeedback(feedback); } - private LocalDate getCurrentDate(Long userId) { + private ZoneId getUserZoneId(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - return LocalDate.now(ZoneId.of(user.getZoneId())); + return ZoneId.of(user.getZoneId()); + } + + private LocalDateTime getTodayToExclusiveUtc(ZoneId userZoneId) { + return LocalDate.now(userZoneId) + .plusDays(1) + .atStartOfDay(userZoneId) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); } private GeminiDurationRecommendation parseRecommendation(String geminiJson) { From 6ee6090cf88026d09a30adfc00200f048c282b0c Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:45:40 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20ai=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/TimerCompleteControllerDocs.java | 1 + .../dto/response/TimerFinishResponse.java | 4 +++- .../domain/timer/service/TimerService.java | 24 ++++++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) 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 index bb601ee..204bc21 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -22,6 +22,7 @@ public interface TimerCompleteControllerDocs { 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 + AI 피드백 생성에 실패해도 타이머 완료는 정상 처리되며 aiFeedback은 null로 반환 """ ) @ApiResponses({ 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 index 893a983..23a7cc4 100644 --- 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 @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.dto.response; import com.Timo.Timo.domain.timer.entity.TimerRecord; +import io.swagger.v3.oas.annotations.media.Schema; public record TimerFinishResponse( Long timerId, @@ -8,6 +9,7 @@ public record TimerFinishResponse( String status, Integer plannedSeconds, Integer actualSeconds, + @Schema(description = "AI 피드백 문구. AI 호출 실패 시 null", nullable = true) String aiFeedback ) { public static TimerFinishResponse of(TimerRecord timerRecord) { @@ -20,4 +22,4 @@ public static TimerFinishResponse of(TimerRecord timerRecord) { timerRecord.getAiFeedback() ); } -} +} \ No newline at end of file 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 edbf19a..298e152 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 @@ -28,9 +28,11 @@ import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -207,12 +209,22 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - String feedback = aiTodoService.createFeedback( - userId, - timerRecord.getTodo().getId() - ); - timerRecord.updateAiFeedback(feedback); + generateAiFeedback(userId, timerRecord); return TimerFinishResponse.of(timerRecord); } -} \ No newline at end of file + + private void generateAiFeedback(Long userId, TimerRecord timerRecord) { + try { + String feedback = aiTodoService.createFeedback(userId, timerRecord.getTodo().getId()); + timerRecord.updateAiFeedback(feedback); + } catch (RuntimeException exception) { + log.warn( + "AI feedback generation failed. timerId={}, userId={}", + timerRecord.getId(), + userId, + exception + ); + } + } +} From 30ad42f4dfdd2079f05c62691c692fca85b4911a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:38:55 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix(ai):url=20=EC=A3=BC=EC=86=8C=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 --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java index d9d1b5b..6d5c84f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -21,14 +21,14 @@ @Slf4j @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/ai") @RequiredArgsConstructor public class AiTodoController implements AiTodoDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/ai/duration") + @PostMapping("/duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request From e0b22dc1a5e39dfdc1618b5b6b0aedd01c18e3da Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:39:54 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix(ai):=20ai=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=EC=9D=80=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) 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 298e152..3fde84b 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 @@ -30,7 +30,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; @Slf4j @Service @@ -46,6 +48,7 @@ public class TimerService { private final UserRepository userRepository; private final TodoInstanceRepository todoInstanceRepository; private final AiTodoService aiTodoService; + private final PlatformTransactionManager transactionManager; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -178,17 +181,38 @@ public void deleteTimersByTodo(Long todoId) { timerRecordRepository.deleteByTodoId(todoId); } - @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) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + FinishedTimer finishedTimer = transactionTemplate.execute(status -> + finishTimerInTransaction(userId, timerId, targetStatus) + ); + + String feedback = generateAiFeedback(userId, finishedTimer.todoId()); + if (feedback != null) { + transactionTemplate.executeWithoutResult(status -> + updateAiFeedback(timerId, feedback) + ); + } + + return new TimerFinishResponse( + finishedTimer.timerId(), + finishedTimer.todoId(), + finishedTimer.status(), + finishedTimer.plannedSeconds(), + finishedTimer.actualSeconds(), + feedback + ); + } + + private FinishedTimer finishTimerInTransaction(Long userId, Long timerId, TimerStatus targetStatus) { TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); @@ -209,22 +233,46 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - generateAiFeedback(userId, timerRecord); - return TimerFinishResponse.of(timerRecord); + return FinishedTimer.from(timerRecord); } - private void generateAiFeedback(Long userId, TimerRecord timerRecord) { + private String generateAiFeedback(Long userId, Long todoId) { try { - String feedback = aiTodoService.createFeedback(userId, timerRecord.getTodo().getId()); - timerRecord.updateAiFeedback(feedback); + return aiTodoService.createFeedback(userId, todoId); } catch (RuntimeException exception) { log.warn( - "AI feedback generation failed. timerId={}, userId={}", - timerRecord.getId(), + "AI feedback generation failed. todoId={}, userId={}", + todoId, userId, exception ); + return null; + } + } + + private void updateAiFeedback(Long timerId, String feedback) { + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + timerRecord.updateAiFeedback(feedback); + } + + private record FinishedTimer( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds + ) { + + private static FinishedTimer from(TimerRecord timerRecord) { + return new FinishedTimer( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getActualSeconds() + ); } } } From 57269bc881c3aea0f2b16a9fdda2248e3ff8fbbc Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:48:27 +0900 Subject: [PATCH 20/21] =?UTF-8?q?feat(ai):=20AI=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20AiErrorCode=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/exception/AiErrorCode.java | 5 ++++- .../Timo/Timo/domain/ai/service/AiTodoService.java | 13 ++++++++----- .../Timo/Timo/domain/ai/service/GeminiService.java | 12 ++++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java index ad97c78..982cc1f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -11,7 +11,10 @@ @RequiredArgsConstructor public enum AiErrorCode implements BaseErrorCode { - AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."); + AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."), + AI_CONFIG_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 설정 정보가 올바르지 않습니다."), + AI_RESPONSE_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답을 처리하는 중 오류가 발생했습니다."), + AI_INVALID_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답 형식이 올바르지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index 0bb21e5..f3f215b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -12,6 +12,7 @@ import com.Timo.Timo.domain.ai.dto.response.GeminiDurationRecommendation; import com.Timo.Timo.domain.ai.dto.response.GeminiTodoFeedback; import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; +import com.Timo.Timo.domain.ai.exception.AiErrorCode; import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; import com.Timo.Timo.domain.ai.prompt.TodoFeedbackPromptBuilder; import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; @@ -130,7 +131,8 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { GeminiDurationRecommendation.class ); } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini duration recommendation.", exception); + log.warn("Failed to parse Gemini duration recommendation.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } @@ -141,7 +143,8 @@ private GeminiTodoFeedback parseFeedback(String geminiJson) { GeminiTodoFeedback.class ); } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini todo feedback.", exception); + log.warn("Failed to parse Gemini todo feedback.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } @@ -149,7 +152,7 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen if (recommendation == null || recommendation.recommendedMinutes() == null ) { - throw new IllegalArgumentException("Gemini recommendation has missing fields."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); @@ -162,7 +165,7 @@ private String validateFeedback(GeminiTodoFeedback feedback) { || feedback.feedback() == null || feedback.feedback().isBlank() ) { - throw new IllegalArgumentException("Gemini feedback has missing fields."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } return feedback.feedback().trim(); @@ -186,4 +189,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java index 20b9527..5a31063 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; +import com.Timo.Timo.domain.ai.exception.AiErrorCode; +import com.Timo.Timo.global.exception.CustomException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,10 +37,10 @@ public class GeminiService { public String generateJson(String prompt) { if (apiKey == null || apiKey.isBlank()) { - throw new IllegalStateException("Gemini API key is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } if (model == null || model.isBlank()) { - throw new IllegalStateException("Gemini model is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } Map request = Map.of( @@ -84,12 +86,14 @@ private String extractText(String response) { .path("text"); if (textNode.isMissingNode() || textNode.asText().isBlank()) { - throw new IllegalStateException("Gemini response text is empty."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } return textNode.asText(); + } catch (CustomException exception) { + throw exception; } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini response.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } } \ No newline at end of file From 91cd60f65631d6f4c056121d7b7513e43ee48f9f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:58:36 +0900 Subject: [PATCH 21/21] =?UTF-8?q?refactor(ai):=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=8B=B0=EB=B8=8C=20=EC=BF=BC=EB=A6=AC=20JPQL=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 --- .../ai/repository/AiTodoQueryRepository.java | 162 ++++++++---------- .../ai/repository/TodoDurationHistoryRow.java | 9 + 2 files changed, 82 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index 03e1d77..adf319d 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -1,6 +1,5 @@ package com.Timo.Timo.domain.ai.repository; -import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; @@ -13,7 +12,6 @@ import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; @Repository @@ -23,41 +21,34 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { - List rows = entityManager.createNativeQuery(""" - select + List sources = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.dto.TodoFeedbackSource( t.title, - t.tag_id, + t.tagId, tag.name, - t.duration_seconds, - coalesce(( - select tr.actual_seconds - from timer_records tr - where tr.todo_id = t.id - and tr.user_id = :userId - and tr.actual_seconds is not null - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - limit 1 - ), 0) - from todos t - left join tags tag on tag.id = t.tag_id + t.durationSeconds, + 0 + ) + from Todo t + left join Tag tag on tag.id = t.tagId where t.id = :todoId - and t.user_id = :userId - """) + and t.user.id = :userId + """, TodoFeedbackSource.class) .setParameter("userId", userId) .setParameter("todoId", todoId) .getResultList(); - if (rows.isEmpty()) { + if (sources.isEmpty()) { return null; } - Object[] row = (Object[])rows.get(0); + TodoFeedbackSource source = sources.get(0); return new TodoFeedbackSource( - (String)row[0], - toLong(row[1]), - (String)row[2], - toInteger(row[3]), - toInteger(row[4]) + source.title(), + source.tagId(), + source.tagName(), + source.estimatedSeconds(), + findLatestActualSeconds(userId, todoId) ); } @@ -68,17 +59,18 @@ public List findActualDurationHistoriesBySimilarTitle( ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - coalesce(tr.ended_at, tr.started_at) as recorded_at - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) < :toExclusive + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive and ( lower(t.title) like lower(concat('%', :title, '%')) or lower(:title) like lower(concat('%', t.title, '%')) @@ -90,15 +82,16 @@ when lower(t.title) like lower(concat('%', :title, '%')) then 1 when lower(:title) like lower(concat('%', t.title, '%')) then 2 else 3 end, - coalesce(tr.ended_at, tr.started_at) desc, + coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc - """) + """, TodoDurationHistoryRow.class) .setParameter("userId", userId) .setParameter("title", title) .setParameter("toExclusive", toExclusive) - .setMaxResults(limit); + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList(), userZoneId); + return toHistories(rows, userZoneId); } public List findActualDurationHistoriesByTagId( @@ -108,70 +101,61 @@ public List findActualDurationHistoriesByTagId( ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - coalesce(tr.ended_at, tr.started_at) as recorded_at - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - and t.tag_id = :tagId - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - """) + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + and t.tagId = :tagId + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, TodoDurationHistoryRow.class) .setParameter("userId", userId) .setParameter("tagId", tagId) .setParameter("toExclusive", toExclusive) - .setMaxResults(limit); + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList(), userZoneId); + return toHistories(rows, userZoneId); + } + + private Integer findLatestActualSeconds(Long userId, Long todoId) { + return entityManager.createQuery(""" + select tr.actualSeconds + from TimerRecord tr + where tr.todo.id = :todoId + and tr.user.id = :userId + and tr.actualSeconds is not null + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, Integer.class) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(0); } - @SuppressWarnings("unchecked") - private List toHistories(List rows, ZoneId userZoneId) { - return ((List)rows).stream() + private List toHistories(List rows, ZoneId userZoneId) { + return rows.stream() .map(row -> new TodoDurationHistory( - (String)row[0], - toInteger(row[1]), - toUserLocalDate(row[2], userZoneId) + row.title(), + row.actualSeconds(), + toUserLocalDate(row.recordedAt(), userZoneId) )) .toList(); } - private Long toLong(Object value) { - if (value == null) { - return null; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return null; - } - return ((Number)value).intValue(); - } - - private LocalDate toUserLocalDate(Object value, ZoneId userZoneId) { - if (value instanceof LocalDate localDate) { - return localDate; - } - if (value instanceof LocalDateTime localDateTime) { - return toUserLocalDate(localDateTime, userZoneId); - } - if (value instanceof Timestamp timestamp) { - return toUserLocalDate(timestamp.toLocalDateTime(), userZoneId); - } - return toUserLocalDate(LocalDateTime.parse(value.toString()), userZoneId); - } - private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { return utcDateTime .atZone(ZoneOffset.UTC) .withZoneSameInstant(userZoneId) .toLocalDate(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java new file mode 100644 index 0000000..ef6d60f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.time.LocalDateTime; + +public record TodoDurationHistoryRow( + String title, + Integer actualSeconds, + LocalDateTime recordedAt +) {} \ No newline at end of file