From 0dd0d15fd92c0ea6fbaca424a21cc20dc3827696 Mon Sep 17 00:00:00 2001 From: minjeong Date: Sun, 30 Aug 2026 00:15:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EB=A3=A8=ED=8B=B4=20=EC=99=84=EB=A3=8C=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20completedCount=EA=B0=80=20totalCount=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routine/entity/RoutineExecution.java | 12 +++- .../RoutineExecutionRepository.java | 3 + .../RoutineExecutionCommandServiceImpl.java | 59 ++++++++++++++++--- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/entity/RoutineExecution.java b/src/main/java/com/moru/server/domain/routine/entity/RoutineExecution.java index 80ab4b7..f906068 100644 --- a/src/main/java/com/moru/server/domain/routine/entity/RoutineExecution.java +++ b/src/main/java/com/moru/server/domain/routine/entity/RoutineExecution.java @@ -12,7 +12,13 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter -@Table(name = "routine_execution") +@Table( + name = "routine_execution", + uniqueConstraints = @UniqueConstraint( + name = "uk_routine_execution_routine_date", + columnNames = {"routine_id", "executed_date"} + ) +) public class RoutineExecution extends BaseEntity { @Id @@ -52,6 +58,10 @@ public void fail(Integer durationSecond) { this.durationSecond = durationSecond; } + public void recordActualWakeTime(LocalTime actualWakeTime) { + this.actualWakeTime = actualWakeTime; + } + public void recordInput(String memberInput) { this.memberInput = memberInput; } diff --git a/src/main/java/com/moru/server/domain/routine/repository/RoutineExecutionRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineExecutionRepository.java index 6273cd7..86122a6 100644 --- a/src/main/java/com/moru/server/domain/routine/repository/RoutineExecutionRepository.java +++ b/src/main/java/com/moru/server/domain/routine/repository/RoutineExecutionRepository.java @@ -7,9 +7,12 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; public interface RoutineExecutionRepository extends JpaRepository { + Optional findByRoutine_IdAndExecutedDate(Long routineId, LocalDate executedDate); + @Query(""" select distinct re.executedDate from RoutineExecution re where re.routine.routineGroup.member.id = :memberId diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java index e7d986a..c642648 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java @@ -49,14 +49,39 @@ public RoutineExecutionResponseDTO.RoutineExecutionResultRes saveExecutionResult throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); } - RoutineExecution routineExecution = RoutineExecutionConverter.toEntity(req, routine); - routineExecutionRepository.save(routineExecution); + RoutineExecution routineExecution = routineExecutionRepository + .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) + .map(existing -> applyExecutionResult(existing, req)) + .orElseGet(() -> routineExecutionRepository.save( + RoutineExecutionConverter.toEntity(req, routine) + )); return RoutineExecutionConverter.toResponse(routineExecution); }) ); } + private RoutineExecution applyExecutionResult( + RoutineExecution existing, + RoutineExecutionRequestDTO.RoutineExecutionResultReq req + ) { + if (Boolean.TRUE.equals(req.isCompleted())) { + existing.complete(req.durationSecond()); + } else { + existing.fail(req.durationSecond()); + } + if (req.memberInput() != null) { + existing.recordInput(req.memberInput()); + } + if (req.aiResponse() != null) { + existing.recordAiResponse(req.aiResponse()); + } + if (req.actualWakeTime() != null) { + existing.recordActualWakeTime(req.actualWakeTime()); + } + return existing; + } + @Override public RoutineExecutionResponseDTO.AiResponseRes judgeUserResponse(Long memberId, RoutineExecutionRequestDTO.AiResponseReq req) { @@ -77,8 +102,7 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( Routine routine = routineRepository.findWithGroupById(req.routineId()) .orElseThrow(() -> new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND)); - - if(!routine.getRoutineGroup().isOwnedBy(memberId)){ + if (!routine.getRoutineGroup().isOwnedBy(memberId)) { throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); } @@ -88,18 +112,37 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( throw new BusinessException(ErrorStatus.AI_JUDGE_FAILED); } - if(dto.shouldProceed()){ - RoutineExecution routineExecution = RoutineExecutionConverter.toEntity(req,routine,dto.aiResponse()); - routineExecutionRepository.save(routineExecution); + if (dto.shouldProceed()) { + routineExecutionRepository + .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) + .map(existing -> applyJudgeResult(existing, req, dto.aiResponse())) + .orElseGet(() -> routineExecutionRepository.save( + RoutineExecutionConverter.toEntity(req, routine, dto.aiResponse()) + )); } - return RoutineExecutionResponseDTO.AiResponseRes.builder() .aiResponse(dto.aiResponse()) .shouldProceed(dto.shouldProceed()) .build(); } + private RoutineExecution applyJudgeResult( + RoutineExecution existing, + RoutineExecutionRequestDTO.AiResponseReq req, + String aiResponse + ) { + existing.complete(req.durationSecond()); + existing.recordAiResponse(aiResponse); + if (req.memberInput() != null) { + existing.recordInput(req.memberInput()); + } + if (req.actualWakeTime() != null) { + existing.recordActualWakeTime(req.actualWakeTime()); + } + return existing; + } + } From 1579e80b6b02371e39c74c829a76581fb54b435f Mon Sep 17 00:00:00 2001 From: minjeong Date: Sun, 30 Aug 2026 00:24:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20judgeUserResponse=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoutineExecutionCommandServiceImpl.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java index c642648..8ad0557 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java @@ -113,12 +113,15 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( } if (dto.shouldProceed()) { - routineExecutionRepository - .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) - .map(existing -> applyJudgeResult(existing, req, dto.aiResponse())) - .orElseGet(() -> routineExecutionRepository.save( - RoutineExecutionConverter.toEntity(req, routine, dto.aiResponse()) - )); + transactionTemplate.execute(status -> { + routineExecutionRepository + .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) + .map(existing -> applyJudgeResult(existing, req, dto.aiResponse())) + .orElseGet(() -> routineExecutionRepository.save( + RoutineExecutionConverter.toEntity(req, routine, dto.aiResponse()) + )); + return null; + }); } return RoutineExecutionResponseDTO.AiResponseRes.builder() From ce3279e8be73de619736da756e8126b970ee753f Mon Sep 17 00:00:00 2001 From: minjeong Date: Sun, 30 Aug 2026 21:37:33 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EB=A3=A8=ED=8B=B4=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20upsert=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoutineExecutionCommandServiceImpl.java | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java index 8ad0557..2f70594 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java @@ -15,9 +15,12 @@ import com.moru.server.global.idempotency.IdempotencyService; import com.moru.server.global.response.code.status.ErrorStatus; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDate; + @Service @RequiredArgsConstructor public class RoutineExecutionCommandServiceImpl implements RoutineExecutionCommandService { @@ -49,18 +52,39 @@ public RoutineExecutionResponseDTO.RoutineExecutionResultRes saveExecutionResult throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); } - RoutineExecution routineExecution = routineExecutionRepository - .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) - .map(existing -> applyExecutionResult(existing, req)) - .orElseGet(() -> routineExecutionRepository.save( - RoutineExecutionConverter.toEntity(req, routine) - )); + RoutineExecution routineExecution = upsertExecutionResult( + req.routineId(), req.executedDate(), routine, req + ); return RoutineExecutionConverter.toResponse(routineExecution); }) ); } + + private RoutineExecution upsertExecutionResult( + Long routineId, + LocalDate executedDate, + Routine routine, + RoutineExecutionRequestDTO.RoutineExecutionResultReq req + ) { + return routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .map(existing -> applyExecutionResult(existing, req)) + .orElseGet(() -> { + try { + return routineExecutionRepository.saveAndFlush( + RoutineExecutionConverter.toEntity(req, routine) + ); + } catch (DataIntegrityViolationException e) { + RoutineExecution raceWinner = routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .orElseThrow(() -> e); + return applyExecutionResult(raceWinner, req); + } + }); + } + private RoutineExecution applyExecutionResult( RoutineExecution existing, RoutineExecutionRequestDTO.RoutineExecutionResultReq req @@ -114,12 +138,7 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( if (dto.shouldProceed()) { transactionTemplate.execute(status -> { - routineExecutionRepository - .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) - .map(existing -> applyJudgeResult(existing, req, dto.aiResponse())) - .orElseGet(() -> routineExecutionRepository.save( - RoutineExecutionConverter.toEntity(req, routine, dto.aiResponse()) - )); + upsertJudgeResult(req.routineId(), req.executedDate(), routine, req, dto.aiResponse()); return null; }); } @@ -146,6 +165,29 @@ private RoutineExecution applyJudgeResult( return existing; } + private RoutineExecution upsertJudgeResult( + Long routineId, + LocalDate executedDate, + Routine routine, + RoutineExecutionRequestDTO.AiResponseReq req, + String aiResponse + ) { + return routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .map(existing -> applyJudgeResult(existing, req, aiResponse)) + .orElseGet(() -> { + try { + return routineExecutionRepository.saveAndFlush( + RoutineExecutionConverter.toEntity(req, routine, aiResponse) + ); + } catch (DataIntegrityViolationException e) { + RoutineExecution raceWinner = routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .orElseThrow(() -> e); + return applyJudgeResult(raceWinner, req, aiResponse); + } + }); + } } From 8c21148f3d87b062f83b613316c955806abdac0a Mon Sep 17 00:00:00 2001 From: minjeong Date: Sun, 30 Aug 2026 21:41:18 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20routine=5Fexecution=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20unique=20=EC=A0=9C=EC=95=BD=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V4__add_unique_constraint_routine_execution.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/resources/db/migration/V4__add_unique_constraint_routine_execution.sql diff --git a/src/main/resources/db/migration/V4__add_unique_constraint_routine_execution.sql b/src/main/resources/db/migration/V4__add_unique_constraint_routine_execution.sql new file mode 100644 index 0000000..8f6194f --- /dev/null +++ b/src/main/resources/db/migration/V4__add_unique_constraint_routine_execution.sql @@ -0,0 +1,9 @@ +DELETE re1 FROM routine_execution re1 +INNER JOIN routine_execution re2 + ON re1.routine_id = re2.routine_id + AND re1.executed_date = re2.executed_date + AND re1.id < re2.id; + +ALTER TABLE routine_execution + ADD CONSTRAINT uk_routine_execution_routine_date + UNIQUE (routine_id, executed_date); \ No newline at end of file From 00d8e3c3b6c13eac6cb5b944adfc3ad2a1a4cc9a Mon Sep 17 00:00:00 2001 From: minjeong Date: Sun, 30 Aug 2026 21:47:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=20rollback-only=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=97=90=EC=84=9C=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoutineExecutionCommandServiceImpl.java | 125 ++++++++++-------- 1 file changed, 71 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java index 2f70594..0ea0142 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineExecution/RoutineExecutionCommandServiceImpl.java @@ -44,25 +44,42 @@ public RoutineExecutionResponseDTO.RoutineExecutionResultRes saveExecutionResult idempotencyKey, req, RoutineExecutionResponseDTO.RoutineExecutionResultRes.class, - () -> transactionTemplate.execute(status -> { - Routine routine = routineRepository.findById(req.routineId()) - .orElseThrow(() -> new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND)); - - if (!routine.getRoutineGroup().isOwnedBy(memberId)) { - throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); - } - - RoutineExecution routineExecution = upsertExecutionResult( - req.routineId(), req.executedDate(), routine, req - ); - - return RoutineExecutionConverter.toResponse(routineExecution); - }) + () -> doSaveExecutionResult(memberId, req) ); } + private RoutineExecutionResponseDTO.RoutineExecutionResultRes doSaveExecutionResult( + Long memberId, + RoutineExecutionRequestDTO.RoutineExecutionResultReq req + ) { + try { + return transactionTemplate.execute(status -> { + Routine routine = routineRepository.findById(req.routineId()) + .orElseThrow(() -> new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND)); + + if (!routine.getRoutineGroup().isOwnedBy(memberId)) { + throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); + } + + RoutineExecution routineExecution = findOrCreateExecution( + req.routineId(), req.executedDate(), routine, req + ); + return RoutineExecutionConverter.toResponse(routineExecution); + }); + } catch (DataIntegrityViolationException e) { + // 동시 요청으로 다른 트랜잭션이 먼저 insert함 -> 새 트랜잭션에서 재조회 후 업데이트 + return transactionTemplate.execute(status -> { + RoutineExecution raceWinner = routineExecutionRepository + .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) + .orElseThrow(() -> e); + RoutineExecution updated = applyExecutionResult(raceWinner, req); + return RoutineExecutionConverter.toResponse(updated); + }); + } + } - private RoutineExecution upsertExecutionResult( + // try-catch 없이 "찾거나 새로 만들거나"만 함 - 예외는 그대로 위로 던져서 트랜잭션이 자연스럽게 롤백 + private RoutineExecution findOrCreateExecution( Long routineId, LocalDate executedDate, Routine routine, @@ -71,20 +88,12 @@ private RoutineExecution upsertExecutionResult( return routineExecutionRepository .findByRoutine_IdAndExecutedDate(routineId, executedDate) .map(existing -> applyExecutionResult(existing, req)) - .orElseGet(() -> { - try { - return routineExecutionRepository.saveAndFlush( - RoutineExecutionConverter.toEntity(req, routine) - ); - } catch (DataIntegrityViolationException e) { - RoutineExecution raceWinner = routineExecutionRepository - .findByRoutine_IdAndExecutedDate(routineId, executedDate) - .orElseThrow(() -> e); - return applyExecutionResult(raceWinner, req); - } - }); + .orElseGet(() -> routineExecutionRepository.saveAndFlush( + RoutineExecutionConverter.toEntity(req, routine) + )); } + private RoutineExecution applyExecutionResult( RoutineExecution existing, RoutineExecutionRequestDTO.RoutineExecutionResultReq req @@ -137,10 +146,7 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( } if (dto.shouldProceed()) { - transactionTemplate.execute(status -> { - upsertJudgeResult(req.routineId(), req.executedDate(), routine, req, dto.aiResponse()); - return null; - }); + saveJudgeResultWithRetry(req, routine, dto.aiResponse()); } return RoutineExecutionResponseDTO.AiResponseRes.builder() @@ -149,23 +155,28 @@ private RoutineExecutionResponseDTO.AiResponseRes doJudgeUserResponse( .build(); } - private RoutineExecution applyJudgeResult( - RoutineExecution existing, + private void saveJudgeResultWithRetry( RoutineExecutionRequestDTO.AiResponseReq req, + Routine routine, String aiResponse ) { - existing.complete(req.durationSecond()); - existing.recordAiResponse(aiResponse); - if (req.memberInput() != null) { - existing.recordInput(req.memberInput()); - } - if (req.actualWakeTime() != null) { - existing.recordActualWakeTime(req.actualWakeTime()); + try { + transactionTemplate.execute(status -> { + findOrCreateJudgeResult(req.routineId(), req.executedDate(), routine, req, aiResponse); + return null; + }); + } catch (DataIntegrityViolationException e) { + transactionTemplate.execute(status -> { + RoutineExecution raceWinner = routineExecutionRepository + .findByRoutine_IdAndExecutedDate(req.routineId(), req.executedDate()) + .orElseThrow(() -> e); + applyJudgeResult(raceWinner, req, aiResponse); + return null; + }); } - return existing; } - private RoutineExecution upsertJudgeResult( + private RoutineExecution findOrCreateJudgeResult( Long routineId, LocalDate executedDate, Routine routine, @@ -175,19 +186,25 @@ private RoutineExecution upsertJudgeResult( return routineExecutionRepository .findByRoutine_IdAndExecutedDate(routineId, executedDate) .map(existing -> applyJudgeResult(existing, req, aiResponse)) - .orElseGet(() -> { - try { - return routineExecutionRepository.saveAndFlush( - RoutineExecutionConverter.toEntity(req, routine, aiResponse) - ); - } catch (DataIntegrityViolationException e) { - RoutineExecution raceWinner = routineExecutionRepository - .findByRoutine_IdAndExecutedDate(routineId, executedDate) - .orElseThrow(() -> e); - return applyJudgeResult(raceWinner, req, aiResponse); - } - }); + .orElseGet(() -> routineExecutionRepository.saveAndFlush( + RoutineExecutionConverter.toEntity(req, routine, aiResponse) + )); } + private RoutineExecution applyJudgeResult( + RoutineExecution existing, + RoutineExecutionRequestDTO.AiResponseReq req, + String aiResponse + ) { + existing.complete(req.durationSecond()); + existing.recordAiResponse(aiResponse); + if (req.memberInput() != null) { + existing.recordInput(req.memberInput()); + } + if (req.actualWakeTime() != null) { + existing.recordActualWakeTime(req.actualWakeTime()); + } + return existing; + } }