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..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 @@ -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 { @@ -41,20 +44,75 @@ 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)); + () -> 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); + }); + } + } - if (!routine.getRoutineGroup().isOwnedBy(memberId)) { - throw new BusinessException(ErrorStatus.ROUTINE_NOT_FOUND); - } + // try-catch 없이 "찾거나 새로 만들거나"만 함 - 예외는 그대로 위로 던져서 트랜잭션이 자연스럽게 롤백 + private RoutineExecution findOrCreateExecution( + Long routineId, + LocalDate executedDate, + Routine routine, + RoutineExecutionRequestDTO.RoutineExecutionResultReq req + ) { + return routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .map(existing -> applyExecutionResult(existing, req)) + .orElseGet(() -> routineExecutionRepository.saveAndFlush( + RoutineExecutionConverter.toEntity(req, routine) + )); + } - RoutineExecution routineExecution = RoutineExecutionConverter.toEntity(req, routine); - routineExecutionRepository.save(routineExecution); - 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; } @@ -77,8 +135,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 +145,66 @@ 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()) { + saveJudgeResultWithRetry(req, routine, dto.aiResponse()); } - return RoutineExecutionResponseDTO.AiResponseRes.builder() .aiResponse(dto.aiResponse()) .shouldProceed(dto.shouldProceed()) .build(); } + private void saveJudgeResultWithRetry( + RoutineExecutionRequestDTO.AiResponseReq req, + Routine routine, + String aiResponse + ) { + 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; + }); + } + } + + private RoutineExecution findOrCreateJudgeResult( + Long routineId, + LocalDate executedDate, + Routine routine, + RoutineExecutionRequestDTO.AiResponseReq req, + String aiResponse + ) { + return routineExecutionRepository + .findByRoutine_IdAndExecutedDate(routineId, executedDate) + .map(existing -> applyJudgeResult(existing, 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; + } } 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