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
2 changes: 1 addition & 1 deletion docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Implemented | REQ-004 | 등록 step3 저장 |
| API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 |
| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Implemented | REQ-003 | 자기소개서 soft delete |
| API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Planned | REQ-005 | 첨삭 Job 생성 |
| API-014 | POST | `/cover-letters/{coverLetterId}/submit` | Implemented | REQ-005 | 첨삭 Job 생성 |
| API-015 | GET | `/llm-jobs/{jobId}` | Implemented | REQ-005 | LLM Job 상태 조회 |
| API-016 | GET | `/llm-jobs/{jobId}/stream` | Planned | REQ-005 | SSE 스트리밍 |
| API-017 | GET | `/cover-letters/{coverLetterId}/review-versions` | Planned | REQ-006 | 첨삭 버전 목록 |
Expand Down
16 changes: 13 additions & 3 deletions docs/api/cover-letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,12 @@ Response:
POST /cover-letters/{coverLetterId}/submit
```

Success Status:

```text
200 OK
```

Request:

```json
Expand All @@ -422,7 +428,8 @@ Response:
{
"coverLetterId": "cl_01HZ...",
"status": "REVIEWING",
"jobId": "job_01HZ..."
"jobId": "job_01HZ...",
"latestReviewVersionId": null
}
```

Expand Down Expand Up @@ -453,7 +460,7 @@ Validation Error Response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "자기소개서 제출에 필요한 정보가 누락되었습니다.",
"message": "입력값이 올바르지 않습니다.",
"details": [
{
"field": "preferences",
Expand All @@ -468,13 +475,16 @@ Validation Error Response:
}
```

다른 종류의 LLM Job이 이미 해당 자기소개서에서 `PENDING` 또는 `PROCESSING` 상태이면 `409 Conflict`와 `LLM_JOB_ALREADY_RUNNING`을 반환한다. 동일한 최초 첨삭 Job에 대한 중복 submit은 예외로 처리하지 않고 기존 Job을 반환한다.

Already Reviewing Response:

```json
{
"coverLetterId": "cl_01HZ...",
"status": "REVIEWING",
"jobId": "job_existing_01HZ..."
"jobId": "job_existing_01HZ...",
"latestReviewVersionId": null
}
```

Expand Down
13 changes: 5 additions & 8 deletions docs/decisions/cover-letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,7 @@ jobPostingUrl: " https://example.com/jobs/1 " -> "https://example.com/jobs/1"

자기소개서 제출 API 호출 시 필수 step 데이터가 누락되어 있으면 `VALIDATION_ERROR`를 반환한다.

응답의 `details`에는 누락된 `field`, 사용자를 돌려보낼 `step`, 사용자 안내용 `reason`을 포함한다.
응답의 `details`에는 누락된 `field`사용자 안내용 `reason`을 포함한다. 프론트엔드는 `field`를 등록 step과 매핑한다.

적용 API:

Expand All @@ -1046,16 +1046,14 @@ Response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "자기소개서 제출에 필요한 정보가 누락되었습니다.",
"message": "입력값이 올바르지 않습니다.",
"details": [
{
"field": "preferences",
"step": "STEP2",
"reason": "채용 우대사항을 입력해야 합니다."
},
{
"field": "questions",
"step": "STEP3",
"reason": "질문과 답변을 1개 이상 입력해야 합니다."
}
]
Expand All @@ -1069,7 +1067,7 @@ Response:

- 등록 완료 버튼을 누르면 AI 첨삭이 시작된다.
- AI 첨삭에는 회사명, 직무명, 우대사항, 질문/답변 같은 입력이 필요하다.
- step별 입력 화면이 있으므로 누락된 step을 프론트엔드가 수 있어야 한다.
- step별 입력 화면이 있으므로 누락된 field를 프론트엔드가 해당 step과 연결할 수 있어야 한다.

### 고려한 대안

Expand All @@ -1083,7 +1081,7 @@ Response:

### 선택 이유

제출은 AI 첨삭을 시작하기 직전의 최종 검증 단계다. 이 단계에서 누락된 정보를 구조적으로 내려주면 프론트엔드가 사용자를 정확한 step으로 돌려보내고, 어떤 값을 보완해야 하는지 바로 안내할 수 있다.
제출은 AI 첨삭을 시작하기 직전의 최종 검증 단계다. 이 단계에서 누락된 정보를 구조적으로 내려주면 프론트엔드가 field를 기준으로 사용자를 정확한 step으로 돌려보내고, 어떤 값을 보완해야 하는지 바로 안내할 수 있다.

### 트레이드오프

Expand All @@ -1093,7 +1091,7 @@ Response:
- 제출 실패 원인이 명확해진다.

- 단점
- 서버가 step/field 매핑 정보를 관리해야 한다.
- 서버가 누락 field와 사용자 안내 문구를 관리해야 한다.
- 필수 입력 정책이 바뀌면 에러 details 정책도 함께 갱신해야 한다.


