From c516a6bad15fac89b0c298d27cd8a004b4dd88f9 Mon Sep 17 00:00:00 2001 From: yong203 Date: Sun, 5 Jul 2026 15:05:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?:sparkles:=20feat:=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=B6=84=EC=84=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/README.md | 2 +- docs/api/keyword-analysis.md | 75 ++++++++++------ docs/erd.md | 3 + docs/status.md | 4 +- .../controller/KeywordAnalysisController.java | 9 ++ .../dto/LatestKeywordAnalysisResponse.java | 85 ++++++++++++++++++ .../entity/KeywordAnalysis.java | 22 +++++ .../entity/KeywordAnalysisKeyword.java | 73 +++++++++++++++ .../KeywordAnalysisKeywordRepository.java | 11 +++ .../service/KeywordAnalysisService.java | 19 ++++ .../service/LatestKeywordAnalysisResult.java | 25 ++++++ .../KeywordAnalysisControllerTest.java | 80 +++++++++++++++++ .../KeywordAnalysisKeywordRepositoryTest.java | 61 +++++++++++++ .../KeywordAnalysisRepositoryTest.java | 24 +++++ .../service/KeywordAnalysisServiceTest.java | 90 +++++++++++++++++++ 15 files changed, 555 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java create mode 100644 src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java create mode 100644 src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java create mode 100644 src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java create mode 100644 src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java diff --git a/docs/api/README.md b/docs/api/README.md index 1fd19b3..782d564 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -69,7 +69,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께 | API-018 | GET | `/cover-letters/{coverLetterId}/review-versions/{versionId}` | Implemented | REQ-006 | 첨삭 버전 상세 | | API-019 | PUT | `/cover-letters/{coverLetterId}/review-versions/{versionId}/final-answers` | Implemented | REQ-006 | 최종 작성본 일괄 저장 | | API-020 | POST | `/cover-letters/{coverLetterId}/keyword-analysis` | Implemented | REQ-009 | 키워드 분석 시작/재분석 | -| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Planned | REQ-009 | 최신 키워드 분석 조회 | +| API-021 | GET | `/cover-letters/{coverLetterId}/keyword-analysis/latest` | Implemented | REQ-009 | 최신 키워드 분석 조회 | | API-022 | POST | `/cover-letters/{coverLetterId}/interviews` | Planned | REQ-010 | 면접 세션/질문 생성 | | API-023 | POST | `/interview-threads/{threadId}/messages` | Planned | REQ-010 | 면접 답변 전송 | | API-024 | POST | `/cover-letters/{coverLetterId}/review-versions` | Implemented | REQ-006 | AI 첨삭 다시받기 | diff --git a/docs/api/keyword-analysis.md b/docs/api/keyword-analysis.md index 3ddb108..a375ea0 100644 --- a/docs/api/keyword-analysis.md +++ b/docs/api/keyword-analysis.md @@ -85,26 +85,48 @@ GET /cover-letters/{coverLetterId}/keyword-analysis/latest } ``` +진행 중인 키워드 분석 결과가 있는 경우: + +```json +{ + "coverLetterId": "cl_01HZ...", + "keywordAnalysis": { + "id": "ka_01HZ...", + "coverLetterId": "cl_01HZ...", + "sourceReviewVersionId": "rv_01HZ...", + "status": "PROCESSING", + "keywords": [], + "error": null, + "createdAt": "2026-06-20T15:00:00", + "completedAt": null + } +} +``` + Response: ```json { - "id": "ka_01HZ...", "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", - "status": "COMPLETED", - "keywords": [ - { - "keyword": "백엔드", - "importance": 95 - }, - { - "keyword": "Spring", - "importance": 88 - } - ], - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:00:30" + "keywordAnalysis": { + "id": "ka_01HZ...", + "coverLetterId": "cl_01HZ...", + "sourceReviewVersionId": "rv_01HZ...", + "status": "COMPLETED", + "keywords": [ + { + "keyword": "백엔드", + "importance": 95 + }, + { + "keyword": "Spring", + "importance": 88 + } + ], + "error": null, + "createdAt": "2026-06-20T15:00:00", + "completedAt": "2026-06-20T15:00:30" + } } ``` @@ -112,16 +134,19 @@ Response: ```json { - "id": "ka_01HZ...", "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", - "status": "FAILED", - "keywords": [], - "error": { - "code": "LLM_PROVIDER_ERROR", - "message": "키워드 분석에 실패했습니다." - }, - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:01:00" + "keywordAnalysis": { + "id": "ka_01HZ...", + "coverLetterId": "cl_01HZ...", + "sourceReviewVersionId": "rv_01HZ...", + "status": "FAILED", + "keywords": [], + "error": { + "code": "LLM_PROVIDER_ERROR", + "message": "키워드 분석에 실패했습니다." + }, + "createdAt": "2026-06-20T15:00:00", + "completedAt": "2026-06-20T15:01:00" + } } ``` diff --git a/docs/erd.md b/docs/erd.md index 3944f9c..482423e 100644 --- a/docs/erd.md +++ b/docs/erd.md @@ -317,6 +317,8 @@ Policy: | `status` | No | `PROCESSING`, `COMPLETED`, `FAILED` | | `created_at` | No | 생성 시각 | | `completed_at` | Yes | 완료 또는 실패 전까지 null | +| `error_code` | Yes | 실패 상태의 에러 코드 | +| `error_message` | Yes | 실패 상태의 사용자 표시 가능 메시지 | Policy: @@ -324,6 +326,7 @@ Policy: - 재분석은 같은 row를 갱신한다. - 재첨삭 완료만으로 기존 키워드 분석 결과를 삭제하지 않는다. - 사용자가 재분석을 실행하면 같은 row를 최신 첨삭 버전 기준으로 갱신한다. +- 재분석을 시작하면 이전 실패 메타데이터는 지운다. ### keyword_analysis_keywords diff --git a/docs/status.md b/docs/status.md index fd56ffc..d85be26 100644 --- a/docs/status.md +++ b/docs/status.md @@ -26,7 +26,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | In Progress | High | API-017, API-018, API-019, API-024 | [#42](https://github.com/Rewrite-Team/Rewrite-BE/issues/42), [#44](https://github.com/Rewrite-Team/Rewrite-BE/issues/44), [#48](https://github.com/Rewrite-Team/Rewrite-BE/issues/48), [#50](https://github.com/Rewrite-Team/Rewrite-BE/issues/50), [#52](https://github.com/Rewrite-Team/Rewrite-BE/issues/52), [#54](https://github.com/Rewrite-Team/Rewrite-BE/issues/54) | `ReviewVersionRepositoryTest`, `ReviewVersionServiceTest`, `ReviewVersionQueryServiceTest`, `ReviewVersionCommandServiceTest`, `ReviewVersionControllerTest`, `OpenAiFirstReviewClientTest`, `FirstReviewJobWorkerTest`, `ReReviewJobWorkerTest`, `FirstReviewJobEventIntegrationTest`, `./gradlew test`, `./gradlew check` | `ReviewVersion`, 문항별 결과 persistence, 최초 첨삭 성공 저장 경계와 OpenAI 문항별 첨삭 결과 client 및 worker 연결, API-017/018 첨삭 버전 목록/상세 조회, API-019 최종 작성본 일괄 저장, API-024 AI 첨삭 다시받기 구현됨 | | 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 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회, 실제 LLM 키워드 분석 worker, keyword result 저장은 후속 이슈로 분리 | +| REQ-009 | 키워드 분석 | In Progress | Medium | API-020, API-021 | [#56](https://github.com/Rewrite-Team/Rewrite-BE/issues/56), [#58](https://github.com/Rewrite-Team/Rewrite-BE/issues/58) | `KeywordAnalysisRepositoryTest`, `KeywordAnalysisKeywordRepositoryTest`, `KeywordAnalysisServiceTest`, `KeywordAnalysisControllerTest`, `LlmJobRepositoryTest`, `./gradlew test`, `./gradlew check` | API-020 키워드 분석 시작/재분석 Job 생성과 `KeywordAnalysis` persistence 구현됨. API-021 최신 결과 조회와 `KeywordAnalysisKeyword` 결과 저장 구조 구현됨. 실제 LLM 키워드 분석 worker는 후속 이슈로 분리 | | REQ-010 | AI 면접 | Planned | Medium | API-022, API-023, API-025 - API-029 | - | - | LLM Job/SSE 기반 | ## Open Maintenance Work @@ -59,7 +59,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메 | Candidate | Related REQ | Related APIs | Suggested Scope | |---|---|---|---| -| 최신 키워드 분석 조회 API | REQ-009 | API-021 | `KeywordAnalysis` 최신 상태와 완료 결과 조회 계약 구현 | +| 키워드 분석 worker 구현 | REQ-009 | API-020 이후 내부 실행 | OpenAI 키워드 분석 client/worker를 연결하고 완료 결과를 `KeywordAnalysisKeyword`로 저장 | | LLM Job 자동 재시도 | REQ-005, REQ-006 | API-014 이후 내부 실행 | provider/output 실패 시 Decision 017 기준 1회 자동 재시도 처리 | ## Update Rules diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java index 4b2980f..e95a360 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisController.java @@ -1,9 +1,11 @@ package com.daon.rewrite.keywordanalysis.controller; +import com.daon.rewrite.keywordanalysis.dto.LatestKeywordAnalysisResponse; import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisRequest; import com.daon.rewrite.keywordanalysis.dto.StartKeywordAnalysisResponse; import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,4 +29,11 @@ public StartKeywordAnalysisResponse startKeywordAnalysis( ) ); } + + @GetMapping("/cover-letters/{coverLetterId}/keyword-analysis/latest") + public LatestKeywordAnalysisResponse getLatestKeywordAnalysis(@PathVariable String coverLetterId) { + return LatestKeywordAnalysisResponse.from( + keywordAnalysisService.findMyLatestKeywordAnalysis(coverLetterId) + ); + } } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java new file mode 100644 index 0000000..5d79235 --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java @@ -0,0 +1,85 @@ +package com.daon.rewrite.keywordanalysis.dto; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +public record LatestKeywordAnalysisResponse( + String coverLetterId, + KeywordAnalysisDetailResponse keywordAnalysis +) { + + private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + + public static LatestKeywordAnalysisResponse from(LatestKeywordAnalysisResult result) { + if (result.keywordAnalysis() == null) { + return new LatestKeywordAnalysisResponse(result.coverLetterId(), null); + } + return new LatestKeywordAnalysisResponse( + result.coverLetterId(), + KeywordAnalysisDetailResponse.from(result.keywordAnalysis(), result.keywords()) + ); + } + + private record KeywordAnalysisDetailResponse( + String id, + String coverLetterId, + String sourceReviewVersionId, + String status, + List keywords, + ErrorResponse error, + LocalDateTime createdAt, + LocalDateTime completedAt + ) { + + private static KeywordAnalysisDetailResponse from( + KeywordAnalysis keywordAnalysis, + List keywords + ) { + return new KeywordAnalysisDetailResponse( + keywordAnalysis.getId(), + keywordAnalysis.getCoverLetter().getId(), + keywordAnalysis.getSourceReviewVersionId(), + keywordAnalysis.getStatus().name(), + keywords.stream() + .map(KeywordResponse::from) + .toList(), + ErrorResponse.from(keywordAnalysis), + LocalDateTime.ofInstant(keywordAnalysis.getCreatedAt(), API_ZONE), + keywordAnalysis.getCompletedAt() == null + ? null + : LocalDateTime.ofInstant(keywordAnalysis.getCompletedAt(), API_ZONE) + ); + } + } + + private record KeywordResponse( + String keyword, + int importance + ) { + + private static KeywordResponse from(KeywordAnalysisKeyword keyword) { + return new KeywordResponse(keyword.getKeyword(), keyword.getImportance()); + } + } + + private record ErrorResponse( + String code, + String message + ) { + + private static ErrorResponse from(KeywordAnalysis keywordAnalysis) { + if (keywordAnalysis.getErrorCode() == null) { + return null; + } + return new ErrorResponse( + keywordAnalysis.getErrorCode(), + keywordAnalysis.getErrorMessage() + ); + } + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java index cae9505..f9381ae 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java @@ -50,6 +50,12 @@ public class KeywordAnalysis { @Column(name = "completed_at") private Instant completedAt; + @Column(name = "error_code", length = 64) + private String errorCode; + + @Column(name = "error_message", length = 500) + private String errorMessage; + private KeywordAnalysis( String id, CoverLetter coverLetter, @@ -83,5 +89,21 @@ public void restart(String sourceReviewVersionId) { this.sourceReviewVersionId = sourceReviewVersionId; this.status = KeywordAnalysisStatus.PROCESSING; this.completedAt = null; + this.errorCode = null; + this.errorMessage = null; + } + + public void complete(Instant completedAt) { + this.status = KeywordAnalysisStatus.COMPLETED; + this.completedAt = completedAt; + this.errorCode = null; + this.errorMessage = null; + } + + public void fail(String errorCode, String errorMessage, Instant completedAt) { + this.status = KeywordAnalysisStatus.FAILED; + this.completedAt = completedAt; + this.errorCode = errorCode; + this.errorMessage = errorMessage; } } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java new file mode 100644 index 0000000..533832d --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysisKeyword.java @@ -0,0 +1,73 @@ +package com.daon.rewrite.keywordanalysis.entity; + +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 = "keyword_analysis_keywords", + uniqueConstraints = @UniqueConstraint( + name = "uk_keyword_analysis_keywords_order", + columnNames = {"keyword_analysis_id", "keyword_order"} + ) +) +public class KeywordAnalysisKeyword { + + @Id + @Column(name = "id", nullable = false, length = 64) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "keyword_analysis_id", nullable = false) + private KeywordAnalysis keywordAnalysis; + + @Column(name = "keyword_order", nullable = false) + private int keywordOrder; + + @Column(name = "keyword", nullable = false, length = 100) + private String keyword; + + @Column(name = "importance", nullable = false) + private int importance; + + private KeywordAnalysisKeyword( + String id, + KeywordAnalysis keywordAnalysis, + int keywordOrder, + String keyword, + int importance + ) { + this.id = id; + this.keywordAnalysis = keywordAnalysis; + this.keywordOrder = keywordOrder; + this.keyword = keyword; + this.importance = importance; + } + + public static KeywordAnalysisKeyword of( + String id, + KeywordAnalysis keywordAnalysis, + int keywordOrder, + String keyword, + int importance + ) { + return new KeywordAnalysisKeyword( + id, + keywordAnalysis, + keywordOrder, + keyword, + importance + ); + } +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java new file mode 100644 index 0000000..3eecadd --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepository.java @@ -0,0 +1,11 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface KeywordAnalysisKeywordRepository extends JpaRepository { + + List findByKeywordAnalysisIdOrderByKeywordOrderAsc(String keywordAnalysisId); +} diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java index 4881ad0..e6fe5cd 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisService.java @@ -9,6 +9,8 @@ import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; import com.daon.rewrite.llmjob.entity.LlmJob; import com.daon.rewrite.llmjob.entity.LlmJobStatus; @@ -40,6 +42,7 @@ public class KeywordAnalysisService { private final CoverLetterRepository coverLetterRepository; private final ReviewVersionRepository reviewVersionRepository; private final KeywordAnalysisRepository keywordAnalysisRepository; + private final KeywordAnalysisKeywordRepository keywordAnalysisKeywordRepository; private final LlmJobRepository llmJobRepository; private final IdGenerator idGenerator; private final Clock clock; @@ -87,6 +90,22 @@ public StartKeywordAnalysisResult startMyKeywordAnalysis( return new StartKeywordAnalysisResult(keywordAnalysis, job); } + @Transactional(readOnly = true) + public LatestKeywordAnalysisResult findMyLatestKeywordAnalysis(String coverLetterId) { + CurrentUser currentUser = currentUserProvider.currentUser(); + CoverLetter coverLetter = coverLetterRepository + .findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + + return keywordAnalysisRepository.findByCoverLetterId(coverLetter.getId()) + .map(keywordAnalysis -> { + List keywords = keywordAnalysisKeywordRepository + .findByKeywordAnalysisIdOrderByKeywordOrderAsc(keywordAnalysis.getId()); + return LatestKeywordAnalysisResult.of(coverLetter.getId(), keywordAnalysis, keywords); + }) + .orElseGet(() -> LatestKeywordAnalysisResult.empty(coverLetter.getId())); + } + private String selectSourceReviewVersionId(CoverLetter coverLetter, String sourceReviewVersionId) { String normalizedSourceReviewVersionId = normalize(sourceReviewVersionId); String selectedSourceReviewVersionId = normalizedSourceReviewVersionId == null diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java b/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java new file mode 100644 index 0000000..186496e --- /dev/null +++ b/src/main/java/com/daon/rewrite/keywordanalysis/service/LatestKeywordAnalysisResult.java @@ -0,0 +1,25 @@ +package com.daon.rewrite.keywordanalysis.service; + +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; + +import java.util.List; + +public record LatestKeywordAnalysisResult( + String coverLetterId, + KeywordAnalysis keywordAnalysis, + List keywords +) { + + public static LatestKeywordAnalysisResult empty(String coverLetterId) { + return new LatestKeywordAnalysisResult(coverLetterId, null, List.of()); + } + + public static LatestKeywordAnalysisResult of( + String coverLetterId, + KeywordAnalysis keywordAnalysis, + List keywords + ) { + return new LatestKeywordAnalysisResult(coverLetterId, keywordAnalysis, keywords); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java index c9fc0ca..93c92ca 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java @@ -4,7 +4,9 @@ import com.daon.rewrite.global.exception.BusinessException; import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult; import com.daon.rewrite.keywordanalysis.service.KeywordAnalysisService; import com.daon.rewrite.keywordanalysis.service.StartKeywordAnalysisResult; import com.daon.rewrite.llmjob.entity.LlmJob; @@ -16,8 +18,10 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; +import java.util.List; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -103,4 +107,80 @@ void startKeywordAnalysisReturnsConflictWhenJobAlreadyRunning() throws Exception .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("LLM_JOB_ALREADY_RUNNING")); } + + @Test + void getLatestKeywordAnalysisReturnsNullWhenAnalysisDoesNotExist() throws Exception { + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.empty("cl_1")); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coverLetterId").value("cl_1")) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); + } + + @Test + void getLatestKeywordAnalysisReturnsCompletedAnalysisWithOrderedKeywords() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.complete(now.plusSeconds(90)); + List keywords = List.of( + KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95), + KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88) + ); + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.of("cl_1", keywordAnalysis, keywords)); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.coverLetterId").value("cl_1")) + .andExpect(jsonPath("$.keywordAnalysis.id").value("ka_1")) + .andExpect(jsonPath("$.keywordAnalysis.coverLetterId").value("cl_1")) + .andExpect(jsonPath("$.keywordAnalysis.sourceReviewVersionId").value("rv_1")) + .andExpect(jsonPath("$.keywordAnalysis.status").value("COMPLETED")) + .andExpect(jsonPath("$.keywordAnalysis.keywords[0].keyword").value("백엔드")) + .andExpect(jsonPath("$.keywordAnalysis.keywords[0].importance").value(95)) + .andExpect(jsonPath("$.keywordAnalysis.keywords[1].keyword").value("Spring")) + .andExpect(jsonPath("$.keywordAnalysis.error").doesNotExist()) + .andExpect(jsonPath("$.keywordAnalysis.createdAt").value("2026-07-03T10:01:00")) + .andExpect(jsonPath("$.keywordAnalysis.completedAt").value("2026-07-03T10:01:30")); + } + + @Test + void getLatestKeywordAnalysisReturnsFailedAnalysisWithError() throws Exception { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(90)); + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) + .willReturn(LatestKeywordAnalysisResult.of("cl_1", keywordAnalysis, List.of())); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.keywordAnalysis.status").value("FAILED")) + .andExpect(jsonPath("$.keywordAnalysis.keywords").isEmpty()) + .andExpect(jsonPath("$.keywordAnalysis.error.code").value("LLM_PROVIDER_ERROR")) + .andExpect(jsonPath("$.keywordAnalysis.error.message").value("키워드 분석에 실패했습니다.")); + } + + @Test + void getLatestKeywordAnalysisReturnsNotFound() throws Exception { + given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_missing")) + .willThrow(new BusinessException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_missing")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); + } } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java new file mode 100644 index 0000000..c92e5a1 --- /dev/null +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisKeywordRepositoryTest.java @@ -0,0 +1,61 @@ +package com.daon.rewrite.keywordanalysis.repository; + +import com.daon.rewrite.coverletter.entity.CoverLetter; +import com.daon.rewrite.coverletter.repository.CoverLetterRepository; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; +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 static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class KeywordAnalysisKeywordRepositoryTest { + + @Autowired + private KeywordAnalysisKeywordRepository repository; + + @Autowired + private KeywordAnalysisRepository keywordAnalysisRepository; + + @Autowired + private CoverLetterRepository coverLetterRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void findByKeywordAnalysisIdReturnsKeywordsOrderedByKeywordOrder() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.save(KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + )); + repository.save(KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88)); + repository.save(KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95)); + + entityManager.flush(); + entityManager.clear(); + + assertThat(repository.findByKeywordAnalysisIdOrderByKeywordOrderAsc("ka_1")) + .extracting( + KeywordAnalysisKeyword::getId, + KeywordAnalysisKeyword::getKeywordOrder, + KeywordAnalysisKeyword::getKeyword, + KeywordAnalysisKeyword::getImportance + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("kak_1", 1, "백엔드", 95), + org.assertj.core.groups.Tuple.tuple("kak_2", 2, "Spring", 88) + ); + } +} diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java index 5450f72..bcbebe8 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java @@ -52,4 +52,28 @@ void saveAndFindProcessingKeywordAnalysisRoundTripsThroughJpa() { assertThat(found.getCompletedAt()).isNull(); } + @Test + void saveAndFindFailedKeywordAnalysisRoundTripsErrorFieldsThroughJpa() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(60) + ); + keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(90)); + + repository.save(keywordAnalysis); + entityManager.flush(); + entityManager.clear(); + + KeywordAnalysis found = repository.findByCoverLetterId("cl_1").orElseThrow(); + + assertThat(found.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(found.getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + assertThat(found.getErrorMessage()).isEqualTo("키워드 분석에 실패했습니다."); + assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(90)); + } + } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java index 38a61b7..3281809 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java @@ -8,7 +8,9 @@ import com.daon.rewrite.global.exception.ErrorCode; import com.daon.rewrite.global.util.IdGenerator; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; +import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisStatus; +import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisKeywordRepository; import com.daon.rewrite.keywordanalysis.repository.KeywordAnalysisRepository; import com.daon.rewrite.llmjob.entity.LlmJob; import com.daon.rewrite.llmjob.entity.LlmJobStatus; @@ -40,6 +42,9 @@ class KeywordAnalysisServiceTest { @Autowired private KeywordAnalysisRepository keywordAnalysisRepository; + @Autowired + private KeywordAnalysisKeywordRepository keywordAnalysisKeywordRepository; + @Autowired private LlmJobRepository llmJobRepository; @@ -60,6 +65,7 @@ class KeywordAnalysisServiceTest { @AfterEach void cleanUp() { + keywordAnalysisKeywordRepository.deleteAll(); keywordAnalysisRepository.deleteAll(); reviewVersionRepository.deleteAll(); llmJobRepository.deleteAll(); @@ -170,6 +176,83 @@ void startMyKeywordAnalysisRejectsWhenJobAlreadyRunning() { .isEqualTo(ErrorCode.LLM_JOB_ALREADY_RUNNING); } + @Test + void findMyLatestKeywordAnalysisReturnsEmptyWhenAnalysisDoesNotExist() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.coverLetterId()).isEqualTo("cl_1"); + assertThat(result.keywordAnalysis()).isNull(); + assertThat(result.keywords()).isEmpty(); + } + + @Test + void findMyLatestKeywordAnalysisReturnsCompletedAnalysisWithOrderedKeywords() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + KeywordAnalysis keywordAnalysis = keywordAnalysisRepository.save(KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(120) + )); + keywordAnalysis.complete(now.plusSeconds(180)); + keywordAnalysisRepository.save(keywordAnalysis); + keywordAnalysisKeywordRepository.save(KeywordAnalysisKeyword.of("kak_2", keywordAnalysis, 2, "Spring", 88)); + keywordAnalysisKeywordRepository.save(KeywordAnalysisKeyword.of("kak_1", keywordAnalysis, 1, "백엔드", 95)); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.keywordAnalysis().getId()).isEqualTo("ka_1"); + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.COMPLETED); + assertThat(result.keywords()) + .extracting(KeywordAnalysisKeyword::getKeyword, KeywordAnalysisKeyword::getImportance) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("백엔드", 95), + org.assertj.core.groups.Tuple.tuple("Spring", 88) + ); + } + + @Test + void findMyLatestKeywordAnalysisReturnsFailedAnalysisWithError() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + CoverLetter coverLetter = saveReviewedCoverLetter("cl_1", "user_1", "rv_1", now); + KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( + "ka_1", + coverLetter, + "rv_1", + now.plusSeconds(120) + ); + keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(180)); + keywordAnalysisRepository.save(keywordAnalysis); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); + + assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); + assertThat(result.keywordAnalysis().getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); + assertThat(result.keywordAnalysis().getErrorMessage()).isEqualTo("키워드 분석에 실패했습니다."); + assertThat(result.keywords()).isEmpty(); + } + + @Test + void findMyLatestKeywordAnalysisRejectsMissingOtherOwnerOrDeletedCoverLetter() { + Instant now = Instant.parse("2026-07-03T01:00:00Z"); + saveReviewedCoverLetter("cl_other", "user_2", "rv_other", now); + CoverLetter deleted = saveReviewedCoverLetter("cl_deleted", "user_1", "rv_deleted", now); + deleted.markDeleted(now.plusSeconds(120)); + coverLetterRepository.save(deleted); + given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); + + assertLatestNotFound("cl_missing"); + assertLatestNotFound("cl_other"); + assertLatestNotFound("cl_deleted"); + } + private void assertNotFound(String coverLetterId) { assertThatThrownBy(() -> service.startMyKeywordAnalysis(coverLetterId, null)) .isInstanceOf(BusinessException.class) @@ -177,6 +260,13 @@ private void assertNotFound(String coverLetterId) { .isEqualTo(ErrorCode.NOT_FOUND); } + private void assertLatestNotFound(String coverLetterId) { + assertThatThrownBy(() -> service.findMyLatestKeywordAnalysis(coverLetterId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_FOUND); + } + private CoverLetter saveReviewedCoverLetter(String coverLetterId, String ownerId, String versionId, Instant now) { CoverLetter coverLetter = CoverLetter.draft(coverLetterId, ownerId, now); coverLetterRepository.save(coverLetter); From af43eebec4a36592e9b7e8c121d1795d2af6d068 Mon Sep 17 00:00:00 2001 From: yong203 Date: Sun, 5 Jul 2026 19:48:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?:recycle:=20refactor:=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/keyword-analysis.md | 67 ++++++------------- docs/erd.md | 3 - .../dto/LatestKeywordAnalysisResponse.java | 65 +++--------------- .../entity/KeywordAnalysis.java | 14 +--- .../KeywordAnalysisControllerTest.java | 34 +++++----- .../KeywordAnalysisRepositoryTest.java | 6 +- .../service/KeywordAnalysisServiceTest.java | 4 +- 7 files changed, 47 insertions(+), 146 deletions(-) diff --git a/docs/api/keyword-analysis.md b/docs/api/keyword-analysis.md index a375ea0..4454198 100644 --- a/docs/api/keyword-analysis.md +++ b/docs/api/keyword-analysis.md @@ -76,12 +76,15 @@ Conflict Response: GET /cover-letters/{coverLetterId}/keyword-analysis/latest ``` +`status`는 `NOT_STARTED`, `PROCESSING`, `COMPLETED`, `FAILED` 중 하나다. +`keywords`는 완료 상태에서만 분석 결과를 담고, 그 외 상태에서는 빈 배열이다. + 키워드 분석 결과가 없는 경우: ```json { - "coverLetterId": "cl_01HZ...", - "keywordAnalysis": null + "status": "NOT_STARTED", + "keywords": [] } ``` @@ -89,17 +92,8 @@ GET /cover-letters/{coverLetterId}/keyword-analysis/latest ```json { - "coverLetterId": "cl_01HZ...", - "keywordAnalysis": { - "id": "ka_01HZ...", - "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", - "status": "PROCESSING", - "keywords": [], - "error": null, - "createdAt": "2026-06-20T15:00:00", - "completedAt": null - } + "status": "PROCESSING", + "keywords": [] } ``` @@ -107,26 +101,17 @@ Response: ```json { - "coverLetterId": "cl_01HZ...", - "keywordAnalysis": { - "id": "ka_01HZ...", - "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", - "status": "COMPLETED", - "keywords": [ - { - "keyword": "백엔드", - "importance": 95 - }, - { - "keyword": "Spring", - "importance": 88 - } - ], - "error": null, - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:00:30" - } + "status": "COMPLETED", + "keywords": [ + { + "keyword": "백엔드", + "importance": 95 + }, + { + "keyword": "Spring", + "importance": 88 + } + ] } ``` @@ -134,19 +119,7 @@ Response: ```json { - "coverLetterId": "cl_01HZ...", - "keywordAnalysis": { - "id": "ka_01HZ...", - "coverLetterId": "cl_01HZ...", - "sourceReviewVersionId": "rv_01HZ...", - "status": "FAILED", - "keywords": [], - "error": { - "code": "LLM_PROVIDER_ERROR", - "message": "키워드 분석에 실패했습니다." - }, - "createdAt": "2026-06-20T15:00:00", - "completedAt": "2026-06-20T15:01:00" - } + "status": "FAILED", + "keywords": [] } ``` diff --git a/docs/erd.md b/docs/erd.md index 482423e..3944f9c 100644 --- a/docs/erd.md +++ b/docs/erd.md @@ -317,8 +317,6 @@ Policy: | `status` | No | `PROCESSING`, `COMPLETED`, `FAILED` | | `created_at` | No | 생성 시각 | | `completed_at` | Yes | 완료 또는 실패 전까지 null | -| `error_code` | Yes | 실패 상태의 에러 코드 | -| `error_message` | Yes | 실패 상태의 사용자 표시 가능 메시지 | Policy: @@ -326,7 +324,6 @@ Policy: - 재분석은 같은 row를 갱신한다. - 재첨삭 완료만으로 기존 키워드 분석 결과를 삭제하지 않는다. - 사용자가 재분석을 실행하면 같은 row를 최신 첨삭 버전 기준으로 갱신한다. -- 재분석을 시작하면 이전 실패 메타데이터는 지운다. ### keyword_analysis_keywords diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java index 5d79235..1c78e94 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/dto/LatestKeywordAnalysisResponse.java @@ -1,60 +1,27 @@ package com.daon.rewrite.keywordanalysis.dto; -import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysis; import com.daon.rewrite.keywordanalysis.entity.KeywordAnalysisKeyword; import com.daon.rewrite.keywordanalysis.service.LatestKeywordAnalysisResult; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.List; public record LatestKeywordAnalysisResponse( - String coverLetterId, - KeywordAnalysisDetailResponse keywordAnalysis + String status, + List keywords ) { - private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul"); + private static final String NOT_STARTED_STATUS = "NOT_STARTED"; public static LatestKeywordAnalysisResponse from(LatestKeywordAnalysisResult result) { if (result.keywordAnalysis() == null) { - return new LatestKeywordAnalysisResponse(result.coverLetterId(), null); + return new LatestKeywordAnalysisResponse(NOT_STARTED_STATUS, List.of()); } return new LatestKeywordAnalysisResponse( - result.coverLetterId(), - KeywordAnalysisDetailResponse.from(result.keywordAnalysis(), result.keywords()) - ); - } - - private record KeywordAnalysisDetailResponse( - String id, - String coverLetterId, - String sourceReviewVersionId, - String status, - List keywords, - ErrorResponse error, - LocalDateTime createdAt, - LocalDateTime completedAt - ) { - - private static KeywordAnalysisDetailResponse from( - KeywordAnalysis keywordAnalysis, - List keywords - ) { - return new KeywordAnalysisDetailResponse( - keywordAnalysis.getId(), - keywordAnalysis.getCoverLetter().getId(), - keywordAnalysis.getSourceReviewVersionId(), - keywordAnalysis.getStatus().name(), - keywords.stream() - .map(KeywordResponse::from) - .toList(), - ErrorResponse.from(keywordAnalysis), - LocalDateTime.ofInstant(keywordAnalysis.getCreatedAt(), API_ZONE), - keywordAnalysis.getCompletedAt() == null - ? null - : LocalDateTime.ofInstant(keywordAnalysis.getCompletedAt(), API_ZONE) + result.keywordAnalysis().getStatus().name(), + result.keywords().stream() + .map(KeywordResponse::from) + .toList() ); - } } private record KeywordResponse( @@ -66,20 +33,4 @@ private static KeywordResponse from(KeywordAnalysisKeyword keyword) { return new KeywordResponse(keyword.getKeyword(), keyword.getImportance()); } } - - private record ErrorResponse( - String code, - String message - ) { - - private static ErrorResponse from(KeywordAnalysis keywordAnalysis) { - if (keywordAnalysis.getErrorCode() == null) { - return null; - } - return new ErrorResponse( - keywordAnalysis.getErrorCode(), - keywordAnalysis.getErrorMessage() - ); - } - } } diff --git a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java index f9381ae..8507f21 100644 --- a/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java +++ b/src/main/java/com/daon/rewrite/keywordanalysis/entity/KeywordAnalysis.java @@ -50,12 +50,6 @@ public class KeywordAnalysis { @Column(name = "completed_at") private Instant completedAt; - @Column(name = "error_code", length = 64) - private String errorCode; - - @Column(name = "error_message", length = 500) - private String errorMessage; - private KeywordAnalysis( String id, CoverLetter coverLetter, @@ -89,21 +83,15 @@ public void restart(String sourceReviewVersionId) { this.sourceReviewVersionId = sourceReviewVersionId; this.status = KeywordAnalysisStatus.PROCESSING; this.completedAt = null; - this.errorCode = null; - this.errorMessage = null; } public void complete(Instant completedAt) { this.status = KeywordAnalysisStatus.COMPLETED; this.completedAt = completedAt; - this.errorCode = null; - this.errorMessage = null; } - public void fail(String errorCode, String errorMessage, Instant completedAt) { + public void fail(Instant completedAt) { this.status = KeywordAnalysisStatus.FAILED; this.completedAt = completedAt; - this.errorCode = errorCode; - this.errorMessage = errorMessage; } } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java index 93c92ca..125f680 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/controller/KeywordAnalysisControllerTest.java @@ -109,13 +109,15 @@ void startKeywordAnalysisReturnsConflictWhenJobAlreadyRunning() throws Exception } @Test - void getLatestKeywordAnalysisReturnsNullWhenAnalysisDoesNotExist() throws Exception { + void getLatestKeywordAnalysisReturnsNotStartedStatusWhenAnalysisDoesNotExist() throws Exception { given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) .willReturn(LatestKeywordAnalysisResult.empty("cl_1")); mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.coverLetterId").value("cl_1")) + .andExpect(jsonPath("$.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.keywords").isEmpty()) + .andExpect(jsonPath("$.coverLetterId").doesNotExist()) .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); } @@ -139,21 +141,16 @@ void getLatestKeywordAnalysisReturnsCompletedAnalysisWithOrderedKeywords() throw mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.coverLetterId").value("cl_1")) - .andExpect(jsonPath("$.keywordAnalysis.id").value("ka_1")) - .andExpect(jsonPath("$.keywordAnalysis.coverLetterId").value("cl_1")) - .andExpect(jsonPath("$.keywordAnalysis.sourceReviewVersionId").value("rv_1")) - .andExpect(jsonPath("$.keywordAnalysis.status").value("COMPLETED")) - .andExpect(jsonPath("$.keywordAnalysis.keywords[0].keyword").value("백엔드")) - .andExpect(jsonPath("$.keywordAnalysis.keywords[0].importance").value(95)) - .andExpect(jsonPath("$.keywordAnalysis.keywords[1].keyword").value("Spring")) - .andExpect(jsonPath("$.keywordAnalysis.error").doesNotExist()) - .andExpect(jsonPath("$.keywordAnalysis.createdAt").value("2026-07-03T10:01:00")) - .andExpect(jsonPath("$.keywordAnalysis.completedAt").value("2026-07-03T10:01:30")); + .andExpect(jsonPath("$.status").value("COMPLETED")) + .andExpect(jsonPath("$.keywords[0].keyword").value("백엔드")) + .andExpect(jsonPath("$.keywords[0].importance").value(95)) + .andExpect(jsonPath("$.keywords[1].keyword").value("Spring")) + .andExpect(jsonPath("$.coverLetterId").doesNotExist()) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); } @Test - void getLatestKeywordAnalysisReturnsFailedAnalysisWithError() throws Exception { + void getLatestKeywordAnalysisReturnsFailedStatus() throws Exception { Instant now = Instant.parse("2026-07-03T01:00:00Z"); CoverLetter coverLetter = CoverLetter.draft("cl_1", "user_1", now); KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( @@ -162,16 +159,15 @@ void getLatestKeywordAnalysisReturnsFailedAnalysisWithError() throws Exception { "rv_1", now.plusSeconds(60) ); - keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(90)); + keywordAnalysis.fail(now.plusSeconds(90)); given(keywordAnalysisService.findMyLatestKeywordAnalysis("cl_1")) .willReturn(LatestKeywordAnalysisResult.of("cl_1", keywordAnalysis, List.of())); mockMvc.perform(get("/cover-letters/{coverLetterId}/keyword-analysis/latest", "cl_1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.keywordAnalysis.status").value("FAILED")) - .andExpect(jsonPath("$.keywordAnalysis.keywords").isEmpty()) - .andExpect(jsonPath("$.keywordAnalysis.error.code").value("LLM_PROVIDER_ERROR")) - .andExpect(jsonPath("$.keywordAnalysis.error.message").value("키워드 분석에 실패했습니다.")); + .andExpect(jsonPath("$.status").value("FAILED")) + .andExpect(jsonPath("$.keywords").isEmpty()) + .andExpect(jsonPath("$.keywordAnalysis").doesNotExist()); } @Test diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java index bcbebe8..a328740 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/repository/KeywordAnalysisRepositoryTest.java @@ -53,7 +53,7 @@ void saveAndFindProcessingKeywordAnalysisRoundTripsThroughJpa() { } @Test - void saveAndFindFailedKeywordAnalysisRoundTripsErrorFieldsThroughJpa() { + void saveAndFindFailedKeywordAnalysisRoundTripsThroughJpa() { Instant now = Instant.parse("2026-07-03T01:00:00Z"); CoverLetter coverLetter = coverLetterRepository.save(CoverLetter.draft("cl_1", "user_1", now)); KeywordAnalysis keywordAnalysis = KeywordAnalysis.processing( @@ -62,7 +62,7 @@ void saveAndFindFailedKeywordAnalysisRoundTripsErrorFieldsThroughJpa() { "rv_1", now.plusSeconds(60) ); - keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(90)); + keywordAnalysis.fail(now.plusSeconds(90)); repository.save(keywordAnalysis); entityManager.flush(); @@ -71,8 +71,6 @@ void saveAndFindFailedKeywordAnalysisRoundTripsErrorFieldsThroughJpa() { KeywordAnalysis found = repository.findByCoverLetterId("cl_1").orElseThrow(); assertThat(found.getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); - assertThat(found.getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); - assertThat(found.getErrorMessage()).isEqualTo("키워드 분석에 실패했습니다."); assertThat(found.getCompletedAt()).isEqualTo(now.plusSeconds(90)); } diff --git a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java index 3281809..04221f0 100644 --- a/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java +++ b/src/test/java/com/daon/rewrite/keywordanalysis/service/KeywordAnalysisServiceTest.java @@ -227,15 +227,13 @@ void findMyLatestKeywordAnalysisReturnsFailedAnalysisWithError() { "rv_1", now.plusSeconds(120) ); - keywordAnalysis.fail("LLM_PROVIDER_ERROR", "키워드 분석에 실패했습니다.", now.plusSeconds(180)); + keywordAnalysis.fail(now.plusSeconds(180)); keywordAnalysisRepository.save(keywordAnalysis); given(currentUserProvider.currentUser()).willReturn(new CurrentUser("user_1", "테스트", null)); LatestKeywordAnalysisResult result = service.findMyLatestKeywordAnalysis("cl_1"); assertThat(result.keywordAnalysis().getStatus()).isEqualTo(KeywordAnalysisStatus.FAILED); - assertThat(result.keywordAnalysis().getErrorCode()).isEqualTo("LLM_PROVIDER_ERROR"); - assertThat(result.keywordAnalysis().getErrorMessage()).isEqualTo("키워드 분석에 실패했습니다."); assertThat(result.keywords()).isEmpty(); }