Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
)
Comment on lines +15 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

유니크 제약 마이그레이션이 필요합니다.

운영은 Flyway와 ddl-auto=validate를 사용하므로 @UniqueConstraint만으로는 실제 DB에 유니크 키가 생성되지 않습니다. 이 문제를 발생시킨 기존 중복 행도 남아 있어 요약 값이 계속 잘못되고, findByRoutine_IdAndExecutedDate()가 다건 결과 예외를 낼 수 있습니다. 기존 (routine_id, executed_date) 중복 데이터를 도메인 규칙에 따라 하나로 정리한 뒤 uk_routine_execution_routine_date를 추가하는 다음 버전 Flyway migration을 포함해 주셔야 한다고 합니다!

public class RoutineExecution extends BaseEntity {

@Id
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

public interface RoutineExecutionRepository extends JpaRepository<RoutineExecution, Long> {

Optional<RoutineExecution> findByRoutine_IdAndExecutedDate(Long routineId, LocalDate executedDate);

@Query("""
select distinct re.executedDate from RoutineExecution re
where re.routine.routineGroup.member.id = :memberId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}


Expand All @@ -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);
}

Expand All @@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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);
Loading