Expand Down Expand Up @@ -1611,4 +1609,3 @@ MVP에서 삭제는 사용자 화면에서 해당 자기소개서를 제거하
- 운영자 복구나 감사 조회는 별도 내부 도구나 DB 대응에 의존해야 한다.
- 향후 사용자-facing 복구 기능을 만들면 접근 정책을 재검토해야 한다.


4 changes: 2 additions & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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) | `LlmJobRepositoryTest`, `LlmJobServiceTest`, `LlmJobControllerTest`, `./gradlew test`, `./gradlew check` | API-015 LLM Job 상태 조회 skeleton 구현됨. API-014 submit, API-016 SSE, 실제 LLM provider 호출은 후속 이슈로 분리 |
| 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-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 |
Expand Down Expand Up @@ -59,7 +59,7 @@ 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 |
| 자기소개서 제출 API | REQ-005 | API-014 | 필수 step 데이터 검증, cover letter 상태 전환, COVER_LETTER_REVIEW Job 생성, service/web/repository test |
| 자기소개서 삭제 시 진행 Job 취소 | REQ-003, REQ-005 | API-013 내부 동작 | soft delete transaction에서 연결된 PENDING/PROCESSING Job을 CANCELED로 전환하고 repository/service test 추가 |

## Update Rules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import com.daon.rewrite.coverletter.dto.SavePreferencesResponse;
import com.daon.rewrite.coverletter.dto.SaveQuestionsRequest;
import com.daon.rewrite.coverletter.dto.SaveQuestionsResponse;
import com.daon.rewrite.coverletter.dto.SubmitCoverLetterResponse;
import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.service.CoverLetterService;
import com.daon.rewrite.coverletter.service.SaveQuestionsResult;
import com.daon.rewrite.coverletter.service.SubmitCoverLetterResult;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -91,4 +93,10 @@ public SaveQuestionsResponse saveQuestions(
);
return SaveQuestionsResponse.from(result);
}

@PostMapping("/cover-letters/{coverLetterId}/submit")
public SubmitCoverLetterResponse submit(@PathVariable String coverLetterId) {
SubmitCoverLetterResult result = coverLetterService.submit(coverLetterId);
return SubmitCoverLetterResponse.from(result);
}
Comment thread
yong203 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.daon.rewrite.coverletter.dto;

import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.service.SubmitCoverLetterResult;

