diff --git a/docs/status.md b/docs/status.md index 9a42ef8..4a65b09 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,8 +22,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 | | REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 | | REQ-004 | 자기소개서 등록 step 저장 | Implemented | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34), [#36](https://github.com/Rewrite-Team/Rewrite-BE/issues/36) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterQuestionRepositoryTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장, API-011 등록 step3 질문과 답변 저장 계약 구현됨 | -| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 | -| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 | +| REQ-005 | 자기소개서 제출과 LLM Job 생성 | In Progress | High | API-014, API-015, API-016 | [#38](https://github.com/Rewrite-Team/Rewrite-BE/issues/38), [#40](https://github.com/Rewrite-Team/Rewrite-BE/issues/40), [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | API-014 제출과 PENDING 최초 첨삭 Job 생성, API-015 Job 상태 조회 skeleton, 최초 첨삭 성공 결과 완료 경계 구현됨. API-016 SSE와 실제 LLM provider 호출은 후속 이슈로 분리 | +| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence와 최초 첨삭 성공 저장 경계 구현됨. API-017~019, API-024는 후속 범위 | | REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 | | REQ-008 | 실제 인증 경계 | Planned | Medium | API-001 - API-006 | - | - | 카카오 OAuth, cookie, CSRF | | REQ-009 | 키워드 분석 | Planned | Medium | API-020, API-021 | - | - | LLM Job 기반 | @@ -58,8 +58,8 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test | -| 자기소개서 삭제 시 진행 Job 취소 | REQ-003, REQ-005 | API-013 내부 동작 | soft delete transaction에서 연결된 PENDING/PROCESSING Job을 CANCELED로 전환하고 repository/service test 추가 | +| OpenAI 최초 첨삭 client | REQ-005, REQ-006 | LLM provider 내부 연동 | 자기소개서 입력을 구조화된 prompt로 변환하고 문항별 `aiReport`, `rewrittenAnswer` 응답을 검증하는 client 구현 | +| 최초 첨삭 비동기 worker | REQ-005, REQ-006 | API-014 이후 내부 실행 | PENDING Job 실행, provider client 호출, 성공 시 ReviewVersion 완료 경계 연결, 실패 상태와 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java index 9673123..414076e 100644 --- a/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java +++ b/src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java @@ -102,6 +102,12 @@ public void startReview(Instant now) { this.updatedAt = now; } + public void completeReview(String reviewVersionId, Instant now) { + this.status = CoverLetterStatus.REVIEWED; + this.latestReviewVersionId = reviewVersionId; + this.updatedAt = now; + } + public void setLatestReviewVersionId(String latestReviewVersionId) { this.latestReviewVersionId = latestReviewVersionId; } diff --git a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java index fddd743..a707ddd 100644 --- a/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java +++ b/src/main/java/com/daon/rewrite/coverletter/repository/CoverLetterRepository.java @@ -37,4 +37,13 @@ Optional findActiveByIdAndOwnerIdForUpdate( @Param("id") String id, @Param("ownerId") String ownerId ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select coverLetter + from CoverLetter coverLetter + where coverLetter.id = :id + and coverLetter.deletedAt is null + """) + Optional findActiveByIdForUpdate(@Param("id") String id); } diff --git a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java index 31ae0c9..8d9be79 100644 --- a/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java +++ b/src/main/java/com/daon/rewrite/llmjob/entity/LlmJob.java @@ -107,6 +107,14 @@ public static LlmJob pendingReview(String id, String coverLetterId, Instant now, ); } + public void startProcessing(String progressMessage) { + if (this.status != LlmJobStatus.PENDING) { + throw new IllegalStateException("PENDING Job만 처리를 시작할 수 있습니다."); + } + this.status = LlmJobStatus.PROCESSING; + this.progressMessage = progressMessage; + } + public void markCompleted( int progressCurrent, String progressMessage, diff --git a/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java index 6b1e3fe..ff817a7 100644 --- a/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java +++ b/src/main/java/com/daon/rewrite/llmjob/repository/LlmJobRepository.java @@ -4,6 +4,11 @@ import com.daon.rewrite.llmjob.entity.LlmJobStatus; import com.daon.rewrite.llmjob.entity.LlmJobTargetType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; import java.util.Collection; import java.util.Optional; @@ -15,4 +20,8 @@ Optional findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc String targetId, Collection statuses ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select job from LlmJob job where job.id = :id") + Optional findByIdForUpdate(@Param("id") String id); } diff --git a/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersion.java b/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersion.java new file mode 100644 index 0000000..a0acbb4 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersion.java @@ -0,0 +1,66 @@ +package com.daon.rewrite.reviewversion.entity; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "review_versions", + uniqueConstraints = @UniqueConstraint( + name = "uk_review_versions_cover_letter_version", + columnNames = {"cover_letter_id", "version"} + ) +) +public class ReviewVersion { + + private static final String FIRST_VERSION = "v0.1"; + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "cover_letter_id", nullable = false) + private CoverLetter coverLetter; + + @Column(name = "version", nullable = false, length = 20) + private String version; + + @Column(name = "request_instruction", length = 1000) + private String requestInstruction; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + private ReviewVersion( + String id, + CoverLetter coverLetter, + String version, + String requestInstruction, + Instant createdAt + ) { + this.id = id; + this.coverLetter = coverLetter; + this.version = version; + this.requestInstruction = requestInstruction; + this.createdAt = createdAt; + } + + public static ReviewVersion first(String id, CoverLetter coverLetter, Instant createdAt) { + return new ReviewVersion(id, coverLetter, FIRST_VERSION, null, createdAt); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersionQuestionResult.java b/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersionQuestionResult.java new file mode 100644 index 0000000..bc2f48a --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/entity/ReviewVersionQuestionResult.java @@ -0,0 +1,111 @@ +package com.daon.rewrite.reviewversion.entity; + +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "review_version_question_results", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_review_question_results_version_question", + columnNames = {"review_version_id", "question_id"} + ), + @UniqueConstraint( + name = "uk_review_question_results_version_order", + columnNames = {"review_version_id", "question_order"} + ) + } +) +public class ReviewVersionQuestionResult { + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "review_version_id", nullable = false) + private ReviewVersion reviewVersion; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "question_id", nullable = false) + private CoverLetterQuestion question; + + @Column(name = "question_order", nullable = false) + private int questionOrder; + + @Column(name = "question", nullable = false, length = 300) + private String questionText; + + @Column(name = "max_answer_length", nullable = false) + private int maxAnswerLength; + + @Column(name = "original_answer", nullable = false, columnDefinition = "text") + private String originalAnswer; + + @Column(name = "original_answer_length", nullable = false) + private int originalAnswerLength; + + @Column(name = "ai_report", nullable = false, columnDefinition = "text") + private String aiReport; + + @Column(name = "rewritten_answer", nullable = false, columnDefinition = "text") + private String rewrittenAnswer; + + @Column(name = "rewritten_answer_length", nullable = false) + private int rewrittenAnswerLength; + + @Column(name = "final_answer", nullable = false, columnDefinition = "text") + private String finalAnswer; + + @Column(name = "final_answer_length", nullable = false) + private int finalAnswerLength; + + private ReviewVersionQuestionResult( + String id, + ReviewVersion reviewVersion, + CoverLetterQuestion question, + String aiReport, + String rewrittenAnswer + ) { + this.id = id; + this.reviewVersion = reviewVersion; + this.question = question; + this.questionOrder = question.getQuestionOrder(); + this.questionText = question.getQuestion(); + this.maxAnswerLength = question.getMaxAnswerLength(); + this.originalAnswer = question.getOriginalAnswer(); + this.originalAnswerLength = countCodePoints(originalAnswer); + this.aiReport = aiReport; + this.rewrittenAnswer = rewrittenAnswer; + this.rewrittenAnswerLength = countCodePoints(rewrittenAnswer); + this.finalAnswer = rewrittenAnswer; + this.finalAnswerLength = rewrittenAnswerLength; + } + + public static ReviewVersionQuestionResult create( + String id, + ReviewVersion reviewVersion, + CoverLetterQuestion question, + String aiReport, + String rewrittenAnswer + ) { + return new ReviewVersionQuestionResult(id, reviewVersion, question, aiReport, rewrittenAnswer); + } + + private static int countCodePoints(String value) { + return value.codePointCount(0, value.length()); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionQuestionResultRepository.java b/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionQuestionResultRepository.java new file mode 100644 index 0000000..92cb161 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionQuestionResultRepository.java @@ -0,0 +1,11 @@ +package com.daon.rewrite.reviewversion.repository; + +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewVersionQuestionResultRepository extends JpaRepository { + + List findByReviewVersionIdOrderByQuestionOrderAsc(String reviewVersionId); +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepository.java b/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepository.java new file mode 100644 index 0000000..9cfc0ec --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepository.java @@ -0,0 +1,7 @@ +package com.daon.rewrite.reviewversion.repository; + +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewVersionRepository extends JpaRepository { +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/CompleteFirstReviewResult.java b/src/main/java/com/daon/rewrite/reviewversion/service/CompleteFirstReviewResult.java new file mode 100644 index 0000000..1328d54 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/CompleteFirstReviewResult.java @@ -0,0 +1,16 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; + +import java.util.List; + +public record CompleteFirstReviewResult( + ReviewVersion reviewVersion, + List questionResults +) { + + public CompleteFirstReviewResult { + questionResults = List.copyOf(questionResults); + } +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/ReviewQuestionResultInput.java b/src/main/java/com/daon/rewrite/reviewversion/service/ReviewQuestionResultInput.java new file mode 100644 index 0000000..6a8b2cf --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/ReviewQuestionResultInput.java @@ -0,0 +1,8 @@ +package com.daon.rewrite.reviewversion.service; + +public record ReviewQuestionResultInput( + String questionId, + String aiReport, + String rewrittenAnswer +) { +} diff --git a/src/main/java/com/daon/rewrite/reviewversion/service/ReviewVersionService.java b/src/main/java/com/daon/rewrite/reviewversion/service/ReviewVersionService.java new file mode 100644 index 0000000..6d60550 --- /dev/null +++ b/src/main/java/com/daon/rewrite/reviewversion/service/ReviewVersionService.java @@ -0,0 +1,182 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobResultRefType; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.entity.LlmJobTargetType; +import com.daon.rewrite.llmjob.entity.LlmJobType; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import com.daon.rewrite.reviewversion.repository.ReviewVersionQuestionResultRepository; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ReviewVersionService { + + private static final String REVIEW_VERSION_ID_PREFIX = "rv"; + private static final String QUESTION_RESULT_ID_PREFIX = "rvqr"; + private static final String COMPLETED_MESSAGE = "첨삭이 완료되었습니다."; + + private final LlmJobRepository llmJobRepository; + private final CoverLetterRepository coverLetterRepository; + private final CoverLetterQuestionRepository questionRepository; + private final ReviewVersionRepository reviewVersionRepository; + private final ReviewVersionQuestionResultRepository questionResultRepository; + private final IdGenerator idGenerator; + private final Clock clock; + + @Transactional + public CompleteFirstReviewResult completeFirstReview( + String jobId, + List inputs + ) { + LlmJob job = llmJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + validateFirstReviewJob(job); + + if (job.getStatus() == LlmJobStatus.COMPLETED) { + return findCompletedResult(job); + } + if (job.getStatus() != LlmJobStatus.PROCESSING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + CoverLetter coverLetter = coverLetterRepository.findActiveByIdForUpdate(job.getTargetId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + if (coverLetter.getStatus() != CoverLetterStatus.REVIEWING) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + List questions = questionRepository + .findByCoverLetterIdOrderByQuestionOrderAsc(coverLetter.getId()); + List normalizedResults = validateAndNormalize(questions, inputs); + + Instant now = Instant.now(clock); + ReviewVersion reviewVersion = reviewVersionRepository.save(ReviewVersion.first( + idGenerator.generate(REVIEW_VERSION_ID_PREFIX), + coverLetter, + now + )); + + List questionResults = new ArrayList<>(); + for (NormalizedReviewResult normalizedResult : normalizedResults) { + questionResults.add(ReviewVersionQuestionResult.create( + idGenerator.generate(QUESTION_RESULT_ID_PREFIX), + reviewVersion, + normalizedResult.question(), + normalizedResult.aiReport(), + normalizedResult.rewrittenAnswer() + )); + } + questionResults = questionResultRepository.saveAll(questionResults); + + coverLetter.completeReview(reviewVersion.getId(), now); + job.markCompleted( + job.getProgressTotal(), + COMPLETED_MESSAGE, + LlmJobResultRefType.REVIEW_VERSION, + reviewVersion.getId(), + now + ); + + return new CompleteFirstReviewResult(reviewVersion, questionResults); + } + + private void validateFirstReviewJob(LlmJob job) { + if (job.getType() != LlmJobType.COVER_LETTER_REVIEW + || job.getTargetType() != LlmJobTargetType.COVER_LETTER) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + } + + private CompleteFirstReviewResult findCompletedResult(LlmJob job) { + if (job.getResultRefType() != LlmJobResultRefType.REVIEW_VERSION + || job.getResultRefId() == null) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + + ReviewVersion reviewVersion = reviewVersionRepository.findById(job.getResultRefId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + List questionResults = questionResultRepository + .findByReviewVersionIdOrderByQuestionOrderAsc(reviewVersion.getId()); + return new CompleteFirstReviewResult(reviewVersion, questionResults); + } + + private List validateAndNormalize( + List questions, + List inputs + ) { + if (questions.isEmpty() || inputs == null || inputs.size() != questions.size()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + + Map inputByQuestionId = new HashMap<>(); + for (ReviewQuestionResultInput input : inputs) { + if (input == null || input.questionId() == null || input.questionId().isBlank() + || inputByQuestionId.putIfAbsent(input.questionId(), input) != null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + } + + List normalizedResults = new ArrayList<>(); + for (CoverLetterQuestion question : questions) { + ReviewQuestionResultInput input = inputByQuestionId.remove(question.getId()); + if (input == null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + + String aiReport = normalizeRequired(input.aiReport()); + String rewrittenAnswer = normalizeRequired(input.rewrittenAnswer()); + if (aiReport == null + || rewrittenAnswer == null + || countCodePoints(rewrittenAnswer) > question.getMaxAnswerLength()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + normalizedResults.add(new NormalizedReviewResult(question, aiReport, rewrittenAnswer)); + } + + if (!inputByQuestionId.isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR); + } + return normalizedResults; + } + + private String normalizeRequired(String value) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.isEmpty() ? null : normalized; + } + + private int countCodePoints(String value) { + return value.codePointCount(0, value.length()); + } + + private record NormalizedReviewResult( + CoverLetterQuestion question, + String aiReport, + String rewrittenAnswer + ) { + } +} diff --git a/src/test/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepositoryTest.java b/src/test/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepositoryTest.java new file mode 100644 index 0000000..ca3f0f1 --- /dev/null +++ b/src/test/java/com/daon/rewrite/reviewversion/repository/ReviewVersionRepositoryTest.java @@ -0,0 +1,91 @@ +package com.daon.rewrite.reviewversion.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersion; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class ReviewVersionRepositoryTest { + + @Autowired + private ReviewVersionRepository reviewVersionRepository; + + @Autowired + private ReviewVersionQuestionResultRepository questionResultRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository coverLetterQuestionRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void saveFirstReviewVersionRoundTripsQuestionSnapshots() { + Instant now = Instant.parse("2026-06-21T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + CoverLetterQuestion question = coverLetterQuestionRepository.save(CoverLetterQuestion.create( + "clq_1", + coverLetter, + 1, + "지원 동기를 작성해주세요.", + 1000, + "원본 답변😀" + )); + ReviewVersion reviewVersion = reviewVersionRepository.save(ReviewVersion.first( + "rv_1", + coverLetter, + now.plusSeconds(60) + )); + questionResultRepository.save(ReviewVersionQuestionResult.create( + "rvqr_1", + reviewVersion, + question, + "STAR 기준으로 성과를 구체화해야 합니다.", + "수정 답변😀" + )); + + entityManager.flush(); + entityManager.clear(); + + ReviewVersion foundVersion = reviewVersionRepository.findById("rv_1").orElseThrow(); + List foundResults = + questionResultRepository.findByReviewVersionIdOrderByQuestionOrderAsc("rv_1"); + + assertThat(foundVersion.getCoverLetter().getId()).isEqualTo("cl_1"); + assertThat(foundVersion.getVersion()).isEqualTo("v0.1"); + assertThat(foundVersion.getRequestInstruction()).isNull(); + assertThat(foundVersion.getCreatedAt()).isEqualTo(now.plusSeconds(60)); + + assertThat(foundResults).hasSize(1); + assertThat(foundResults.getFirst().getId()).isEqualTo("rvqr_1"); + assertThat(foundResults.getFirst().getReviewVersion().getId()).isEqualTo("rv_1"); + assertThat(foundResults.getFirst().getQuestion().getId()).isEqualTo("clq_1"); + assertThat(foundResults.getFirst().getQuestionOrder()).isEqualTo(1); + assertThat(foundResults.getFirst().getQuestionText()).isEqualTo("지원 동기를 작성해주세요."); + assertThat(foundResults.getFirst().getMaxAnswerLength()).isEqualTo(1000); + assertThat(foundResults.getFirst().getOriginalAnswer()).isEqualTo("원본 답변😀"); + assertThat(foundResults.getFirst().getOriginalAnswerLength()).isEqualTo(6); + assertThat(foundResults.getFirst().getAiReport()).isEqualTo("STAR 기준으로 성과를 구체화해야 합니다."); + assertThat(foundResults.getFirst().getRewrittenAnswer()).isEqualTo("수정 답변😀"); + assertThat(foundResults.getFirst().getRewrittenAnswerLength()).isEqualTo(6); + assertThat(foundResults.getFirst().getFinalAnswer()).isEqualTo("수정 답변😀"); + assertThat(foundResults.getFirst().getFinalAnswerLength()).isEqualTo(6); + } +} diff --git a/src/test/java/com/daon/rewrite/reviewversion/service/ReviewVersionServiceTest.java b/src/test/java/com/daon/rewrite/reviewversion/service/ReviewVersionServiceTest.java new file mode 100644 index 0000000..15cfb4c --- /dev/null +++ b/src/test/java/com/daon/rewrite/reviewversion/service/ReviewVersionServiceTest.java @@ -0,0 +1,241 @@ +package com.daon.rewrite.reviewversion.service; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.entity.CoverLetterQuestion; +import com.daon.rewrite.coverletter.entity.CoverLetterStatus; +import com.daon.rewrite.coverletter.repository.CoverLetterQuestionRepository; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.global.exception.BusinessException; +import com.daon.rewrite.global.exception.ErrorCode; +import com.daon.rewrite.global.util.IdGenerator; +import com.daon.rewrite.llmjob.entity.LlmJob; +import com.daon.rewrite.llmjob.entity.LlmJobResultRefType; +import com.daon.rewrite.llmjob.entity.LlmJobStatus; +import com.daon.rewrite.llmjob.repository.LlmJobRepository; +import com.daon.rewrite.reviewversion.entity.ReviewVersionQuestionResult; +import com.daon.rewrite.reviewversion.repository.ReviewVersionQuestionResultRepository; +import com.daon.rewrite.reviewversion.repository.ReviewVersionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +class ReviewVersionServiceTest { + + @Autowired + private ReviewVersionService service; + + @Autowired + private ReviewVersionRepository reviewVersionRepository; + + @Autowired + private ReviewVersionQuestionResultRepository questionResultRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private CoverLetterQuestionRepository questionRepository; + + @Autowired + private LlmJobRepository llmJobRepository; + + @MockitoBean + private IdGenerator idGenerator; + + @MockitoBean + private Clock clock; + + @AfterEach + void cleanUp() { + questionResultRepository.deleteAll(); + reviewVersionRepository.deleteAll(); + llmJobRepository.deleteAll(); + questionRepository.deleteAll(); + coverLetterRepository.deleteAll(); + } + + @Test + void completeFirstReviewPersistsOrderedSnapshotsAndCompletesResources() { + Instant completedAt = Instant.parse("2026-06-21T05:00:00Z"); + saveProcessingReview("cl_1", "job_1", 2); + given(idGenerator.generate("rv")).willReturn("rv_1"); + given(idGenerator.generate("rvqr")).willReturn("rvqr_1", "rvqr_2"); + given(clock.instant()).willReturn(completedAt); + + CompleteFirstReviewResult result = service.completeFirstReview( + "job_1", + List.of( + new ReviewQuestionResultInput("clq_2", " 두 번째 리포트 ", " 두 번째 수정본 "), + new ReviewQuestionResultInput("clq_1", " 첫 번째 리포트 ", " 첫 번째 수정본😀 ") + ) + ); + + assertThat(result.reviewVersion().getId()).isEqualTo("rv_1"); + assertThat(result.reviewVersion().getVersion()).isEqualTo("v0.1"); + assertThat(result.questionResults()) + .extracting(ReviewVersionQuestionResult::getId) + .containsExactly("rvqr_1", "rvqr_2"); + assertThat(result.questionResults()) + .extracting(ReviewVersionQuestionResult::getQuestionOrder) + .containsExactly(1, 2); + assertThat(result.questionResults()) + .extracting(ReviewVersionQuestionResult::getAiReport) + .containsExactly("첫 번째 리포트", "두 번째 리포트"); + assertThat(result.questionResults().getFirst().getRewrittenAnswer()).isEqualTo("첫 번째 수정본😀"); + assertThat(result.questionResults().getFirst().getRewrittenAnswerLength()).isEqualTo(9); + assertThat(result.questionResults().getFirst().getFinalAnswer()).isEqualTo("첫 번째 수정본😀"); + + assertThat(coverLetterRepository.findById("cl_1")).hasValueSatisfying(coverLetter -> { + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.REVIEWED); + assertThat(coverLetter.getLatestReviewVersionId()).isEqualTo("rv_1"); + assertThat(coverLetter.getUpdatedAt()).isEqualTo(completedAt); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.COMPLETED); + assertThat(job.getProgressCurrent()).isEqualTo(2); + assertThat(job.getResultRefType()).isEqualTo(LlmJobResultRefType.REVIEW_VERSION); + assertThat(job.getResultRefId()).isEqualTo("rv_1"); + assertThat(job.getCompletedAt()).isEqualTo(completedAt); + }); + } + + @Test + void completeFirstReviewRejectsInvalidQuestionMappingWithoutPartialChanges() { + saveProcessingReview("cl_1", "job_1", 2); + + List> invalidInputs = List.of( + List.of(new ReviewQuestionResultInput("clq_1", "리포트", "수정본")), + List.of( + new ReviewQuestionResultInput("clq_1", "리포트", "수정본"), + new ReviewQuestionResultInput("clq_1", "리포트", "수정본") + ), + List.of( + new ReviewQuestionResultInput("clq_1", "리포트", "수정본"), + new ReviewQuestionResultInput("clq_unknown", "리포트", "수정본") + ) + ); + + for (List input : invalidInputs) { + assertThatThrownBy(() -> service.completeFirstReview("job_1", input)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + assertUnchangedProcessingState(); + } + + @Test + void completeFirstReviewRejectsBlankOrTooLongGeneratedTextWithoutPartialChanges() { + saveProcessingReview("cl_1", "job_1", 2); + + assertThatThrownBy(() -> service.completeFirstReview( + "job_1", + List.of( + new ReviewQuestionResultInput("clq_1", " ", "수정본"), + new ReviewQuestionResultInput("clq_2", "리포트", "수정본") + ) + )).isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + assertThatThrownBy(() -> service.completeFirstReview( + "job_1", + List.of( + new ReviewQuestionResultInput("clq_1", "리포트", "가".repeat(1001)), + new ReviewQuestionResultInput("clq_2", "리포트", "수정본") + ) + )).isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + assertUnchangedProcessingState(); + } + + @Test + void completeFirstReviewReturnsExistingVersionWhenJobIsAlreadyCompleted() { + saveProcessingReview("cl_1", "job_1", 1); + given(idGenerator.generate("rv")).willReturn("rv_1"); + given(idGenerator.generate("rvqr")).willReturn("rvqr_1"); + given(clock.instant()).willReturn(Instant.parse("2026-06-21T05:00:00Z")); + List input = List.of( + new ReviewQuestionResultInput("clq_1", "리포트", "수정본") + ); + + CompleteFirstReviewResult first = service.completeFirstReview("job_1", input); + CompleteFirstReviewResult second = service.completeFirstReview("job_1", input); + + assertThat(second.reviewVersion().getId()).isEqualTo(first.reviewVersion().getId()); + assertThat(reviewVersionRepository.count()).isEqualTo(1); + assertThat(questionResultRepository.count()).isEqualTo(1); + } + + @Test + void completeFirstReviewRejectsJobThatIsNotProcessing() { + Instant now = Instant.parse("2026-06-21T01:00:00Z"); + CoverLetter coverLetter = saveReviewingCoverLetter("cl_1", 1); + llmJobRepository.save(LlmJob.pendingReview("job_pending", coverLetter.getId(), now, 1)); + + assertThatThrownBy(() -> service.completeFirstReview( + "job_pending", + List.of(new ReviewQuestionResultInput("clq_1", "리포트", "수정본")) + )).isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INTERNAL_ERROR); + } + + private void saveProcessingReview(String coverLetterId, String jobId, int questionCount) { + Instant now = Instant.parse("2026-06-21T01:00:00Z"); + CoverLetter coverLetter = saveReviewingCoverLetter(coverLetterId, questionCount); + LlmJob job = LlmJob.pendingReview(jobId, coverLetter.getId(), now, questionCount); + job.startProcessing("첨삭을 시작합니다."); + llmJobRepository.save(job); + } + + private CoverLetter saveReviewingCoverLetter(String coverLetterId, int questionCount) { + Instant now = Instant.parse("2026-06-21T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft(coverLetterId, "user_1", now); + coverLetter.fillBasicInfo("제목", "회사", "직무", null, now); + coverLetter.fillPreferences("우대사항", now); + coverLetter.startReview(now); + coverLetterRepository.save(coverLetter); + + for (int index = 1; index <= questionCount; index++) { + questionRepository.save(CoverLetterQuestion.create( + "clq_" + index, + coverLetter, + index, + "질문 " + index, + 1000, + "원본 답변 " + index + )); + } + return coverLetter; + } + + private void assertUnchangedProcessingState() { + assertThat(reviewVersionRepository.count()).isZero(); + assertThat(questionResultRepository.count()).isZero(); + assertThat(coverLetterRepository.findById("cl_1")).hasValueSatisfying(coverLetter -> { + assertThat(coverLetter.getStatus()).isEqualTo(CoverLetterStatus.REVIEWING); + assertThat(coverLetter.getLatestReviewVersionId()).isNull(); + }); + assertThat(llmJobRepository.findById("job_1")).hasValueSatisfying(job -> { + assertThat(job.getStatus()).isEqualTo(LlmJobStatus.PROCESSING); + assertThat(job.getResultRefId()).isNull(); + }); + } +}