From 003586066fd412b580436796090b9f5024f5f6e0 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 28 Jun 2026 23:26:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(cover-letter):=20=EC=9E=90=EC=86=8C?= =?UTF-8?q?=EC=84=9C(=EA=B3=B5=EC=B1=84)=20=EB=AC=B8=ED=95=AD=EB=B3=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20+=20=EB=B6=84=EC=84=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대기업 공채는 포트폴리오보다 자소서 + 면접 자기소개 중심이라, 자소서를 새 자료 유형으로 추가. 문항별(질문+답변) 텍스트 입력. 분석→임베딩→세션컨텍스트→질문생성 파이프라인은 source 무관이라 그대로 재사용. DB (V20): - cover_letters(items JSONB: [{question,answer}], status) 추가 - analyzed_documents.cover_letter_id FK + 단일소스 CHECK 를 3-택1로 확장 Backend (이력서 흐름 미러링): - coverletter 도메인(엔티티/서비스/컨트롤러) — POST/GET/DELETE /api/cover-letters - 생성→CoverLetterUploadedEvent→requestCoverLetterAnalysis: 문항을 마크다운으로 합쳐 inline 으로 analyze.cover_letter 발행(S3 미사용) - AnalyzedDocument.forCoverLetter + AnalysisCallbackService COVER_LETTER 처리 - resolveSourceType(질문생성·문서조회 DTO) COVER_LETTER 분기 - RabbitMQ ai.analyze.cover_letter 큐 + DLQ (definitions.json/RabbitMqConfig) AI: - TargetType/SourceType COVER_LETTER, TextSourceExtractor(inline 본문), cover_letter_consumer(ResumeAnalyzer 재사용), 자소서 전용 분석 프롬프트 Frontend: - features/cover-letter(문항 반복 입력 폼 + 목록) + 워크스페이스 '자소서' 섹션/네비/라우트 - 세션 자료 선택기는 source 무관이라 자동 포함, DocumentList COVER_LETTER 라벨 검증: 백엔드 ./gradlew test 통과, AI 297 passed + flake8 clean, 프론트 npm run build 통과. OpenAPI/프론트 타입 재생성. 회의적 검증으로 read DTO(AnalyzedDocumentResult) 누락 분기도 보강. Co-Authored-By: Claude Opus 4.8 --- ai/CLAUDE.md | 1 + ai/src/ai_server/analyzer/sources/__init__.py | 2 + ai/src/ai_server/analyzer/sources/base.py | 2 +- ai/src/ai_server/analyzer/sources/text.py | 22 +++ .../chain/prompts/document_analysis.py | 6 +- ai/src/ai_server/config/settings.py | 6 + .../consumers/cover_letter_consumer.py | 152 ++++++++++++++++ ai/src/ai_server/messaging/runner.py | 25 +++ ai/src/ai_server/model/messages/analyze.py | 11 +- ai/tests/test_cover_letter_consumer.py | 107 +++++++++++ backend/CLAUDE.md | 3 +- backend/openapi.json | 170 ++++++++++++++++++ .../config/properties/RabbitMqProperties.java | 2 + .../common/exception/ApiErrorCode.java | 3 + .../common/messaging/RabbitMqConfig.java | 11 ++ .../application/CoverLetterService.java | 105 +++++++++++ .../dto/CoverLetterCreateCommand.java | 9 + .../application/dto/CoverLetterItem.java | 8 + .../application/dto/CoverLetterResult.java | 26 +++ .../event/CoverLetterDeletedEvent.java | 5 + .../event/CoverLetterUploadedEvent.java | 9 + .../coverletter/domain/CoverLetter.java | 82 +++++++++ .../domain/CoverLetterRepository.java | 12 ++ .../coverletter/domain/CoverLetterStatus.java | 8 + .../presentation/CoverLetterController.java | 79 ++++++++ .../dto/CoverLetterCreateRequest.java | 20 +++ .../presentation/dto/CoverLetterResponse.java | 32 ++++ .../application/AnalysisCallbackService.java | 12 ++ .../application/AnalysisRequestService.java | 68 +++++++ .../AnalyzedDocumentCascadeListener.java | 15 ++ .../CoverLetterAnalysisEventListener.java | 22 +++ .../dto/AnalyzeCoverLetterPayload.java | 9 + .../dto/AnalyzedDocumentResult.java | 4 +- .../document/domain/AnalyzedDocument.java | 22 ++- .../domain/AnalyzedDocumentRepository.java | 18 +- .../SessionQuestionsRequester.java | 1 + backend/src/main/resources/application.yml | 2 + .../db/migration/V20__add_cover_letters.sql | 38 ++++ .../stackup/StackupApplicationTests.java | 3 + .../messaging/RabbitMessagePublisherTest.java | 2 + .../common/messaging/RabbitMqConfigTest.java | 2 + .../common/openapi/OpenApiSpecExportTest.java | 2 + .../application/CoverLetterServiceTest.java | 81 +++++++++ .../SessionFeedbackRequesterTest.java | 2 +- .../SessionQuestionsRequesterTest.java | 2 +- .../VoiceAnswerUploadServiceTest.java | 2 + docs/database.md | 3 +- docs/messaging.md | 2 + frontend/src/app/router/index.tsx | 8 + frontend/src/features/analysis/model/types.ts | 2 +- .../src/features/analysis/ui/DocumentList.tsx | 1 + .../features/cover-letter/api/coverLetter.ts | 18 ++ frontend/src/features/cover-letter/index.ts | 14 ++ .../src/features/cover-letter/model/types.ts | 8 + .../cover-letter/model/useCoverLetters.ts | 50 ++++++ .../cover-letter/ui/CoverLetterForm.tsx | 119 ++++++++++++ .../cover-letter/ui/CoverLetterList.tsx | 150 ++++++++++++++++ .../pages/Workspace/ui/CoverLettersView.tsx | 26 +++ .../src/pages/Workspace/ui/WorkspacePage.tsx | 9 +- frontend/src/shared/api/generated.ts | 163 +++++++++++++++++ .../workspace-sidebar/ui/WorkspaceSidebar.tsx | 19 ++ infra/rabbitmq/definitions.json | 31 ++++ 62 files changed, 1832 insertions(+), 16 deletions(-) create mode 100644 ai/src/ai_server/analyzer/sources/text.py create mode 100644 ai/src/ai_server/messaging/consumers/cover_letter_consumer.py create mode 100644 ai/tests/test_cover_letter_consumer.py create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/CoverLetterService.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterCreateCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterItem.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterDeletedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterUploadedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetter.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterStatus.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/presentation/CoverLetterController.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterCreateRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/CoverLetterAnalysisEventListener.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeCoverLetterPayload.java create mode 100644 backend/src/main/resources/db/migration/V20__add_cover_letters.sql create mode 100644 backend/src/test/java/com/stackup/stackup/coverletter/application/CoverLetterServiceTest.java create mode 100644 frontend/src/features/cover-letter/api/coverLetter.ts create mode 100644 frontend/src/features/cover-letter/index.ts create mode 100644 frontend/src/features/cover-letter/model/types.ts create mode 100644 frontend/src/features/cover-letter/model/useCoverLetters.ts create mode 100644 frontend/src/features/cover-letter/ui/CoverLetterForm.tsx create mode 100644 frontend/src/features/cover-letter/ui/CoverLetterList.tsx create mode 100644 frontend/src/pages/Workspace/ui/CoverLettersView.tsx diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index a527f714..053ae497 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -94,6 +94,7 @@ ai/ | `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) | | `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) | | `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | +| `ai.analyze.cover_letter` | `analyze.cover_letter` | 본 구현 (자소서 문항 inline 텍스트 → MD, `TextSourceExtractor`) | | `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) | | `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) | | `ai.generate.tts` | `generate.tts` | 본 구현 (질문 음성화, OpenAI TTS → S3 → `callback.tts`) | diff --git a/ai/src/ai_server/analyzer/sources/__init__.py b/ai/src/ai_server/analyzer/sources/__init__.py index 53344b6e..8f4c9392 100644 --- a/ai/src/ai_server/analyzer/sources/__init__.py +++ b/ai/src/ai_server/analyzer/sources/__init__.py @@ -8,6 +8,7 @@ RepositoryFetchError, ) from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.text import TextSourceExtractor from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor __all__ = [ @@ -15,6 +16,7 @@ "SourceExtractor", "SourceType", "PdfSourceExtractor", + "TextSourceExtractor", "GitHubRepoSourceExtractor", "RepositoryFetchError", "WebSourceExtractor", diff --git a/ai/src/ai_server/analyzer/sources/base.py b/ai/src/ai_server/analyzer/sources/base.py index b8a4e0cb..cf4d56d0 100644 --- a/ai/src/ai_server/analyzer/sources/base.py +++ b/ai/src/ai_server/analyzer/sources/base.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field -SourceType = Literal["PDF", "REPOSITORY", "WEB"] +SourceType = Literal["PDF", "REPOSITORY", "WEB", "COVER_LETTER"] # 모든 Source Extractor가 공통으로 반환하는 결과 모델 diff --git a/ai/src/ai_server/analyzer/sources/text.py b/ai/src/ai_server/analyzer/sources/text.py new file mode 100644 index 00000000..393699ae --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/text.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from ai_server.analyzer.sources.base import ( + ExtractedSource, + SourceExtractor, + SourceType, +) + + +# inline 텍스트(자소서 문항 마크다운 등)를 그대로 ExtractedSource 로 감싼다. +# locator 자체가 본문 — S3/URL fetch 없음. 자소서처럼 Core 가 본문을 직접 실어 보낼 때 사용. +class TextSourceExtractor(SourceExtractor): + + def __init__(self, *, source_type: SourceType = "COVER_LETTER") -> None: + self._source_type = source_type + + async def extract(self, locator: str) -> ExtractedSource: + return ExtractedSource( + text=locator or "", + source_type=self._source_type, + metadata={"length": len(locator or "")}, + ) diff --git a/ai/src/ai_server/chain/prompts/document_analysis.py b/ai/src/ai_server/chain/prompts/document_analysis.py index c6ce8492..8ffe82e4 100644 --- a/ai/src/ai_server/chain/prompts/document_analysis.py +++ b/ai/src/ai_server/chain/prompts/document_analysis.py @@ -30,6 +30,10 @@ "5. summary: 위 추출 내용만 근거로 한 2~4문장 한국어 요약.\n" "6. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용. " - "추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n\n" + "추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n" + "※ 출처 유형이 COVER_LETTER(자기소개서)이면: 기술 나열보다 **지원동기·가치관·성장 경험·" + "직무 적합성·핵심 강점/소재**를 우선 추출하세요. experiences 에 자소서 문항별 핵심 주장과 " + "근거 일화를 담고, tech_stack 은 자소서에 실제 언급된 기술만(없으면 빈 배열). markdown 에는 " + "면접에서 파고들 만한 주장·수치·경험을 정리해 질문의 근거가 되게 하세요.\n\n" "{format_instructions}" ) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 6a6c2655..8ad8f0c7 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): # AI consumer (큐 이름, prefetch, 콜백 라우팅 등) ai_queue_resume: str = "ai.analyze.resume" ai_queue_repository: str = "ai.analyze.repository" + ai_queue_cover_letter: str = "ai.analyze.cover_letter" ai_queue_web: str = "ai.analyze.web" ai_queue_questions: str = "ai.generate.questions" ai_queue_followup: str = "ai.generate.followup" @@ -110,6 +111,11 @@ class Settings(BaseSettings): analyzed_web_resume_md_key_template: str = ( "analyzed/web-resume/{resume_id}/summary.md" ) + # 자소서 분석 마크다운 키. ResumeAnalyzer 재사용으로 placeholder 이름은 resume_id 를 따른다 + # (실제 값은 cover_letter_id). + analyzed_cover_letter_md_key_template: str = ( + "analyzed/cover-letter/{resume_id}/summary.md" + ) # Core 서버 internal API (사용자별 GitHub access_token 조회 등) core_internal_base_url: str = "http://localhost:38010" diff --git a/ai/src/ai_server/messaging/consumers/cover_letter_consumer.py b/ai/src/ai_server/messaging/consumers/cover_letter_consumer.py new file mode 100644 index 00000000..827bfde3 --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/cover_letter_consumer.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.progress import AnalysisProgressNotifier +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + CoverLetterAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +# 자소서 분석 consumer. 본문이 inline(content)이라는 점만 빼면 이력서와 동일한 분석·임베딩 +# 파이프라인을 탄다 — ResumeAnalyzer 를 TextSourceExtractor 와 자소서 키 템플릿으로 재사용. +class CoverLetterConsumer: + def __init__( + self, + *, + analyzer: ResumeAnalyzer, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + progress_notifier: AnalysisProgressNotifier | None = None, + ) -> None: + self._analyzer = analyzer + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + self._progress = progress_notifier + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[CoverLetterAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: # parse error → DLQ-ready (auto NACK on raise) + log.error( + "cover_letter.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "cover_letter.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + log.info( + "cover_letter.analyze.start", + message_id=envelope.message_id, + cover_letter_id=req.cover_letter_id, + trace_id=envelope.trace_id, + ) + + payload = await self._run_and_build_payload( + req, envelope.trace_id, user_id=envelope.context.user_id + ) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "cover_letter.analyze.done", + message_id=envelope.message_id, + cover_letter_id=req.cover_letter_id, + status=payload.status, + trace_id=envelope.trace_id, + ) + + async def _run_and_build_payload( + self, + req: CoverLetterAnalyzeRequest, + trace_id: str, + *, + user_id: int | None, + ) -> AnalysisCallbackPayload: + progress = ( + self._progress.emitter_for( + user_id=user_id, + target_type="COVER_LETTER", + target_id=req.cover_letter_id, + trace_id=trace_id, + ) + if self._progress is not None + else None + ) + try: + # ResumeAnalyzer 의 resume_id/file_path 는 각각 식별자/추출 locator 로 일반화돼 있어 + # 자소서는 cover_letter_id 와 inline content 를 그대로 넘긴다. + result = await self._analyzer.analyze( + resume_id=req.cover_letter_id, + file_path=req.content, + analyzed_document_id=req.analyzed_document_id, + progress=progress, + ) + except ResumeAnalyzeError as err: + log.warning( + "cover_letter.analyze.domain_failed", + cover_letter_id=req.cover_letter_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="COVER_LETTER", + target_id=req.cover_letter_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "cover_letter.analyze.unexpected_failed", + cover_letter_id=req.cover_letter_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="COVER_LETTER", + target_id=req.cover_letter_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="COVER_LETTER", + target_id=req.cover_letter_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=result.embedding_chunk_count, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index c62610f8..7aaf7a4f 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -7,6 +7,7 @@ from ai_server.analyzer.resume_analyzer import ResumeAnalyzer from ai_server.analyzer.sources.github_repo import GitHubRepoSourceExtractor from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.text import TextSourceExtractor from ai_server.analyzer.sources.web import WebSourceExtractor from ai_server.analyzer.web_resume_analyzer import WebResumeAnalyzer from ai_server.chain.document_analysis_chain import ( @@ -46,6 +47,7 @@ from ai_server.messaging.consumers.voice_consumer import VoiceConsumer from ai_server.voice.stt.factory import build_stt_provider from ai_server.voice.tts.factory import build_tts_provider +from ai_server.messaging.consumers.cover_letter_consumer import CoverLetterConsumer from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer from ai_server.messaging.consumers.web_consumer import WebResumeConsumer @@ -123,6 +125,17 @@ def __init__(self, settings: Settings) -> None: analyzed_key_template=settings.analyzed_resume_md_key_template, ) + # 자소서(inline 텍스트) — 이력서와 동일 분석·임베딩 파이프라인, extractor/키만 다름. + cover_letter_analyzer = ResumeAnalyzer( + extractor=TextSourceExtractor(source_type="COVER_LETTER"), + chain=chain_analyzer, + storage=storage, + chunker=chunker, + embedder=embedder, + core_client=core_client, + analyzed_key_template=settings.analyzed_cover_letter_md_key_template, + ) + # 리포지토리 repo_analyzer = RepositoryAnalyzer( extractor=GitHubRepoSourceExtractor( @@ -174,6 +187,13 @@ def __init__(self, settings: Settings) -> None: idempotency=self._idempotency, callback_routing_key=settings.ai_callback_routing_analysis, ) + self._cover_letter_consumer = CoverLetterConsumer( + analyzer=cover_letter_analyzer, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + progress_notifier=self._progress_notifier, + ) # 질문 풀 생성 (US-18) question_generator = LlmQuestionGenerator( @@ -300,6 +320,11 @@ async def start(self) -> None: queue_name=self._settings.ai_queue_web, handler=self._web_consumer.handle, ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_cover_letter, + handler=self._cover_letter_consumer.handle, + ) await self._start_consumer( channel, queue_name=self._settings.ai_queue_questions, diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py index aff39b25..10141104 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -4,7 +4,7 @@ from ai_server.model._config import camel_config -TargetType = Literal["RESUME", "REPOSITORY", "WEB"] +TargetType = Literal["RESUME", "REPOSITORY", "WEB", "COVER_LETTER"] AnalysisStatus = Literal["ANALYZED", "FAILED"] @@ -33,6 +33,15 @@ class WebResumeAnalyzeRequest(BaseModel): analyzed_document_id: int +class CoverLetterAnalyzeRequest(BaseModel): + model_config = camel_config() + + cover_letter_id: int + # 문항을 합친 마크다운 본문(S3 가 아니라 inline). TextSourceExtractor 가 그대로 사용. + content: str + analyzed_document_id: int + + class AnalysisCallbackPayload(BaseModel): model_config = camel_config() diff --git a/ai/tests/test_cover_letter_consumer.py b/ai/tests/test_cover_letter_consumer.py new file mode 100644 index 00000000..58bd3e68 --- /dev/null +++ b/ai/tests/test_cover_letter_consumer.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.analyzer.resume_analyzer import ( + ResumeAnalyzeError, + ResumeAnalysisResult, +) +from ai_server.messaging.consumers.cover_letter_consumer import CoverLetterConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + CoverLetterAnalyzeRequest, +) + + +def _request_envelope(message_id: str = "cl-1", cover_letter_id: int = 9) -> bytes: + env = Envelope[CoverLetterAnalyzeRequest].model_validate( + { + "messageId": message_id, + "messageType": "analyze.cover_letter", + "version": "v1", + "traceId": "trace-x", + "publishedAt": datetime(2026, 6, 28, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": { + "coverLetterId": cover_letter_id, + "content": "# 자기소개서\n\n## 지원동기\n저는 ...", + "analyzedDocumentId": 55, + }, + "context": {"userId": 123}, + } + ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer(analyzer: AsyncMock) -> tuple[CoverLetterConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = CoverLetterConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher + + +@pytest.mark.asyncio +async def test_happy_path_publishes_cover_letter_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=ResumeAnalysisResult( + summary="자소서 요약", + tech_stack=["Spring"], + document_path="analyzed/cover-letter/9/summary.md", + embedding_chunk_count=2, + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + # inline content 가 ResumeAnalyzer 의 file_path(=locator)로, cover_letter_id 가 resume_id 로 전달. + analyzer.analyze.assert_awaited_once_with( + resume_id=9, + file_path="# 자기소개서\n\n## 지원동기\n저는 ...", + analyzed_document_id=55, + progress=None, + ) + publisher.publish.assert_awaited_once() + payload = publisher.publish.await_args.kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + assert payload.status == "ANALYZED" + assert payload.target_type == "COVER_LETTER" + assert payload.target_id == 9 + assert payload.summary == "자소서 요약" + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_cover_letter_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=ResumeAnalyzeError( + code="EMPTY_PDF_TEXT", message="비어있음", retriable=False + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + payload = publisher.publish.await_args.kwargs["payload"] + assert payload.status == "FAILED" + assert payload.target_type == "COVER_LETTER" + assert payload.error_code == "EMPTY_PDF_TEXT" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 7fe58134..08e0c907 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -86,7 +86,8 @@ com.stackup.stackup.{domain}/ | `user.consent` | 개인정보처리동의 기록·조회·철회 | US-03 | | `github` | GitHub API 연동, 레포 목록/등록/메타 동기화 | US-07, US-08 | | `resume` | 이력서 업로드(S3)·메타 저장·목록·삭제 | US-05, US-06 | -| `document` | 분석 문서(이력서/레포 공통) 메타 + S3 경로 | US-09~12 | +| `coverletter` | 자소서(공채) 문항별 텍스트 입력·메타 저장·목록·삭제. inline 텍스트→`analyze.cover_letter`→분석 파이프라인 재사용. AnalyzedDocument 에 `cover_letter_id` 다형성 FK 추가 | — | +| `document` | 분석 문서(이력서/레포/자소서 공통) 메타 + S3 경로 | US-09~12 | | `session` | 면접 세션·메시지·피드백 (가장 큰 도메인) | US-13~20, US-24~27 | | `log.activity` | 사용자 행동 로그 | US-31 | | `log.ai` | AI 요청/응답 로깅 | US-30 | diff --git a/backend/openapi.json b/backend/openapi.json index 353cb425..d9ee123b 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -44,6 +44,9 @@ }, { "name" : "Internal: Document Embeddings", "description" : "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출." + }, { + "name" : "CoverLetters", + "description" : "자소서(공채용 문항별 텍스트) 입력/관리. 생성 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨." }, { "name" : "User Consents", "description" : "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit)." @@ -1215,6 +1218,89 @@ } } }, + "/api/cover-letters" : { + "get" : { + "tags" : [ "CoverLetters" ], + "summary" : "내 자소서 목록", + "operationId" : "listCoverLetters", + "responses" : { + "200" : { + "description" : "사용자 소유 자소서 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CoverLetterResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CoverLetterResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "CoverLetters" ], + "summary" : "자소서 입력 + 분석 트리거", + "description" : "문항별(질문+답변) 텍스트 자소서를 받아 DB row 생성(status=PENDING) → AI 분석 자동 발행. 답변이 비어있지 않은 문항이 1개 이상 필요.", + "operationId" : "createCoverLetter", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CoverLetterCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "생성 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CoverLetterResponse" + } + } + } + }, + "400" : { + "description" : "문항 없음 / 빈 답변", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CoverLetterResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CoverLetterResponse" + } + } + } + } + } + } + }, "/api/auth/stream-token" : { "post" : { "tags" : [ "Auth" ], @@ -2457,6 +2543,33 @@ } } }, + "/api/cover-letters/{coverLetterId}" : { + "delete" : { + "tags" : [ "CoverLetters" ], + "summary" : "자소서 soft delete", + "operationId" : "deleteCoverLetter", + "parameters" : [ { + "name" : "coverLetterId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제 완료" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "자소서 없음" + } + } + } + }, "/api/auth/logout" : { "delete" : { "tags" : [ "Auth" ], @@ -3112,6 +3225,63 @@ }, "required" : [ "requestType", "status" ] }, + "CoverLetterCreateRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : "string" + }, + "items" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Item" + }, + "minItems" : 1 + } + }, + "required" : [ "items" ] + }, + "Item" : { + "type" : "object", + "properties" : { + "question" : { + "type" : "string" + }, + "answer" : { + "type" : "string" + } + } + }, + "CoverLetterResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "title" : { + "type" : "string" + }, + "items" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Item" + } + }, + "status" : { + "type" : "string", + "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, "RefreshTokenResponse" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index 8bc4f4e3..7b44bd0c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -65,6 +65,7 @@ public record Queues( public record Names( @NotBlank String aiAnalyzeResume, @NotBlank String aiAnalyzeRepository, + @NotBlank String aiAnalyzeCoverLetter, @NotBlank String aiGenerateQuestions, @NotBlank String aiGenerateFollowup, @NotBlank String aiGenerateFeedback, @@ -82,6 +83,7 @@ public record Names( public record RoutingKeyProperties( @NotBlank String analyzeResume, @NotBlank String analyzeRepository, + @NotBlank String analyzeCoverLetter, @NotBlank String generateQuestions, @NotBlank String generateFollowup, @NotBlank String generateFeedback, diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 3fbdfaf0..54fd06dd 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -18,6 +18,9 @@ public enum ApiErrorCode { RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "이력서를 찾을 수 없습니다."), RESUME_IN_USE(HttpStatus.CONFLICT, "사용 중인 이력서입니다."), + COVER_LETTER_EMPTY(HttpStatus.BAD_REQUEST, "답변이 입력된 문항이 최소 1개 필요합니다."), + COVER_LETTER_NOT_FOUND(HttpStatus.NOT_FOUND, "자소서를 찾을 수 없습니다."), + REPO_NOT_FOUND(HttpStatus.NOT_FOUND, "레포지토리를 찾을 수 없습니다."), REPO_ALREADY_REGISTERED(HttpStatus.CONFLICT, "이미 등록된 레포지토리입니다."), REPO_GITHUB_API_FAILED(HttpStatus.BAD_GATEWAY, "GitHub API 요청에 실패했습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index de24ba78..cd2a1554 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -77,6 +77,11 @@ public Queue aiAnalyzeRepositoryQueue() { return workQueue(properties.queues().names().aiAnalyzeRepository()); } + @Bean + public Queue aiAnalyzeCoverLetterQueue() { + return workQueue(properties.queues().names().aiAnalyzeCoverLetter()); + } + @Bean public Queue aiGenerateQuestionsQueue() { return workQueue(properties.queues().names().aiGenerateQuestions()); @@ -135,6 +140,7 @@ public Declarables rabbitDeclarables( DirectExchange deadLetterExchange, Queue aiAnalyzeResumeQueue, Queue aiAnalyzeRepositoryQueue, + Queue aiAnalyzeCoverLetterQueue, Queue aiGenerateQuestionsQueue, Queue aiGenerateFollowupQueue, Queue aiGenerateFeedbackQueue, @@ -148,6 +154,7 @@ public Declarables rabbitDeclarables( ) { Queue dlqAiAnalyzeResume = dlq(properties.queues().names().aiAnalyzeResume()); Queue dlqAiAnalyzeRepository = dlq(properties.queues().names().aiAnalyzeRepository()); + Queue dlqAiAnalyzeCoverLetter = dlq(properties.queues().names().aiAnalyzeCoverLetter()); Queue dlqAiGenerateQuestions = dlq(properties.queues().names().aiGenerateQuestions()); Queue dlqAiGenerateFollowup = dlq(properties.queues().names().aiGenerateFollowup()); Queue dlqAiGenerateFeedback = dlq(properties.queues().names().aiGenerateFeedback()); @@ -166,6 +173,7 @@ public Declarables rabbitDeclarables( deadLetterExchange, aiAnalyzeResumeQueue, aiAnalyzeRepositoryQueue, + aiAnalyzeCoverLetterQueue, aiGenerateQuestionsQueue, aiGenerateFollowupQueue, aiGenerateFeedbackQueue, @@ -178,6 +186,7 @@ public Declarables rabbitDeclarables( coreCallbackTtsQueue, dlqAiAnalyzeResume, dlqAiAnalyzeRepository, + dlqAiAnalyzeCoverLetter, dlqAiGenerateQuestions, dlqAiGenerateFollowup, dlqAiGenerateFeedback, @@ -190,6 +199,7 @@ public Declarables rabbitDeclarables( dlqCoreCallbackTts, BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeResume()), BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeRepository()), + BindingBuilder.bind(aiAnalyzeCoverLetterQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeCoverLetter()), BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(properties.routingKeys().generateQuestions()), BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(properties.routingKeys().generateFollowup()), BindingBuilder.bind(aiGenerateFeedbackQueue).to(coreToAiExchange).with(properties.routingKeys().generateFeedback()), @@ -202,6 +212,7 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(coreCallbackTtsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackTts()), BindingBuilder.bind(dlqAiAnalyzeResume).to(deadLetterExchange).with(dlqAiAnalyzeResume.getName()), BindingBuilder.bind(dlqAiAnalyzeRepository).to(deadLetterExchange).with(dlqAiAnalyzeRepository.getName()), + BindingBuilder.bind(dlqAiAnalyzeCoverLetter).to(deadLetterExchange).with(dlqAiAnalyzeCoverLetter.getName()), BindingBuilder.bind(dlqAiGenerateQuestions).to(deadLetterExchange).with(dlqAiGenerateQuestions.getName()), BindingBuilder.bind(dlqAiGenerateFollowup).to(deadLetterExchange).with(dlqAiGenerateFollowup.getName()), BindingBuilder.bind(dlqAiGenerateFeedback).to(deadLetterExchange).with(dlqAiGenerateFeedback.getName()), diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/CoverLetterService.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/CoverLetterService.java new file mode 100644 index 00000000..710b3cc2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/CoverLetterService.java @@ -0,0 +1,105 @@ +package com.stackup.stackup.coverletter.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.coverletter.application.dto.CoverLetterCreateCommand; +import com.stackup.stackup.coverletter.application.dto.CoverLetterItem; +import com.stackup.stackup.coverletter.application.dto.CoverLetterResult; +import com.stackup.stackup.coverletter.application.event.CoverLetterDeletedEvent; +import com.stackup.stackup.coverletter.application.event.CoverLetterUploadedEvent; +import com.stackup.stackup.coverletter.domain.CoverLetter; +import com.stackup.stackup.coverletter.domain.CoverLetterRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CoverLetterService { + + private static final Logger log = LoggerFactory.getLogger(CoverLetterService.class); + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final TypeReference> ITEM_LIST = new TypeReference<>() {}; + + private final CoverLetterRepository coverLetterRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher events; + + @Transactional + public CoverLetterResult create(Long userId, CoverLetterCreateCommand command) { + List items = sanitize(command.items()); + if (items.isEmpty()) { + throw new DomainException(ApiErrorCode.COVER_LETTER_EMPTY); + } + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + CoverLetter coverLetter = coverLetterRepository.save( + CoverLetter.create(user, command.title(), serialize(items)) + ); + // document 도메인 listener 가 동일 트랜잭션 안에서 AnalyzedDocument(PROCESSING) 생성 + AFTER_COMMIT 으로 analyze.cover_letter 발행. + events.publishEvent(new CoverLetterUploadedEvent(userId, coverLetter.getId())); + return CoverLetterResult.of(coverLetter, items); + } + + public List list(Long userId) { + return coverLetterRepository.findByUser_IdAndDeletedFalseOrderByIdDesc(userId).stream() + .map(cl -> CoverLetterResult.of(cl, parse(cl.getItems()))) + .toList(); + } + + @Transactional + public void delete(Long userId, Long coverLetterId) { + CoverLetter coverLetter = loadOwned(userId, coverLetterId); + coverLetter.markDeleted(); + // 분석 결과 cascade — document 도메인 listener 가 받아 AnalyzedDocument soft delete (도메인 cycle 회피). + events.publishEvent(new CoverLetterDeletedEvent(userId, coverLetterId)); + } + + private CoverLetter loadOwned(Long userId, Long coverLetterId) { + return coverLetterRepository.findByIdAndUser_IdAndDeletedFalse(coverLetterId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.COVER_LETTER_NOT_FOUND)); + } + + private List sanitize(List items) { + if (items == null) { + return List.of(); + } + return items.stream() + .map(it -> new CoverLetterItem( + it.question() == null ? "" : it.question().trim(), + it.answer() == null ? "" : it.answer().trim())) + .filter(it -> !it.answer().isBlank()) // 답변 없는 문항은 버린다. + .toList(); + } + + private String serialize(List items) { + try { + return JSON.writeValueAsString(items); + } catch (JsonProcessingException e) { + throw new DomainException(ApiErrorCode.SYS_INTERNAL_ERROR); + } + } + + private List parse(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return JSON.readValue(json, ITEM_LIST); + } catch (JsonProcessingException e) { + log.warn("cover letter items parse failed, return empty. raw={}", json, e); + return List.of(); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterCreateCommand.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterCreateCommand.java new file mode 100644 index 00000000..4464ba37 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterCreateCommand.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.coverletter.application.dto; + +import java.util.List; + +public record CoverLetterCreateCommand( + String title, + List items +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterItem.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterItem.java new file mode 100644 index 00000000..451fe6a4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterItem.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.coverletter.application.dto; + +// 자소서 문항 한 개 (질문 + 답변). +public record CoverLetterItem( + String question, + String answer +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterResult.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterResult.java new file mode 100644 index 00000000..750a3ec1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/dto/CoverLetterResult.java @@ -0,0 +1,26 @@ +package com.stackup.stackup.coverletter.application.dto; + +import com.stackup.stackup.coverletter.domain.CoverLetter; +import com.stackup.stackup.coverletter.domain.CoverLetterStatus; +import java.time.Instant; +import java.util.List; + +public record CoverLetterResult( + Long id, + String title, + List items, + CoverLetterStatus status, + Instant createdAt, + Instant updatedAt +) { + public static CoverLetterResult of(CoverLetter coverLetter, List items) { + return new CoverLetterResult( + coverLetter.getId(), + coverLetter.getTitle(), + items, + coverLetter.getStatus(), + coverLetter.getCreatedAt(), + coverLetter.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterDeletedEvent.java new file mode 100644 index 00000000..d6aac408 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterDeletedEvent.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.coverletter.application.event; + +// CoverLetter soft delete commit 후 발화. document 도메인이 수신해 관련 AnalyzedDocument cascade soft delete. +public record CoverLetterDeletedEvent(Long userId, Long coverLetterId) { +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterUploadedEvent.java b/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterUploadedEvent.java new file mode 100644 index 00000000..c421c00e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/application/event/CoverLetterUploadedEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.coverletter.application.event; + +// 자소서 생성 직후 발행. document 도메인이 listener 로 받아 분석 트리거. +// coverletter 도메인이 document 도메인을 직접 의존하지 않도록 분리하기 위한 매개체. +public record CoverLetterUploadedEvent( + Long userId, + Long coverLetterId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetter.java b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetter.java new file mode 100644 index 00000000..bccadaab --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetter.java @@ -0,0 +1,82 @@ +package com.stackup.stackup.coverletter.domain; + +import com.stackup.stackup.common.entity.BaseSoftDeleteEntity; +import com.stackup.stackup.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Entity +@Table( + name = "cover_letters", + indexes = { + @Index(name = "idx_cover_letters_user_id", columnList = "user_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CoverLetter extends BaseSoftDeleteEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(length = 200) + private String title; + + // 문항 배열 JSON: [{"question":"...","answer":"..."}]. AnalyzedDocument.techStack 와 동일 매핑 패턴. + @Column(name = "items", columnDefinition = "jsonb", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private String items; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private CoverLetterStatus status = CoverLetterStatus.PENDING; + + private CoverLetter(User user, String title, String items) { + this.user = user; + this.title = title; + this.items = items; + } + + public static CoverLetter create(User user, String title, String itemsJson) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + return new CoverLetter(user, title, itemsJson == null ? "[]" : itemsJson); + } + + public void markAnalyzing() { + this.status = CoverLetterStatus.ANALYZING; + } + + public void markAnalyzed() { + this.status = CoverLetterStatus.ANALYZED; + } + + public void markFailed() { + this.status = CoverLetterStatus.FAILED; + } + + public void markDeleted() { + this.deleted = true; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterRepository.java b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterRepository.java new file mode 100644 index 00000000..afa463d4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.coverletter.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CoverLetterRepository extends JpaRepository { + + List findByUser_IdAndDeletedFalseOrderByIdDesc(Long userId); + + Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterStatus.java b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterStatus.java new file mode 100644 index 00000000..63bfa4e9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/domain/CoverLetterStatus.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.coverletter.domain; + +public enum CoverLetterStatus { + PENDING, + ANALYZING, + ANALYZED, + FAILED +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/presentation/CoverLetterController.java b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/CoverLetterController.java new file mode 100644 index 00000000..74da2817 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/CoverLetterController.java @@ -0,0 +1,79 @@ +package com.stackup.stackup.coverletter.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.coverletter.application.CoverLetterService; +import com.stackup.stackup.coverletter.presentation.dto.CoverLetterCreateRequest; +import com.stackup.stackup.coverletter.presentation.dto.CoverLetterResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "CoverLetters", description = "자소서(공채용 문항별 텍스트) 입력/관리. 생성 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨.") +@RestController +@RequestMapping("/api/cover-letters") +@RequiredArgsConstructor +public class CoverLetterController { + + private final CoverLetterService coverLetterService; + + @Operation( + operationId = "createCoverLetter", + summary = "자소서 입력 + 분석 트리거", + description = "문항별(질문+답변) 텍스트 자소서를 받아 DB row 생성(status=PENDING) → AI 분석 자동 발행. 답변이 비어있지 않은 문항이 1개 이상 필요." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 + 분석 트리거 성공"), + @ApiResponse(responseCode = "400", description = "문항 없음 / 빈 답변"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CoverLetterResponse create( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody CoverLetterCreateRequest request + ) { + return CoverLetterResponse.from( + coverLetterService.create(principal.userId(), request.toCommand())); + } + + @Operation(operationId = "listCoverLetters", summary = "내 자소서 목록") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 소유 자소서 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping + public List list(@AuthenticationPrincipal UserPrincipal principal) { + return coverLetterService.list(principal.userId()).stream() + .map(CoverLetterResponse::from) + .toList(); + } + + @Operation(operationId = "deleteCoverLetter", summary = "자소서 soft delete") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "자소서 없음") + }) + @DeleteMapping("/{coverLetterId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long coverLetterId + ) { + coverLetterService.delete(principal.userId(), coverLetterId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterCreateRequest.java b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterCreateRequest.java new file mode 100644 index 00000000..ef0ca2a2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterCreateRequest.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.coverletter.presentation.dto; + +import com.stackup.stackup.coverletter.application.dto.CoverLetterCreateCommand; +import com.stackup.stackup.coverletter.application.dto.CoverLetterItem; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record CoverLetterCreateRequest( + String title, + @NotEmpty List items +) { + public record Item(String question, String answer) { + } + + public CoverLetterCreateCommand toCommand() { + List mapped = items == null ? List.of() + : items.stream().map(i -> new CoverLetterItem(i.question(), i.answer())).toList(); + return new CoverLetterCreateCommand(title, mapped); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterResponse.java b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterResponse.java new file mode 100644 index 00000000..93de37cb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/coverletter/presentation/dto/CoverLetterResponse.java @@ -0,0 +1,32 @@ +package com.stackup.stackup.coverletter.presentation.dto; + +import com.stackup.stackup.coverletter.application.dto.CoverLetterResult; +import com.stackup.stackup.coverletter.domain.CoverLetterStatus; +import java.time.Instant; +import java.util.List; + +public record CoverLetterResponse( + Long id, + String title, + List items, + CoverLetterStatus status, + Instant createdAt, + Instant updatedAt +) { + public record Item(String question, String answer) { + } + + public static CoverLetterResponse from(CoverLetterResult result) { + List items = result.items().stream() + .map(i -> new Item(i.question(), i.answer())) + .toList(); + return new CoverLetterResponse( + result.id(), + result.title(), + items, + result.status(), + result.createdAt(), + result.updatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java index 94b1eb43..dcc0ab88 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java @@ -7,6 +7,7 @@ import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; import org.springframework.context.ApplicationEventPublisher; +import com.stackup.stackup.coverletter.domain.CoverLetter; import com.stackup.stackup.document.application.dto.AnalysisCallbackEnvelope; import com.stackup.stackup.document.application.dto.AnalysisCallbackPayload; import com.stackup.stackup.document.domain.AnalyzedDocument; @@ -89,6 +90,10 @@ private void applyAnalyzed(AnalyzedDocument doc, AnalysisCallbackPayload payload if (repo != null) { repo.markAnalyzed(); } + CoverLetter coverLetter = doc.getCoverLetter(); + if (coverLetter != null) { + coverLetter.markAnalyzed(); + } } private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) { @@ -101,6 +106,10 @@ private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) if (repo != null) { repo.markFailed(); } + CoverLetter coverLetter = doc.getCoverLetter(); + if (coverLetter != null) { + coverLetter.markFailed(); + } } private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) { @@ -120,6 +129,9 @@ private Long resolveOwnerUserId(AnalyzedDocument doc) { if (doc.getRepository() != null && doc.getRepository().getUser() != null) { return doc.getRepository().getUser().getId(); } + if (doc.getCoverLetter() != null && doc.getCoverLetter().getUser() != null) { + return doc.getCoverLetter().getUser().getId(); + } return null; } diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java index b7043bcc..80a175df 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -1,8 +1,13 @@ package com.stackup.stackup.document.application; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.messaging.MessageContext; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.coverletter.domain.CoverLetter; +import com.stackup.stackup.coverletter.domain.CoverLetterRepository; +import com.stackup.stackup.document.application.dto.AnalyzeCoverLetterPayload; import com.stackup.stackup.document.application.dto.AnalyzeRepositoryPayload; import com.stackup.stackup.document.application.dto.AnalyzeResumePayload; import com.stackup.stackup.document.domain.AnalyzedDocument; @@ -26,8 +31,11 @@ @RequiredArgsConstructor public class AnalysisRequestService { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private final ResumeRepository resumeRepository; private final GithubRepositoryRepository githubRepositoryRepository; + private final CoverLetterRepository coverLetterRepository; private final AnalyzedDocumentRepository analyzedDocumentRepository; private final RabbitMessagePublisher publisher; private final RabbitMqProperties properties; @@ -74,6 +82,53 @@ public AnalysisHandle requestRepositoryAnalysis(Long userId, Long repositoryId) return new AnalysisHandle(doc.getId(), null, repo.getId()); } + @Transactional + public AnalysisHandle requestCoverLetterAnalysis(Long userId, Long coverLetterId) { + CoverLetter coverLetter = coverLetterRepository.findById(coverLetterId) + .orElseThrow(() -> new IllegalArgumentException("cover letter not found: " + coverLetterId)); + if (!coverLetter.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("cover letter does not belong to user"); + } + AnalyzedDocument doc = analyzedDocumentRepository.save(AnalyzedDocument.forCoverLetter(coverLetter)); + coverLetter.markAnalyzing(); + + String content = buildMarkdown(coverLetter.getTitle(), coverLetter.getItems()); + events.publishEvent(new CoverLetterAnalysisRequestedEvent( + userId, + doc.getId(), + new AnalyzeCoverLetterPayload(coverLetter.getId(), content, doc.getId()) + )); + return new AnalysisHandle(doc.getId(), null, null); + } + + // 문항 JSON([{question, answer}]) → 분석·임베딩용 마크다운. 문항 구조를 보존해 질문 생성의 근거가 되게 한다. + private String buildMarkdown(String title, String itemsJson) { + StringBuilder sb = new StringBuilder(); + sb.append("# 자기소개서"); + if (title != null && !title.isBlank()) { + sb.append(" — ").append(title.trim()); + } + sb.append("\n\n"); + try { + JsonNode root = JSON.readTree(itemsJson == null ? "[]" : itemsJson); + if (root.isArray()) { + for (JsonNode item : root) { + String question = item.path("question").asText("").trim(); + String answer = item.path("answer").asText("").trim(); + if (answer.isEmpty()) { + continue; + } + sb.append("## ").append(question.isEmpty() ? "문항" : question).append("\n"); + sb.append(answer).append("\n\n"); + } + } + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + // 파싱 실패 시 원문이라도 분석에 넘긴다(빈 분석 방지). + sb.append(itemsJson == null ? "" : itemsJson); + } + return sb.toString(); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onResumeAnalysisRequested(ResumeAnalysisRequestedEvent event) { @@ -94,6 +149,16 @@ public void onRepositoryAnalysisRequested(RepositoryAnalysisRequestedEvent event ); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCoverLetterAnalysisRequested(CoverLetterAnalysisRequestedEvent event) { + publisher.publishToAi( + properties.routingKeys().analyzeCoverLetter(), + event.payload(), + new MessageContext(event.userId(), null, event.documentId(), null) + ); + } + public record AnalysisHandle(Long analyzedDocumentId, Long resumeId, Long repositoryId) { } @@ -102,4 +167,7 @@ record ResumeAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeResumeP record RepositoryAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeRepositoryPayload payload) { } + + record CoverLetterAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeCoverLetterPayload payload) { + } } diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java index ca576967..4258d488 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java @@ -1,5 +1,6 @@ package com.stackup.stackup.document.application; +import com.stackup.stackup.coverletter.application.event.CoverLetterDeletedEvent; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; @@ -49,4 +50,18 @@ public void on(RepositoryDeletedEvent event) { event.userId(), event.repositoryId(), docs.size()); } } + + @EventListener + @Transactional + public void on(CoverLetterDeletedEvent event) { + List docs = analyzedDocumentRepository + .findActiveByCoverLetterIdAndOwner(event.coverLetterId(), event.userId()); + for (AnalyzedDocument doc : docs) { + doc.markDeleted(); + } + if (!docs.isEmpty()) { + log.info("AnalyzedDocument cascade soft delete (cover letter). userId={}, coverLetterId={}, count={}", + event.userId(), event.coverLetterId(), docs.size()); + } + } } diff --git a/backend/src/main/java/com/stackup/stackup/document/application/CoverLetterAnalysisEventListener.java b/backend/src/main/java/com/stackup/stackup/document/application/CoverLetterAnalysisEventListener.java new file mode 100644 index 00000000..e925f110 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/CoverLetterAnalysisEventListener.java @@ -0,0 +1,22 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.coverletter.application.event.CoverLetterUploadedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// coverletter 도메인이 발행한 CoverLetterUploadedEvent 를 받아 분석 트리거. +// 도메인 간 직접 호출(coverletter → document)을 피하기 위한 분리. document → coverletter 단방향만 유지. +@Component +@RequiredArgsConstructor +public class CoverLetterAnalysisEventListener { + + private final AnalysisRequestService analysisRequestService; + + @EventListener + public void on(CoverLetterUploadedEvent event) { + // 동일 트랜잭션 안에서 실행. AnalysisRequestService 내부에서 다시 @TransactionalEventListener(AFTER_COMMIT) + // 로 분기되어 트랜잭션 커밋 후 RabbitMQ 발행. + analysisRequestService.requestCoverLetterAnalysis(event.userId(), event.coverLetterId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeCoverLetterPayload.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeCoverLetterPayload.java new file mode 100644 index 00000000..8a592eb1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeCoverLetterPayload.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.document.application.dto; + +// 자소서는 파일(S3)이 아니라 문항을 합친 마크다운 본문을 inline 으로 실어 보낸다. +public record AnalyzeCoverLetterPayload( + Long coverLetterId, + String content, + Long analyzedDocumentId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java index a9404d97..603c47de 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java @@ -9,7 +9,7 @@ public record AnalyzedDocumentResult( Long id, - String sourceType, // "RESUME" | "REPOSITORY" + String sourceType, // "RESUME" | "REPOSITORY" | "COVER_LETTER" Long sourceId, String documentPath, // S3 키 (raw 경로) URI documentDownloadUrl, // presigned (detail 응답에서만 채움) @@ -45,12 +45,14 @@ public static AnalyzedDocumentResult of(AnalyzedDocument doc, List techS private static String resolveSourceType(AnalyzedDocument doc) { if (doc.getResume() != null) return "RESUME"; if (doc.getRepository() != null) return "REPOSITORY"; + if (doc.getCoverLetter() != null) return "COVER_LETTER"; return null; } private static Long resolveSourceId(AnalyzedDocument doc) { if (doc.getResume() != null) return doc.getResume().getId(); if (doc.getRepository() != null) return doc.getRepository().getId(); + if (doc.getCoverLetter() != null) return doc.getCoverLetter().getId(); return null; } } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index 95ca76d3..f257ed6b 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -1,6 +1,7 @@ package com.stackup.stackup.document.domain; import com.stackup.stackup.common.entity.BaseSoftDeleteEntity; +import com.stackup.stackup.coverletter.domain.CoverLetter; import com.stackup.stackup.github.domain.GithubRepository; import com.stackup.stackup.resume.domain.Resume; import jakarta.persistence.Column; @@ -27,7 +28,8 @@ name = "analyzed_documents", indexes = { @Index(name = "idx_analyzed_documents_resume_id", columnList = "resume_id"), - @Index(name = "idx_analyzed_documents_repository_id", columnList = "repository_id") + @Index(name = "idx_analyzed_documents_repository_id", columnList = "repository_id"), + @Index(name = "idx_analyzed_documents_cover_letter_id", columnList = "cover_letter_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -45,6 +47,10 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { @JoinColumn(name = "repository_id") private GithubRepository repository; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cover_letter_id") + private CoverLetter coverLetter; + @Column(name = "document_path", length = 1000) private String documentPath; @@ -72,23 +78,31 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { @Column(name = "embedding_chunk_count", nullable = false) private int embeddingChunkCount = 0; - private AnalyzedDocument(Resume resume, GithubRepository repository) { + private AnalyzedDocument(Resume resume, GithubRepository repository, CoverLetter coverLetter) { this.resume = resume; this.repository = repository; + this.coverLetter = coverLetter; } public static AnalyzedDocument forResume(Resume resume) { if (resume == null) { throw new IllegalArgumentException("resume must not be null"); } - return new AnalyzedDocument(resume, null); + return new AnalyzedDocument(resume, null, null); } public static AnalyzedDocument forRepository(GithubRepository repository) { if (repository == null) { throw new IllegalArgumentException("repository must not be null"); } - return new AnalyzedDocument(null, repository); + return new AnalyzedDocument(null, repository, null); + } + + public static AnalyzedDocument forCoverLetter(CoverLetter coverLetter) { + if (coverLetter == null) { + throw new IllegalArgumentException("coverLetter must not be null"); + } + return new AnalyzedDocument(null, null, coverLetter); } public void markAnalyzed(String documentPath, String summary, String techStackJson, int embeddingChunkCount) { diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java index 6e31625e..08c1c312 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java @@ -24,8 +24,10 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( LEFT JOIN rs.user ru LEFT JOIN d.repository rp LEFT JOIN rp.user pu + LEFT JOIN d.coverLetter cl + LEFT JOIN cl.user cu WHERE d.deleted = false - AND (ru.id = :userId OR pu.id = :userId) + AND (ru.id = :userId OR pu.id = :userId OR cu.id = :userId) ORDER BY d.id DESC """) List findActiveByOwner(@Param("userId") Long userId); @@ -36,12 +38,24 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( LEFT JOIN rs.user ru LEFT JOIN d.repository rp LEFT JOIN rp.user pu + LEFT JOIN d.coverLetter cl + LEFT JOIN cl.user cu WHERE d.id = :id AND d.deleted = false - AND (ru.id = :userId OR pu.id = :userId) + AND (ru.id = :userId OR pu.id = :userId OR cu.id = :userId) """) Optional findActiveByIdAndOwner(@Param("id") Long id, @Param("userId") Long userId); + @Query(""" + SELECT d FROM AnalyzedDocument d + WHERE d.coverLetter.id = :coverLetterId + AND d.coverLetter.user.id = :userId + AND d.deleted = false + ORDER BY d.id DESC + """) + List findActiveByCoverLetterIdAndOwner( + @Param("coverLetterId") Long coverLetterId, @Param("userId") Long userId); + @Query(""" SELECT d FROM AnalyzedDocument d WHERE d.resume.id = :resumeId diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java index 9a081e25..13ed89e6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java @@ -140,6 +140,7 @@ private List buildDocumentContexts(List documentIds) { private String resolveSourceType(AnalyzedDocument doc) { if (doc.getResume() != null) return "RESUME"; if (doc.getRepository() != null) return "REPOSITORY"; + if (doc.getCoverLetter() != null) return "COVER_LETTER"; return "UNKNOWN"; } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f5692372..31792eca 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -92,6 +92,7 @@ app: names: ai-analyze-resume: ai.analyze.resume ai-analyze-repository: ai.analyze.repository + ai-analyze-cover-letter: ai.analyze.cover_letter ai-generate-questions: ai.generate.questions ai-generate-followup: ai.generate.followup ai-generate-feedback: ai.generate.feedback @@ -105,6 +106,7 @@ app: routing-keys: analyze-resume: analyze.resume analyze-repository: analyze.repository + analyze-cover-letter: analyze.cover_letter generate-questions: generate.questions generate-followup: generate.followup generate-feedback: generate.feedback diff --git a/backend/src/main/resources/db/migration/V20__add_cover_letters.sql b/backend/src/main/resources/db/migration/V20__add_cover_letters.sql new file mode 100644 index 00000000..ea67e2dc --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__add_cover_letters.sql @@ -0,0 +1,38 @@ +-- 자소서(cover letter): 대기업 공채용 — 문항별(질문+답변) 텍스트 입력. +-- 이력서/레포와 동일하게 분석 → 임베딩 → 세션 컨텍스트 → 질문 생성 파이프라인을 탄다. + +CREATE TABLE cover_letters ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id), + title VARCHAR(200), + -- 문항 배열: [{"question": "...", "answer": "..."}] + items JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_cover_letters_status + CHECK (status IN ('PENDING', 'ANALYZING', 'ANALYZED', 'FAILED')) +); + +CREATE INDEX idx_cover_letters_user_id ON cover_letters (user_id); + +-- analyzed_documents 다형성 소스에 cover_letter 추가. +ALTER TABLE analyzed_documents + ADD COLUMN cover_letter_id BIGINT REFERENCES cover_letters (id); + +CREATE INDEX idx_analyzed_documents_cover_letter_id + ON analyzed_documents (cover_letter_id); + +-- 기존 "resume XOR repository" 단일 소스 제약을 "셋 중 정확히 하나"로 확장. +ALTER TABLE analyzed_documents + DROP CONSTRAINT chk_analyzed_documents_single_source; + +ALTER TABLE analyzed_documents + ADD CONSTRAINT chk_analyzed_documents_single_source CHECK ( + (resume_id IS NOT NULL AND repository_id IS NULL AND cover_letter_id IS NULL) + OR + (resume_id IS NULL AND repository_id IS NOT NULL AND cover_letter_id IS NULL) + OR + (resume_id IS NULL AND repository_id IS NULL AND cover_letter_id IS NOT NULL) + ); diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index 54fd016f..09ca7f5a 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -35,6 +35,9 @@ class StackupApplicationTests { @MockitoBean private ResumeRepository resumeRepository; + @MockitoBean + private com.stackup.stackup.coverletter.domain.CoverLetterRepository coverLetterRepository; + @MockitoBean private GithubRepositoryRepository githubRepositoryRepository; diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 7c79b5c1..65ff3d17 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -90,6 +90,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Queues.Names( "ai.analyze.resume", "ai.analyze.repository", + "ai.analyze.cover_letter", "ai.generate.questions", "ai.generate.followup", "ai.generate.feedback", @@ -105,6 +106,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", + "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java index c9ddc9cf..bd2ed284 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java @@ -59,6 +59,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Queues.Names( "ai.analyze.resume", "ai.analyze.repository", + "ai.analyze.cover_letter", "ai.generate.questions", "ai.generate.followup", "ai.generate.feedback", @@ -74,6 +75,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", + "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", diff --git a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java index 9698318f..034a39df 100644 --- a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java @@ -7,6 +7,7 @@ import com.stackup.stackup.auth.domain.OAuthStateRepository; import com.stackup.stackup.auth.domain.RefreshTokenRepository; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.coverletter.domain.CoverLetterRepository; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.github.domain.GithubRepositoryRepository; @@ -54,6 +55,7 @@ class OpenApiSpecExportTest { @MockitoBean private RefreshTokenRepository refreshTokenRepository; @MockitoBean private UserRepository userRepository; @MockitoBean private ResumeRepository resumeRepository; + @MockitoBean private CoverLetterRepository coverLetterRepository; @MockitoBean private GithubRepositoryRepository githubRepositoryRepository; @MockitoBean private AnalyzedDocumentRepository analyzedDocumentRepository; @MockitoBean private ProcessedMessageRepository processedMessageRepository; diff --git a/backend/src/test/java/com/stackup/stackup/coverletter/application/CoverLetterServiceTest.java b/backend/src/test/java/com/stackup/stackup/coverletter/application/CoverLetterServiceTest.java new file mode 100644 index 00000000..ca5499d9 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/coverletter/application/CoverLetterServiceTest.java @@ -0,0 +1,81 @@ +package com.stackup.stackup.coverletter.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.coverletter.application.dto.CoverLetterCreateCommand; +import com.stackup.stackup.coverletter.application.dto.CoverLetterItem; +import com.stackup.stackup.coverletter.application.dto.CoverLetterResult; +import com.stackup.stackup.coverletter.application.event.CoverLetterUploadedEvent; +import com.stackup.stackup.coverletter.domain.CoverLetter; +import com.stackup.stackup.coverletter.domain.CoverLetterRepository; +import com.stackup.stackup.coverletter.domain.CoverLetterStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CoverLetterServiceTest { + + @Mock CoverLetterRepository coverLetterRepository; + @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks CoverLetterService service; + + @Test + void create_persistsSerializesItemsAndPublishesEvent() { + User user = user(); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(coverLetterRepository.save(any(CoverLetter.class))) + .thenAnswer(inv -> { + CoverLetter cl = inv.getArgument(0); + ReflectionTestUtils.setField(cl, "id", 7L); + return cl; + }); + + CoverLetterCreateCommand command = new CoverLetterCreateCommand( + "OO기업 공채", + List.of( + new CoverLetterItem("지원동기", "저는 ..."), + new CoverLetterItem("성장과정", "어릴 때 ..."), + new CoverLetterItem("빈 문항", " ") // 답변 공백 → 제거됨 + ) + ); + + CoverLetterResult result = service.create(1L, command); + + assertThat(result.status()).isEqualTo(CoverLetterStatus.PENDING); + assertThat(result.items()).hasSize(2); // 빈 답변 문항 제외 + assertThat(result.items().get(0).question()).isEqualTo("지원동기"); + verify(events).publishEvent(any(CoverLetterUploadedEvent.class)); + } + + @Test + void create_rejectsWhenNoAnswerableItem() { + CoverLetterCreateCommand command = new CoverLetterCreateCommand( + "빈 자소서", + List.of(new CoverLetterItem("문항", " ")) + ); + + assertThatThrownBy(() -> service.create(1L, command)) + .isInstanceOf(DomainException.class); + } + + private User user() { + User u = User.createGithubUser(123L, "octocat", null, null, "tok"); + ReflectionTestUtils.setField(u, "id", 1L); + return u; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index d9208daa..16f6ac1f 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -135,7 +135,7 @@ private InterviewSession sessionFixture(Long id) { private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { return new RabbitMqProperties.RoutingKeyProperties( - "analyze.resume", "analyze.repository", + "analyze.resume", "analyze.repository", "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index e9d53e0c..13a0be08 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -108,7 +108,7 @@ void onSelfIntroAnswered_includesRecentQuestionsForDedup() { private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { return new RabbitMqProperties.RoutingKeyProperties( - "analyze.resume", "analyze.repository", + "analyze.resume", "analyze.repository", "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java index faece9b0..df0a1360 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -209,6 +209,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Queues.Names( "ai.analyze.resume", "ai.analyze.repository", + "ai.analyze.cover_letter", "ai.generate.questions", "ai.generate.followup", "ai.generate.feedback", @@ -223,6 +224,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", + "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", diff --git a/docs/database.md b/docs/database.md index a2d7ddc0..95fa4343 100644 --- a/docs/database.md +++ b/docs/database.md @@ -26,7 +26,8 @@ interview_sessions → session_feedbacks | 3 | `user_consents` | 개인정보처리동의 이력 | | 4 | `repositories` | 면접 분석용 GitHub 레포 메타 | | 5 | `resumes` | 이력서 메타 (실 파일은 S3) | -| 6 | `analyzed_documents` | AI 분석 결과 메타 + S3 경로 | +| 5-1 | `cover_letters` | 자소서(공채) 문항별 텍스트 (`items` JSONB: `[{question,answer}]`). V20 | +| 6 | `analyzed_documents` | AI 분석 결과 메타 + S3 경로. 다형성 FK `resume_id`/`repository_id`/`cover_letter_id`(V20) 중 정확히 하나 | | 7 | `interview_sessions` | 면접 세션 설정·상태·히스토리 | | 7-1 | `session_job_categories` | 세션 직군 다중 선택 (대표 직군은 interview_sessions) | | 8 | `session_contexts` | 세션 ↔ 분석 문서 N:M | diff --git a/docs/messaging.md b/docs/messaging.md index 1defbfc0..e951d075 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -21,6 +21,7 @@ |-------|----------|-------------|----------| | `ai.analyze.repository` | `stackup.core-to-ai` | `analyze.repository` | AI Server | | `ai.analyze.resume` | `stackup.core-to-ai` | `analyze.resume` | AI Server | +| `ai.analyze.cover_letter` | `stackup.core-to-ai` | `analyze.cover_letter` | AI Server | | `ai.analyze.web` | `stackup.core-to-ai` | `analyze.web` | AI Server | | `ai.generate.questions` | `stackup.core-to-ai` | `generate.questions` | AI Server | | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | @@ -37,6 +38,7 @@ | DLQ | Bound to | Routing Key | 격리 대상 | |-----|----------|-------------|-----------| | `dlq.ai.analyze.resume` | `stackup.dlx` | `dlq.ai.analyze.resume` | `ai.analyze.resume` 처리 실패 | +| `dlq.ai.analyze.cover_letter` | `stackup.dlx` | `dlq.ai.analyze.cover_letter` | `ai.analyze.cover_letter` 처리 실패 | | `dlq.ai.analyze.repository` | `stackup.dlx` | `dlq.ai.analyze.repository` | `ai.analyze.repository` 처리 실패 | | `dlq.ai.analyze.web` | `stackup.dlx` | `dlq.ai.analyze.web` | `ai.analyze.web` 처리 실패 | | `dlq.ai.generate.questions` | `stackup.dlx` | `dlq.ai.generate.questions` | `ai.generate.questions` 처리 실패 | diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index 6b6824f4..9829c909 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -44,6 +44,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/workspace/cover-letters', + element: ( + + + + ), + }, { path: '/sessions/new', element: ( diff --git a/frontend/src/features/analysis/model/types.ts b/frontend/src/features/analysis/model/types.ts index d5bad745..6930f6f1 100644 --- a/frontend/src/features/analysis/model/types.ts +++ b/frontend/src/features/analysis/model/types.ts @@ -1,6 +1,6 @@ export type AnalysisStatus = 'PROCESSING' | 'ANALYZED' | 'FAILED' -export type AnalysisSourceType = 'RESUME' | 'REPOSITORY' | 'WEB' +export type AnalysisSourceType = 'RESUME' | 'REPOSITORY' | 'WEB' | 'COVER_LETTER' export type AnalyzedDocument = { id: number diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index 0a88c937..18f11317 100644 --- a/frontend/src/features/analysis/ui/DocumentList.tsx +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -20,6 +20,7 @@ const SOURCE_LABEL: Record = { RESUME: '이력서', REPOSITORY: '레포지토리', WEB: '웹', + COVER_LETTER: '자소서', } const TECH_PREVIEW_COUNT = 4 diff --git a/frontend/src/features/cover-letter/api/coverLetter.ts b/frontend/src/features/cover-letter/api/coverLetter.ts new file mode 100644 index 00000000..1a9ab7a7 --- /dev/null +++ b/frontend/src/features/cover-letter/api/coverLetter.ts @@ -0,0 +1,18 @@ +import { apiClient } from '@/shared/api' +import type { CoverLetter, CoverLetterCreateRequest } from '../model/types' + +export async function fetchCoverLetters(): Promise { + const response = await apiClient.get('/api/cover-letters') + return response.data +} + +export async function createCoverLetter( + body: CoverLetterCreateRequest, +): Promise { + const response = await apiClient.post('/api/cover-letters', body) + return response.data +} + +export async function deleteCoverLetter(id: number): Promise { + await apiClient.delete(`/api/cover-letters/${id}`) +} diff --git a/frontend/src/features/cover-letter/index.ts b/frontend/src/features/cover-letter/index.ts new file mode 100644 index 00000000..621b6e12 --- /dev/null +++ b/frontend/src/features/cover-letter/index.ts @@ -0,0 +1,14 @@ +export { CoverLetterForm } from './ui/CoverLetterForm' +export { CoverLetterList } from './ui/CoverLetterList' +export { + useCoverLetters, + useCreateCoverLetter, + useDeleteCoverLetter, + coverLetterKeys, +} from './model/useCoverLetters' +export type { + CoverLetter, + CoverLetterItem, + CoverLetterStatus, + CoverLetterCreateRequest, +} from './model/types' diff --git a/frontend/src/features/cover-letter/model/types.ts b/frontend/src/features/cover-letter/model/types.ts new file mode 100644 index 00000000..cedaabe4 --- /dev/null +++ b/frontend/src/features/cover-letter/model/types.ts @@ -0,0 +1,8 @@ +import type { components } from '@/shared/api/generated' + +type Schemas = components['schemas'] + +export type CoverLetter = Schemas['CoverLetterResponse'] +export type CoverLetterCreateRequest = Schemas['CoverLetterCreateRequest'] +export type CoverLetterItem = Schemas['Item'] +export type CoverLetterStatus = NonNullable diff --git a/frontend/src/features/cover-letter/model/useCoverLetters.ts b/frontend/src/features/cover-letter/model/useCoverLetters.ts new file mode 100644 index 00000000..cc99c56f --- /dev/null +++ b/frontend/src/features/cover-letter/model/useCoverLetters.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { isApiError } from '@/shared/api' +import { toast } from '@/shared/ui' +import { + createCoverLetter, + deleteCoverLetter, + fetchCoverLetters, +} from '../api/coverLetter' +import type { CoverLetter } from './types' + +const errMessage = (e: unknown, fallback: string) => + isApiError(e) ? e.message : fallback + +export const coverLetterKeys = { + all: ['cover-letters'] as const, +} + +export function useCoverLetters() { + return useQuery({ + queryKey: coverLetterKeys.all, + queryFn: fetchCoverLetters, + }) +} + +export function useCreateCoverLetter() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: createCoverLetter, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: coverLetterKeys.all }) + toast.success('자소서를 저장했어요. 분석이 곧 시작됩니다.') + }, + onError: (e) => toast.error(errMessage(e, '자소서 저장에 실패했어요')), + }) +} + +export function useDeleteCoverLetter() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: deleteCoverLetter, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: coverLetterKeys.all }) + // 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지) + // 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다. + void queryClient.invalidateQueries({ queryKey: ['documents'] }) + toast.success('자소서를 삭제했어요') + }, + onError: (e) => toast.error(errMessage(e, '자소서 삭제에 실패했어요')), + }) +} diff --git a/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx b/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx new file mode 100644 index 00000000..82a16e24 --- /dev/null +++ b/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { Spinner } from '@/shared/ui/Spinner' +import { useCreateCoverLetter } from '../model/useCoverLetters' + +const TITLE_MAX = 200 +const ANSWER_MAX = 5000 + +type DraftItem = { question: string; answer: string } + +const emptyItem = (): DraftItem => ({ question: '', answer: '' }) + +// 공채 자소서 문항별 입력 — 질문(문항) + 답변을 여러 개 추가. 텍스트 전용. +export function CoverLetterForm() { + const [title, setTitle] = useState('') + const [items, setItems] = useState([emptyItem()]) + const create = useCreateCoverLetter() + + const updateItem = (idx: number, patch: Partial) => + setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it))) + const addItem = () => setItems((prev) => [...prev, emptyItem()]) + const removeItem = (idx: number) => + setItems((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))) + + // 답변이 하나라도 채워져 있어야 제출 가능. + const hasAnswer = items.some((it) => it.answer.trim().length > 0) + const submitting = create.isPending + + const handleSubmit = () => { + if (!hasAnswer || submitting) return + const payloadItems = items + .filter((it) => it.answer.trim().length > 0) + .map((it) => ({ question: it.question.trim(), answer: it.answer.trim() })) + create.mutate( + { title: title.trim() || undefined, items: payloadItems }, + { + onSuccess: () => { + setTitle('') + setItems([emptyItem()]) + }, + }, + ) + } + + return ( +
+
+ + setTitle(e.target.value)} + className="rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg outline-none focus:border-primary" + /> +
+ +
+ {items.map((item, idx) => ( +
+
+ 문항 {idx + 1} + {items.length > 1 ? ( + + ) : null} +
+ updateItem(idx, { question: e.target.value })} + className="rounded-lg border border-border bg-surface-raised px-3 py-2 text-body text-fg outline-none focus:border-primary" + /> +