public record SubmitCoverLetterResponse(
String coverLetterId,
CoverLetterStatus status,
String jobId,
String latestReviewVersionId
) {

public static SubmitCoverLetterResponse from(SubmitCoverLetterResult result) {
return new SubmitCoverLetterResponse(
result.coverLetter().getId(),
result.coverLetter().getStatus(),
result.job() == null ? null : result.job().getId(),
result.coverLetter().getLatestReviewVersionId()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ public void markDeleted(Instant now) {
this.updatedAt = now;
}

public void startReview(Instant now) {
this.status = CoverLetterStatus.REVIEWING;
if (this.submittedAt == null) {
this.submittedAt = now;
}
this.updatedAt = now;
}

public void setLatestReviewVersionId(String latestReviewVersionId) {
this.latestReviewVersionId = latestReviewVersionId;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.Optional;

Expand All @@ -19,4 +24,17 @@ Page<CoverLetter> findByOwnerIdAndStatusAndDeletedAtIsNull(
);

Optional<CoverLetter> findByIdAndOwnerIdAndDeletedAtIsNull(String id, String ownerId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select coverLetter
from CoverLetter coverLetter
where coverLetter.id = :id
and coverLetter.ownerId = :ownerId
and coverLetter.deletedAt is null
""")
Optional<CoverLetter> findActiveByIdAndOwnerIdForUpdate(
@Param("id") String id,
@Param("ownerId") String ownerId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
import com.daon.rewrite.global.exception.ErrorCode;
import com.daon.rewrite.global.response.ErrorResponse;
import com.daon.rewrite.global.util.IdGenerator;
import com.daon.rewrite.llmjob.entity.LlmJob;
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 lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -42,10 +47,16 @@ public class CoverLetterService {
private static final int MIN_MAX_ANSWER_LENGTH = 100;
private static final int MAX_MAX_ANSWER_LENGTH = 5000;
private static final int MAX_ORIGINAL_ANSWER_LENGTH = 5000;
private static final String LLM_JOB_ID_PREFIX = "job";
private static final List<LlmJobStatus> RUNNING_JOB_STATUSES = List.of(
LlmJobStatus.PENDING,
LlmJobStatus.PROCESSING
);

private final CurrentUserProvider currentUserProvider;
private final CoverLetterRepository coverLetterRepository;
private final CoverLetterQuestionRepository coverLetterQuestionRepository;
private final LlmJobRepository llmJobRepository;
private final IdGenerator idGenerator;
private final Clock clock;

Expand Down Expand Up @@ -178,6 +189,115 @@ public SaveQuestionsResult saveQuestions(String coverLetterId, List<SaveQuestion
return new SaveQuestionsResult(coverLetter, savedQuestions);
}

@Transactional
public SubmitCoverLetterResult submit(String coverLetterId) {
CurrentUser currentUser = currentUserProvider.currentUser();
CoverLetter coverLetter = coverLetterRepository
.findActiveByIdAndOwnerIdForUpdate(coverLetterId, currentUser.id())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

// 이미 최초 AI 첨삭이 완료된 자소서에 submit 이 다시 호출된다면 새로운 첨삭 job 생성 없이 coverLetter 반환
if (coverLetter.getStatus() == CoverLetterStatus.REVIEWED) {
if (coverLetter.getLatestReviewVersionId() == null) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}
return new SubmitCoverLetterResult(coverLetter, null);
}

LlmJob runningJob = findRunningJob(coverLetter.getId());
// 최소첨삭 진행 중인 자소서가 있는 경우
if (coverLetter.getStatus() == CoverLetterStatus.REVIEWING) {
// 실제 진행중인 COVER_LETTER_REVIEW Job이 없는것은 모순, 예외처리
if (runningJob == null || runningJob.getType() != LlmJobType.COVER_LETTER_REVIEW) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}
Comment thread
yong203 marked this conversation as resolved.
// 기존 첨삭중인 job 반환
return new SubmitCoverLetterResult(coverLetter, runningJob);
}
// 자소서 status 가 REVIEWING 이 아닌데 첨삭 진행중인 job 이 있는것은 모순, 추가 job 생성 방지
if (runningJob != null) {
throw new BusinessException(ErrorCode.LLM_JOB_ALREADY_RUNNING);
}

List<CoverLetterQuestion> questions = coverLetterQuestionRepository
.findByCoverLetterIdOrderByQuestionOrderAsc(coverLetter.getId());
validateSubmit(coverLetter, questions);

Instant now = Instant.now(clock);
LlmJob job = llmJobRepository.save(LlmJob.pendingReview(
idGenerator.generate(LLM_JOB_ID_PREFIX),
coverLetter.getId(),
now,
questions.size()
));
coverLetter.startReview(now);

return new SubmitCoverLetterResult(coverLetter, job);
}

private LlmJob findRunningJob(String coverLetterId) {
return llmJobRepository
.findFirstByTargetTypeAndTargetIdAndStatusInOrderByCreatedAtDesc(
LlmJobTargetType.COVER_LETTER,
coverLetterId,
RUNNING_JOB_STATUSES
)
.orElse(null);
}

private void validateSubmit(CoverLetter coverLetter, List<CoverLetterQuestion> questions) {
List<ErrorResponse.ErrorDetail> details = new ArrayList<>();
addMissingDetail("title", coverLetter.getTitle(), "자기소개서 제목을 입력해야 합니다.", details);
addMissingDetail("companyName", coverLetter.getCompanyName(), "회사명을 입력해야 합니다.", details);
addMissingDetail("positionTitle", coverLetter.getPositionTitle(), "직무명을 입력해야 합니다.", details);
addMissingDetail("preferences", coverLetter.getPreferences(), "채용 우대사항을 입력해야 합니다.", details);

if (questions.isEmpty()) {
details.add(new ErrorResponse.ErrorDetail(
"questions",
"질문과 답변을 1개 이상 입력해야 합니다."
));
} else {
for (int index = 0; index < questions.size(); index++) {
CoverLetterQuestion question = questions.get(index);
addMissingDetail(
"questions[" + index + "].question",
question.getQuestion(),
"질문을 입력해야 합니다.",
details
);
if (question.getMaxAnswerLength() < MIN_MAX_ANSWER_LENGTH
|| question.getMaxAnswerLength() > MAX_MAX_ANSWER_LENGTH) {
details.add(new ErrorResponse.ErrorDetail(
"questions[" + index + "].maxAnswerLength",
"최대 답변 글자 수는 100자 이상 5000자 이하여야 합니다."
));
}
addMissingDetail(
"questions[" + index + "].originalAnswer",
question.getOriginalAnswer(),
"답변을 입력해야 합니다.",
details
);
}
}

if (!details.isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, details);
}
}

private void addMissingDetail(
String field,
String value,
String reason,
List<ErrorResponse.ErrorDetail> details
) {
if (value == null || value.isBlank()) {
details.add(new ErrorResponse.ErrorDetail(field, reason));
}
}

private void validateListQuery(int page, int size) {
if (page < 1 || size < 1 || size > MAX_LIST_SIZE) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.daon.rewrite.coverletter.service;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.llmjob.entity.LlmJob;

public record SubmitCoverLetterResult(
CoverLetter coverLetter,
LlmJob job
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public enum ErrorCode {
"제출된 자기소개서의 원본 정보는 수정할 수 없습니다."
),

LLM_JOB_ALREADY_RUNNING(
HttpStatus.CONFLICT,
"LLM_JOB_ALREADY_RUNNING",
"이미 진행 중인 LLM 작업이 있습니다."
),

INTERNAL_ERROR(
HttpStatus.INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
Expand Down
Loading
Loading