diff --git a/.env.example b/.env.example
index 08dab98..320abfc 100644
--- a/.env.example
+++ b/.env.example
@@ -38,11 +38,17 @@ TOSS_SECRET_KEY=change-me
TOSS_BASE_URL=https://api.tosspayments.com
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
+APP_WORKER_EXCHANGE=jobdri.worker.exchange
APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key
-APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange
+APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE}
APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest
APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest
APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq
+APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE}
+APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute
+APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute
+APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq
+APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
JOB_POSTING_ASYNC_QUEUE_CAPACITY=20
diff --git a/.env.production.example b/.env.production.example
index 5578866..ea85838 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -1,22 +1,27 @@
APP_PORT=8080
+SPRING_PROFILES_ACTIVE=prod
IMAGE_NAME=ghcr.io/jobdri-developer/backend
IMAGE_TAG=latest
+WORKER_IMAGE_NAME=ghcr.io/jobdri-developer/analysis-worker
+WORKER_IMAGE_TAG=latest
DB_URL=jdbc:postgresql://your-db-host:5432/jobdri
DB_USERNAME=jobdri
DB_PASSWORD=change-me
DB_DRIVER=org.postgresql.Driver
JPA_DDL_AUTO=update
+SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION=true
REDIS_HOST=your-upstash-host
REDIS_PORT=6379
REDIS_PASSWORD=change-me
REDIS_SSL_ENABLED=true
-RABBITMQ_HOST=your-rabbitmq-host
+RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=jobdri
RABBITMQ_PASSWORD=change-me
RABBITMQ_VHOST=/
+RABBITMQ_MANAGEMENT_PORT=15672
JWT_SECRET_KEY=base64-encoded-secret
JWT_ACCESS_TOKEN_EXPIRATION=3600000
@@ -36,17 +41,36 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=change-me
APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect
OPENAI_API_KEY=change-me
+COHERE_API_KEY=
TOSS_CLIENT_KEY=change-me
TOSS_SECRET_KEY=change-me
TOSS_BASE_URL=https://api.tosspayments.com
+AWS_REGION=ap-northeast-2
+S3_BUCKET=your-s3-bucket
+AWS_ACCESS_KEY_ID=change-me
+AWS_SECRET_ACCESS_KEY=change-me
+S3_PRESIGN_GET_EXPIRATION_MINUTES=10
+S3_PRESIGN_PUT_EXPIRATION_MINUTES=5
+JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES=5242880
+
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
+APP_WORKER_EXCHANGE=jobdri.worker.exchange
APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key
-APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange
+APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE}
APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest
APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest
APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq
+APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE}
+APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute
+APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute
+APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq
+APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3
+APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS=300000
+WORKER_PREFETCH_COUNT=1
+OPENAI_JOB_POSTING_MODEL=gpt-4o-mini
+OPENAI_ANALYSIS_MODEL=gpt-4o-mini
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
JOB_POSTING_ASYNC_QUEUE_CAPACITY=20
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26d8c32..ff85d09 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,11 +4,11 @@ on:
pull_request:
branches:
- main
- - develop
+ - dev
push:
branches:
- main
- - develop
+ - dev
permissions:
contents: read
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5e5ef1e..ff29774 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
+ - dev
workflow_dispatch:
permissions:
diff --git a/.gitignore b/.gitignore
index 43d7664..c4515e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,8 @@ out/
# application secrets
application-local.yml
+.venv
+
+# Local evaluation datasets/results
+evaluation_cases*.csv
+evaluation_ai_results*.csv
diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck
deleted file mode 100644
index e69de29..0000000
diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part
deleted file mode 100644
index e69de29..0000000
diff --git a/README.md b/README.md
index e0c1c47..e294cfa 100644
--- a/README.md
+++ b/README.md
@@ -1,183 +1,431 @@
-# BackEnd
-Repository of JobDri BackEnd
+# JobDri SERVER
-## Docker
+**AI 기반 채용 공고 정리 · 모의 지원서 생성 · 자소서 분석 플랫폼 `JobDri`의 백엔드 서버**입니다.
-로컬 실행:
+> **"채용 공고를 정리하고, 지원서를 만들고, 자소서까지 분석받자."**
+> 사용자가 업로드하거나 입력한 채용 공고를 AI가 구조화하고,
+> 직무/회사 기반의 **모의 공고와 문항**을 생성하며,
+> 작성한 자소서를 **AI 분석 + 실시간 상태 알림**으로 제공하는 **AI 취업 지원 플랫폼**입니다.
-```bash
-cp .env.example .env
-docker compose up --build
-```
+Server: `https://api.jobdri.site`
-포함 서비스:
+## 📖 프로젝트 소개
-- Spring Boot API: `http://localhost:8080`
-- FastAPI worker health: `http://localhost:8000/health`
-- RabbitMQ: `amqp://localhost:5672`
-- RabbitMQ management: `http://localhost:15672`
+`JobDri`는 채용 공고부터 모의 지원, 자소서 분석까지 이어지는 지원 준비 흐름을 하나의 서비스 안에서 제공하는 백엔드입니다.
-배포 서버 실행:
-
-```bash
-cp .env.production.example .env
-docker compose -f docker-compose.prod.yml up -d
-```
+이 서버는 다음 흐름을 중심으로 동작합니다.
-`prod` 프로필은 `/actuator/health`를 노출합니다.
+1. **채용 공고 입력/정리**
+ 사용자가 텍스트 또는 이미지 기반 공고를 입력하면 AI가 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 구조화합니다.
+2. **공고 저장 및 모의 지원 생성**
+ 저장된 실제 공고 또는 AI가 만든 모의 공고를 기반으로 `MockApply`를 생성하고, 추천 문항 또는 직접 추가 문항을 선택해 지원서를 작성합니다.
+3. **자소서 분석**
+ 작성한 답변을 AI가 비동기로 분석하고, 점수/피드백/문항별 코멘트/누락 키워드를 저장합니다.
+4. **실시간 상태 추적**
+ 긴 작업은 `RabbitMQ + 외부 FastAPI worker + internal callback API + SSE` 구조로 처리하며, 사용자는 진행 상태와 완료 알림을 실시간으로 확인할 수 있습니다.
-## Async Worker Architecture
+## 🔄 Core Flow
+
+### 1. 채용 공고 처리
-현재 비동기 구조는 아래처럼 분리됩니다.
+- `POST /api/job-postings/extract`
+ 텍스트 또는 S3 이미지 object key를 받아 AI가 공고를 추출합니다.
+- `POST /api/job-postings/ingest`
+ 공고 추출 → 분류 후보 탐색 → 공고 초안 생성 → 저장까지 비동기 작업으로 접수합니다.
+- `GET /api/job-postings/ingest/async/{taskId}`
+ 비동기 상태를 조회합니다.
+- `GET /api/job-postings/ingest/async/{taskId}/stream`
+ SSE로 상태 변화를 구독합니다.
-- API 서버: task row 생성, 상태 조회 API 제공, RabbitMQ publish, 최종 DB 저장
-- RabbitMQ: `job posting ingest` 작업 버퍼링과 재전달
-- FastAPI worker: MQ consume, OpenAI 기반 추출/분류/생성 수행, 내부 API callback
+### 2. 모의 지원서 생성
+
+- 저장된 실제 공고 기반 `ACTUAL` 타입 모의 지원 생성
+- 저장된 공고 기반 `MOCK` 타입 모의 지원 생성
+- 회사/직무 분류 기반 가상 공고 생성과 추천 질문 생성
+- 같은 공고 기준 회차(`sequence`) 관리 및 재도전 생성
-로컬에 필요한 추가 env:
+### 3. 자소서 작성 및 분석
-```bash
-RABBITMQ_HOST=localhost
-RABBITMQ_PORT=5672
-RABBITMQ_USERNAME=guest
-RABBITMQ_PASSWORD=guest
-RABBITMQ_VHOST=/
-APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key
-APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange
-APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest
-APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest
-APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq
-WORKER_MAX_RETRY_COUNT=3
+- 문항 후보 조회, 직접 문항 추가, 선택 문항 저장
+- 답변 저장/수정
+- `POST /api/mock-applies/{mockApplyId}/analysis`
+ 자소서 분석 비동기 작업 접수
+- `GET /api/mock-applies/{mockApplyId}/analysis`
+ 저장된 분석 결과 조회
+- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}/stream`
+ SSE 상태 스트림 구독
+
+## 🛠️ Tech Stack
+
+| Category | Stack |
+| --- | --- |
+| **Language** | Java 21 |
+| **Framework** | Spring Boot 3.5.14 |
+| **ORM / DB** | Spring Data JPA, PostgreSQL, H2(Test), pgvector |
+| **Cache / Messaging** | Redis, RabbitMQ |
+| **Auth / Security** | Spring Security, JWT, Google OAuth2 |
+| **AI / Embedding** | OpenAI API (`gpt-4o-mini` default), Cohere Embedding (`embed-v4.0`) |
+| **Storage / Infra** | AWS S3, Docker, GHCR, EC2, Nginx |
+| **Payment** | Toss Payments |
+| **Mail / Realtime** | JavaMailSender(Gmail SMTP), SSE |
+| **Docs / Build** | SpringDoc Swagger, Gradle, GitHub Actions |
+
+## ✨ Key Features
+
+### 1. 인증 및 사용자 관리
+
+- 이메일 인증번호 발송/검증 기반 회원가입
+- JWT Access/Refresh Token 로그인 및 재발급
+- Redis 기반 로그아웃/토큰 무효화 처리
+- Google OAuth2 로그인 및 프론트 리다이렉트 연동
+
+### 2. 채용 공고 AI 처리
+
+- 공고 텍스트/이미지에서 구조화 정보 추출
+- S3 presigned PUT URL 기반 이미지 직접 업로드
+- 직무 소분류 자동 분류 후보 추천
+- 회사/직무 기반 실제 공고 초안 생성
+- 회사/직무 기반 모의 공고 및 추천 질문 생성
+
+### 3. 모의 지원서 작성 흐름
+
+- 실제 공고 기반 `ACTUAL` 타입 지원 생성
+- 생성 공고 기반 `MOCK` 타입 지원 생성
+- 질문 후보 조회 및 직접 문항 추가
+- 선택 문항 저장 후 답변 작성 단계 전환
+- 공고별 회차 관리 및 재도전 지원
+
+### 4. 자소서 분석
+
+- 비동기 분석 작업 접수 및 상태 조회
+- 분석 완료 시 종합 점수, 항목별 점수, 전체 피드백 저장
+- 문항별 분석 결과와 누락 키워드 제공
+- 분석 실행 시 크레딧 1회 차감, 실패 시 환불
+
+### 5. 결제 및 크레딧
+
+- 크레딧 플랜 조회
+- 토스페이먼츠 결제 준비/승인
+- 사용자 크레딧 잔액/거래 내역 조회
+- 기본 플랜
+ `1회권`, `5회권`, `10회권`
+
+### 6. 실시간 알림
+
+- SSE 기반 인앱 알림 스트림
+- 미읽음 개수 조회
+- 개별/전체 읽음 처리
+- 현재 구현된 주요 알림 타입
+ `JOB_POSTING_ASYNC_SUCCEEDED`, `JOB_POSTING_ASYNC_FAILED`, `ANALYSIS_ASYNC_SUCCEEDED`, `ANALYSIS_ASYNC_FAILED`
+
+### 7. 관리자 / 운영 기능
+
+- corpus xlsx 적재 API
+- corpus embedding 동기화 API
+- 분석 retrieval preview 디버그 API
+- 부트스트랩 관리자 이메일 승격
+- 비동기 task timeout sweep 스케줄러
+- 감사 로그 AOP (`@AuditLogEvent`)
+
+## 🔍 상세 기능 명세
+
+### 🧩 인증 및 유저
+
+- `POST /api/auth/email-verifications`
+- `POST /api/auth/email-verifications/confirmations`
+- `POST /api/auth/signup`
+- `POST /api/auth/login`
+- `POST /api/auth/reissue`
+- `POST /api/auth/logout`
+- `GET /oauth2/authorization/google`
+- `GET /login/oauth2/code/google`
+
+### 🗂️ 직무 분류
+
+- `GET /api/classifications`
+- `GET /api/classifications/{bigId}/middles`
+- `GET /api/classifications/middles/{middleId}/details`
+
+### 📝 채용 공고
+
+- `POST /api/job-postings`
+- `PATCH /api/job-postings/{jobPostingId}`
+- `GET /api/job-postings/me`
+- `GET /api/job-postings/me/{jobPostingId}`
+- `DELETE /api/job-postings/{jobPostingId}`
+- `POST /api/job-postings/generate`
+- `POST /api/job-postings/extract`
+- `POST /api/job-postings/ingest`
+- `GET /api/job-postings/ingest/async/{taskId}`
+- `GET /api/job-postings/ingest/async/{taskId}/stream`
+- `POST /api/job-postings/images/presign-upload`
+- `POST /api/job-postings/extension/ingest`
+
+### 🎯 모의 공고 / 모의 지원
+
+- `POST /api/job-postings/mock/generate`
+- `GET /api/job-postings/mock/questions`
+- `POST /api/job-postings/mock/questions`
+- `GET /api/mock-applies/me`
+- `POST /api/mock-applies/actual`
+- `POST /api/mock-applies/mock/from-job-posting`
+- `POST /api/mock-applies/mock`
+- `POST /api/mock-applies/{mockApplyId}/retry`
+- `GET /api/mock-applies/{mockApplyId}/job-posting`
+- `GET /api/mock-applies/{mockApplyId}/sequence`
+
+### ✍️ 문항 / 답변 / 분석
+
+- `GET /api/mock-applies/{mockApplyId}/questions/candidates`
+- `POST /api/mock-applies/{mockApplyId}/questions/candidates`
+- `GET /api/mock-applies/{mockApplyId}/questions`
+- `PUT /api/mock-applies/{mockApplyId}/questions`
+- `PATCH /api/mock-applies/{mockApplyId}/questions/answers`
+- `POST /api/mock-applies/{mockApplyId}/analysis`
+- `GET /api/mock-applies/{mockApplyId}/analysis`
+- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}`
+- `GET /api/mock-applies/{mockApplyId}/analysis/async/{taskId}/stream`
+- `GET /api/job-postings/{jobPostingId}/analysis`
+
+### 💳 결제 / 크레딧
+
+- `GET /api/payments/plans`
+- `POST /api/payments/prepare`
+- `POST /api/payments/confirm`
+- `GET /api/payments/credits/me`
+- `GET /api/payments/credits/me/transactions`
+
+### 🔔 알림
+
+- `GET /api/notifications`
+- `GET /api/notifications/unread-count`
+- `GET /api/notifications/stream`
+- `PATCH /api/notifications/{notificationId}/read`
+- `PATCH /api/notifications/read-all`
+
+### 🛠️ 관리자 / 워커 내부 API
+
+- `POST /api/admin/corpus/import`
+- `POST /api/admin/corpus/import/upload`
+- `POST /api/admin/corpus/embeddings/sync`
+- `POST /api/admin/analysis/retrieval-preview`
+- `POST /api/internal/worker/job-postings/...`
+- `POST /api/internal/worker/analysis/...`
+
+## 🏛️ System Architecture
+
+### 1. 전체 서버 아키텍처
+
+```mermaid
+graph TD
+ Client[Web Client] -->|JWT / OAuth2 / REST| Spring[Spring Boot API]
+ Client -->|SSE| Spring
+ Client -->|Presigned URL Upload| S3[(AWS S3)]
+
+ Spring -->|JPA| Postgres[(PostgreSQL)]
+ Spring -->|Cache / Token / Realtime state| Redis[(Redis)]
+ Spring -->|AMQP Publish| Rabbit[(RabbitMQ)]
+ Spring -->|OpenAI Responses API| OpenAI[OpenAI]
+ Spring -->|Embedding Sync| Cohere[Cohere]
+ Spring -->|Payment Confirm| Toss[Toss Payments]
+ Spring -->|SMTP| Gmail[Gmail SMTP]
+
+ Rabbit -->|Task Consume| Worker[FastAPI Worker
external deployment]
+ Worker -->|Internal callback API| Spring
+
+ GitHub[GitHub Actions] -->|Build jar / Docker image| GHCR[GHCR]
+ GHCR -->|Pull latest image| EC2[EC2 + Docker Compose]
+ EC2 -->|Reverse Proxy| Nginx[Nginx]
+ Nginx --> Spring
```
-스모크 테스트:
-
-1. `docker compose up --build`
-2. 로그인 토큰을 준비합니다.
-3. `POST /api/job-postings/ingest` 로 `rawText` 또는 `imageObjectKey`를 전송합니다.
-4. 응답의 `taskId`로 `GET /api/job-postings/ingest/async/{taskId}` 를 조회합니다.
-5. worker가 성공하면 `SUCCEEDED`, 실패하면 `FAILED` 와 에러 메시지가 반환됩니다.
-
-worker 단독 실행:
-
-```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -r worker/requirements.txt
-uvicorn worker.app.main:app --host 0.0.0.0 --port 8000
+### 2. 비동기 워커 처리 흐름
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant C as Client
+ participant S as Spring Boot API
+ participant MQ as RabbitMQ
+ participant W as FastAPI Worker
+ participant DB as PostgreSQL
+ participant SSE as SSE Stream
+
+ U->>C: 채용 공고 분석 / 자소서 분석 요청
+ C->>S: 비동기 API 호출
+ S->>DB: async task 생성
+ S->>MQ: 작업 메시지 publish
+ S-->>C: taskId 반환
+
+ C->>SSE: taskId 기준 상태 스트림 구독
+ MQ->>W: 작업 전달
+ W->>S: context 조회 (internal API)
+ W->>S: running / retry / failed / complete callback
+ S->>DB: 상태 및 결과 저장
+ S-->>SSE: 상태 변경 이벤트 전송
+ SSE-->>C: 진행 상태 / 완료 / 실패 수신
```
-## Corpus Import Script
+## 🔁 Async Worker Flow
+
+이 서버의 핵심 비동기 작업은 `채용 공고 ingest`와 `자소서 분석` 두 종류입니다.
-대용량 corpus 엑셀 적재는 관리자 API 대신 Python 스크립트로 직접 실행할 수 있습니다.
+1. 사용자가 비동기 API를 호출합니다.
+2. Spring 서버가 task row를 생성합니다.
+3. Spring 서버가 RabbitMQ exchange/queue로 작업 메시지를 publish 합니다.
+4. 외부 FastAPI worker가 메시지를 consume 합니다.
+5. worker는 필요 컨텍스트를 internal API로 조회합니다.
+6. worker가 AI 처리 결과를 internal callback API로 완료/실패 반영합니다.
+7. Spring 서버가 DB 저장, 알림 생성, SSE 상태 전파를 수행합니다.
-설치:
+## 📂 Project Structure
```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -r scripts/requirements-corpus-import.txt
+com.jobdri.jobdri_api
+├── domain
+│ ├── analysis # 자소서 분석, 질문 선택, async task, worker bridge
+│ ├── auth # 회원가입, 로그인, 이메일 인증, OAuth2
+│ ├── classification # 대/중/소분류 직무 체계 조회
+│ ├── company # 회사 엔티티
+│ ├── corpus # corpus 적재, 임베딩 동기화, retrieval
+│ ├── jobposting # 공고 저장/수정/생성/추출/ingest/extension 연동
+│ ├── mockapply # 모의 지원 생성, 회차, 재도전
+│ ├── notification # SSE 알림, 읽음 처리
+│ ├── payment # 크레딧, 결제, 거래 내역
+│ ├── audit # 감사 로그 AOP
+│ ├── user # 사용자 도메인
+│ └── skill / experience
+├── global
+│ ├── apiPayload # 공통 응답/에러 코드
+│ ├── config # Security, Swagger, RabbitMQ, AWS S3, Async 설정
+│ ├── jwt # JWT 필터/유틸
+│ ├── mq # RabbitMQ publish support
+│ ├── scheduling # async timeout sweep scheduler
+│ ├── security # UserDetails, internal API key validator
+│ └── sse # SSE subscription registry
+└── resources
+ ├── application*.yaml
+ └── schema.sql
```
-실행:
+## 🚀 Local Run
+
+### 1. 애플리케이션 실행
```bash
cp .env.example .env
-python scripts/import_corpus.py /absolute/path/to/file.xlsx
+./gradlew bootRun
```
-- `.env`의 `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`를 사용합니다.
-- 기본 env 파일 경로를 바꾸려면 `--env-file /path/to/.env` 옵션을 사용할 수 있습니다.
-- 엑셀 시트명은 `jd_embed_corpus`, `question_embed_corpus`를 사용합니다.
-- `source_analysis_id`, `source_question_id` 기준으로 `INSERT ... ON CONFLICT DO UPDATE` 방식으로 적재합니다.
-
-## Corpus Embedding Sync Script
+기본 포트:
-관리자 API 대신 Python 스크립트로 corpus 임베딩을 일괄 동기화할 수 있습니다.
+- API: `http://localhost:8080`
+- Swagger: `http://localhost:8080/swagger-ui/index.html`
-실행:
+### 2. 테스트 실행
```bash
-source .venv/bin/activate
-pip install -r scripts/requirements-corpus-import.txt
-python scripts/sync_corpus_embeddings.py --env-file .env
+./gradlew test
```
-옵션 예시:
+테스트 환경은 `H2 (MODE=PostgreSQL)`를 사용합니다.
+
+### 3. Docker Compose
```bash
-python scripts/sync_corpus_embeddings.py --env-file .env --limit 100
-python scripts/sync_corpus_embeddings.py --env-file .env --job-only
-python scripts/sync_corpus_embeddings.py --env-file .env --question-only --batch-size 16
+cp .env.example .env
+docker compose up --build
```
-- `.env`의 `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `COHERE_API_KEY`를 사용합니다.
-- 기본 모델은 `embed-v4.0`, 기본 배치 크기는 `32`입니다.
-- `mock_job_posting_embeddings`, `mock_question_embeddings` 테이블에 `INSERT ... ON CONFLICT DO UPDATE` 방식으로 적재합니다.
+포함 의도 서비스:
-## CI/CD
+- Spring Boot API
+- PostgreSQL
+- Redis
+- RabbitMQ
+- external FastAPI worker 연결용 worker service 정의
-- `CI`: `main`, `develop` 브랜치 push 및 PR에서 테스트와 Docker 이미지 빌드를 실행합니다.
-- `Deploy`: `main` 브랜치 push 또는 수동 실행 시 GHCR에 이미지를 푸시하고, 배포 서버 secret이 있으면 SSH로 `docker-compose.prod.yml`을 갱신합니다.
-GitHub Actions 배포 secret:
+## ⚙️ Environment
-- `DEPLOY_HOST`
-- `DEPLOY_USER`
-- `DEPLOY_PORT` optional, default `22`
-- `DEPLOY_SSH_KEY`
-- `DEPLOY_PATH`
-- `GHCR_USERNAME`
-- `GHCR_TOKEN`
+주요 환경 변수:
-## 배포 환경
+- `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`
+- `REDIS_HOST`, `REDIS_PORT`
+- `RABBITMQ_HOST`, `RABBITMQ_PORT`, `RABBITMQ_USERNAME`, `RABBITMQ_PASSWORD`
+- `JWT_SECRET_KEY`, `JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION`
+- `MAIL_HOST`, `MAIL_USERNAME`, `MAIL_PASSWORD`
+- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
+- `OPENAI_API_KEY`
+- `COHERE_API_KEY`
+- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`
+- `TOSS_CLIENT_KEY`, `TOSS_SECRET_KEY`
+- `APP_WORKER_INTERNAL_API_KEY`
-백엔드 API는 Docker 이미지로 빌드되어 GHCR에 업로드되고, GitHub Actions를 통해 EC2 서버에 배포됩니다.
+worker 연동 관련 기본 queue 설정:
-### 인프라
+- `APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest`
+- `APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute`
-- 서버: AWS EC2 `t3.small`
-- OS: Amazon Linux 2023
-- DB: AWS RDS PostgreSQL `db.t3.micro`
-- Redis: Upstash Redis
-- 웹 서버: Nginx
-- 컨테이너 런타임: Docker, Docker Compose
+## 🧠 Corpus / Retrieval
-### 배포 흐름
+이 프로젝트는 단순 LLM 호출만 사용하는 구조가 아니라, corpus 기반 retrieval를 함께 사용합니다.
-1. `main` 브랜치에 변경사항이 반영됩니다.
-2. GitHub Actions가 Docker 이미지를 빌드합니다.
-3. 이미지를 GHCR에 푸시합니다.
-4. GitHub Actions가 EC2에 SSH로 접속합니다.
-5. EC2에서 `docker-compose.prod.yml`을 사용해 최신 이미지를 pull하고 API 컨테이너를 재기동합니다.
+- 채용 공고 corpus 적재
+- 질문 corpus 적재
+- Cohere 임베딩 생성
+- pgvector 저장
+- 모의 공고 생성 및 자소서 분석 시 retrieval context 참고
+- 관리자 preview API로 retrieval 결과 사전 점검 가능
-### 서버 배포 경로
+Python 스크립트도 포함되어 있습니다.
-```bash
-/opt/jobdri-api
-```
+- `scripts/import_corpus.py`
+- `scripts/sync_corpus_embeddings.py`
-서버에는 아래 파일이 필요합니다.
+## 🚀 Deployment Pipeline
-```text
-docker-compose.prod.yml
-.env
-```
+GitHub Actions 기준 배포 흐름:
-## 운영 API 도메인
+1. `main`, `dev` 브랜치 push 또는 PR 발생
+2. CI에서 JDK 21 기반 `./gradlew clean test` 실행
+3. Docker image build
+4. Deploy workflow에서 GHCR에 이미지 push
+5. 배포 secret이 존재하면 원격 서버에 SSH 접속
+6. 서버에서 `docker compose -f docker-compose.prod.yml pull api`
+7. `docker compose -f docker-compose.prod.yml up -d api`
-운영 API는 Nginx reverse proxy와 Let's Encrypt 인증서를 통해 HTTPS로 제공됩니다.
+## 📝 API Documentation
-```text
-https://api.jobdri.site
-```
+SpringDoc Swagger UI:
-- Nginx는 80/443 요청을 Spring Boot API 컨테이너의 8080 포트로 프록시합니다.
-- SSL 인증서는 Certbot으로 발급했으며 자동 갱신이 설정되어 있습니다.
-- 상태 확인: `https://api.jobdri.site/actuator/health`
+- Local: `http://localhost:8080/swagger-ui/index.html`
+- Production: `https://api.jobdri.site/swagger-ui/index.html`
-정상 응답:
+추가 공개 경로:
-```json
-{"status":"UP","groups":["liveness","readiness"]}
-```
+- OpenAPI docs: `/v3/api-docs`
+- Health check: `/actuator/health`
+
+## 📌 현재 구현 범위
+
+- 이메일 인증 기반 로컬 회원가입/로그인
+- Google OAuth2 로그인
+- JWT 인증/인가 및 로그아웃 처리
+- 직무 대/중/소분류 조회
+- 채용 공고 생성/수정/조회/삭제
+- 채용 공고 이미지 presigned upload
+- AI 채용 공고 추출
+- AI 채용 공고 초안 생성
+- AI 모의 공고 생성
+- 추천 질문 캐시 및 조회
+- 실제/모의 공고 기반 모의 지원 생성
+- 질문 선택, 직접 문항 추가, 답변 저장
+- 자소서 분석 비동기 처리
+- 크레딧 차감/환불
+- 토스 결제 연동
+- SSE 알림
+- 관리자 corpus import / embedding sync
+- RabbitMQ 기반 외부 worker 연동용 internal API
+- async timeout sweep scheduler
+- 감사 로그 적재
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 14a8931..cdc0832 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,4 +1,20 @@
services:
+ rabbitmq:
+ image: rabbitmq:3.13-management-alpine
+ container_name: jobdri-rabbitmq
+ environment:
+ RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-jobdri}
+ RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-change-me}
+ RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/}
+ ports:
+ - "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672"
+ healthcheck:
+ test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ restart: unless-stopped
+
api:
image: ${IMAGE_NAME:-ghcr.io/jobdri-developer/backend}:${IMAGE_TAG:-latest}
container_name: jobdri-api
@@ -7,6 +23,46 @@ services:
environment:
SPRING_PROFILES_ACTIVE: prod
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
+ RABBITMQ_HOST: rabbitmq
ports:
- "${APP_PORT:-8080}:8080"
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ restart: unless-stopped
+
+ worker:
+ image: ${WORKER_IMAGE_NAME:-ghcr.io/jobdri-developer/analysis-server}:${WORKER_IMAGE_TAG:-latest}
+ container_name: jobdri-analysis-worker
+ env_file:
+ - .env
+ environment:
+ WORKER_ENV: prod
+ SPRING_API_BASE_URL: http://api:8080
+ APP_WORKER_INTERNAL_API_KEY: ${APP_WORKER_INTERNAL_API_KEY}
+ RABBITMQ_HOST: rabbitmq
+ RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
+ RABBITMQ_USERNAME: ${RABBITMQ_USERNAME}
+ RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
+ RABBITMQ_VHOST: ${RABBITMQ_VHOST:-/}
+ APP_WORKER_JOB_POSTING_EXCHANGE: ${APP_WORKER_JOB_POSTING_EXCHANGE:-${APP_WORKER_EXCHANGE:-jobdri.worker.exchange}}
+ APP_WORKER_JOB_POSTING_QUEUE: ${APP_WORKER_JOB_POSTING_QUEUE:-jobdri.job-posting.ingest}
+ APP_WORKER_JOB_POSTING_ROUTING_KEY: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:-job-posting.ingest}
+ APP_WORKER_JOB_POSTING_DLQ: ${APP_WORKER_JOB_POSTING_DLQ:-jobdri.job-posting.ingest.dlq}
+ APP_WORKER_ANALYSIS_EXCHANGE: ${APP_WORKER_ANALYSIS_EXCHANGE:-${APP_WORKER_EXCHANGE:-jobdri.worker.exchange}}
+ APP_WORKER_ANALYSIS_QUEUE: ${APP_WORKER_ANALYSIS_QUEUE:-jobdri.analysis.execute}
+ APP_WORKER_ANALYSIS_ROUTING_KEY: ${APP_WORKER_ANALYSIS_ROUTING_KEY:-analysis.execute}
+ APP_WORKER_ANALYSIS_DLQ: ${APP_WORKER_ANALYSIS_DLQ:-jobdri.analysis.execute.dlq}
+ APP_WORKER_ANALYSIS_MAX_RETRY_COUNT: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:-3}
+ APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS: ${APP_WORKER_ANALYSIS_QUEUE_TIMEOUT_MILLIS:-300000}
+ WORKER_PREFETCH_COUNT: ${WORKER_PREFETCH_COUNT:-1}
+ JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:-0.65}
+ OPENAI_API_KEY: ${OPENAI_API_KEY}
+ OPENAI_JOB_POSTING_MODEL: ${OPENAI_JOB_POSTING_MODEL:-gpt-4o-mini}
+ OPENAI_ANALYSIS_MODEL: ${OPENAI_ANALYSIS_MODEL:-gpt-4o-mini}
+ depends_on:
+ api:
+ condition: service_started
+ rabbitmq:
+ condition: service_healthy
restart: unless-stopped
diff --git a/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql
new file mode 100644
index 0000000..b743b58
--- /dev/null
+++ b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql
@@ -0,0 +1,20 @@
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40);
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100);
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0;
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3;
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP;
+
+ALTER TABLE analysis_async_tasks
+ ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT;
diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql
new file mode 100644
index 0000000..37d4942
--- /dev/null
+++ b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql
@@ -0,0 +1,5 @@
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS user_id BIGINT;
+
+CREATE INDEX IF NOT EXISTS idx_job_posting_async_tasks_user_id_created_at
+ ON job_posting_async_tasks (user_id, created_at DESC);
diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql
new file mode 100644
index 0000000..d17e0e1
--- /dev/null
+++ b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql
@@ -0,0 +1,20 @@
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40);
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100);
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0;
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3;
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP;
+
+ALTER TABLE job_posting_async_tasks
+ ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT;
diff --git a/ops/db/migrations/20260707_notifications.sql b/ops/db/migrations/20260707_notifications.sql
new file mode 100644
index 0000000..b290381
--- /dev/null
+++ b/ops/db/migrations/20260707_notifications.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS notifications (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ notification_type VARCHAR(50) NOT NULL,
+ title VARCHAR(120) NOT NULL,
+ body VARCHAR(500) NOT NULL,
+ target_type VARCHAR(50) NOT NULL,
+ target_id VARCHAR(100),
+ payload_json TEXT,
+ read_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP,
+ CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_notifications_user_created_at
+ ON notifications (user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_notifications_user_read_at
+ ON notifications (user_id, read_at)
+ WHERE read_at IS NULL;
diff --git a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java
index 897d2d4..7c9bcaa 100644
--- a/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java
+++ b/src/main/java/com/jobdri/jobdri_api/JobdriApiApplication.java
@@ -4,14 +4,39 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+import java.util.Arrays;
@SpringBootApplication
@EnableAsync
@EnableRetry
+@EnableScheduling
public class JobdriApiApplication {
public static void main(String[] args) {
+ disableDevtoolsRestartForAnalysisEval(args);
SpringApplication.run(JobdriApiApplication.class, args);
}
+ private static void disableDevtoolsRestartForAnalysisEval(String[] args) {
+ String activeProfiles = System.getProperty("spring.profiles.active", System.getenv("SPRING_PROFILES_ACTIVE"));
+ boolean analysisEvalProfile = containsAnalysisEvalProfile(activeProfiles);
+ if (!analysisEvalProfile) {
+ analysisEvalProfile = Arrays.stream(args)
+ .filter(arg -> arg.startsWith("--spring.profiles.active="))
+ .map(arg -> arg.substring("--spring.profiles.active=".length()))
+ .anyMatch(JobdriApiApplication::containsAnalysisEvalProfile);
+ }
+ if (analysisEvalProfile) {
+ System.setProperty("spring.devtools.restart.enabled", "false");
+ }
+ }
+
+ private static boolean containsAnalysisEvalProfile(String activeProfiles) {
+ return activeProfiles != null && Arrays.stream(activeProfiles.split(","))
+ .map(String::trim)
+ .anyMatch("analysis-eval"::equals);
+ }
+
}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java
index 23b0585..c9985a3 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java
@@ -4,6 +4,7 @@
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse;
import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncFacadeService;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncSseService;
import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
@@ -13,11 +14,13 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.http.MediaType;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequiredArgsConstructor
@@ -27,6 +30,7 @@ public class AnalysisController {
private final AnalysisService analysisService;
private final AnalysisAsyncFacadeService analysisAsyncFacadeService;
+ private final AnalysisAsyncSseService analysisAsyncSseService;
@Operation(summary = "자소서 분석 비동기 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서 분석 작업을 접수합니다.")
@PostMapping
@@ -53,6 +57,20 @@ public ApiResponse getAnalysisTask(
);
}
+ @Operation(summary = "자소서 분석 비동기 작업 상태 SSE 구독", description = "taskId로 자소서 분석 비동기 작업 상태를 SSE 스트림으로 구독합니다.")
+ @GetMapping(value = "/async/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public SseEmitter streamAnalysisTask(
+ @AuthenticationPrincipal UserDetailsImpl userDetails,
+ @PathVariable Long mockApplyId,
+ @PathVariable String taskId
+ ) {
+ var user = getAuthenticatedUser(userDetails);
+ return analysisAsyncSseService.subscribe(
+ taskId,
+ () -> analysisAsyncFacadeService.getTask(user, mockApplyId, taskId)
+ );
+ }
+
@Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.")
@GetMapping
public ApiResponse getAnalysis(
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java
new file mode 100644
index 0000000..c5f9452
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java
@@ -0,0 +1,119 @@
+package com.jobdri.jobdri_api.domain.analysis.controller;
+
+import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse;
+import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextRequest;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerFailureRequest;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRetryRequest;
+import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRunningRequest;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncTaskService;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisWorkerBridgeService;
+import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
+import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator;
+import jakarta.validation.Valid;
+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;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/internal/worker/analysis")
+public class AnalysisWorkerInternalController {
+
+ private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key";
+
+ private final InternalApiKeyValidator internalApiKeyValidator;
+ private final AnalysisWorkerBridgeService analysisWorkerBridgeService;
+ private final AnalysisAsyncTaskService analysisAsyncTaskService;
+
+ @PostMapping("/tasks/{taskId}/running")
+ public ApiResponse markRunning(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @PathVariable String taskId,
+ @Valid @RequestBody AnalysisWorkerRunningRequest request
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ analysisWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt());
+ return ApiResponse.onSuccess("자소서 분석 worker 작업 시작 상태를 반영했습니다.");
+ }
+
+ @PostMapping("/tasks/{taskId}/retry")
+ public ApiResponse markRetry(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @PathVariable String taskId,
+ @Valid @RequestBody AnalysisWorkerRetryRequest request
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ analysisWorkerBridgeService.markRetry(
+ taskId,
+ request.failureReason(),
+ request.errorMessage(),
+ request.retryCount(),
+ request.workerId(),
+ request.queueLatencyMillis()
+ );
+ return ApiResponse.onSuccess("자소서 분석 worker 작업 재시도 상태를 반영했습니다.");
+ }
+
+ @PostMapping("/tasks/{taskId}/failed")
+ public ApiResponse failTask(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @PathVariable String taskId,
+ @Valid @RequestBody AnalysisWorkerFailureRequest request
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ analysisWorkerBridgeService.failTask(
+ taskId,
+ request.failureReason(),
+ request.errorMessage(),
+ request.retryCount(),
+ request.workerId(),
+ request.queueLatencyMillis()
+ );
+ return ApiResponse.onSuccess("자소서 분석 worker 작업 실패 상태를 반영했습니다.");
+ }
+
+ @PostMapping("/context")
+ public ApiResponse getContext(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @Valid @RequestBody AnalysisWorkerContextRequest request
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ return ApiResponse.onSuccess(
+ "자소서 분석 worker 컨텍스트 조회에 성공했습니다.",
+ analysisWorkerBridgeService.getContext(request.taskId(), request.userId(), request.mockApplyId())
+ );
+ }
+
+ @PostMapping("/tasks/{taskId}/complete")
+ public ApiResponse completeTask(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @PathVariable String taskId,
+ @Valid @RequestBody AnalysisWorkerCompleteRequest request
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ return ApiResponse.onSuccess(
+ "자소서 분석 worker 작업 완료 상태를 반영했습니다.",
+ analysisWorkerBridgeService.completeTask(taskId, request)
+ );
+ }
+
+ @GetMapping("/tasks/{taskId}")
+ public ApiResponse getTask(
+ @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey,
+ @PathVariable String taskId
+ ) {
+ internalApiKeyValidator.validate(internalApiKey);
+ return ApiResponse.onSuccess(
+ "자소서 분석 worker 작업 상태 조회에 성공했습니다.",
+ analysisAsyncTaskService.getTaskStatusByTaskId(taskId)
+ );
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java
new file mode 100644
index 0000000..15733c5
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java
@@ -0,0 +1,16 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.criteria;
+
+import java.util.List;
+
+public record JobCategoryEvaluationCriteria(
+ String jobCategoryMiddle,
+ List includedJobs,
+ List competencyTags,
+ List coreCompetencies,
+ List relatedActions,
+ List relatedExperiences,
+ List relatedKeywords,
+ String goodEvidenceExample,
+ List missingKeywordExamples
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java
index f2fbbbd..04d27a1 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java
@@ -7,8 +7,25 @@ public record AnalysisLlmResponse(
Integer impact,
Integer completeness,
String feedback,
+ List missingKeywords,
List questionAnalyses
) {
+ public AnalysisLlmResponse(
+ Integer jobFit,
+ Integer impact,
+ Integer completeness,
+ String feedback,
+ List questionAnalyses
+ ) {
+ this(jobFit, impact, completeness, feedback, List.of(), questionAnalyses);
+ }
+
+ public record MissingKeywordItem(
+ String keyword,
+ String source
+ ) {
+ }
+
public record QuestionAnalysisItem(
Long questionId,
String sentence,
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java
index 5220d1b..62000ce 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java
@@ -11,7 +11,14 @@ public record AnalysisAsyncStatusResponse(
String status,
String message,
String error,
+ String failureReason,
+ String workerId,
+ Integer retryCount,
+ Integer maxRetryCount,
+ Long queueLatencyMillis,
LocalDateTime createdAt,
+ LocalDateTime submittedAt,
+ LocalDateTime lastAttemptAt,
LocalDateTime startedAt,
LocalDateTime completedAt,
AnalysisResponse result
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java
index 9d841fb..7f9cdad 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java
@@ -15,12 +15,14 @@ public record AnalysisResponse(
int impact,
int completeness,
String feedback,
+ List missingKeywords,
List questions
) {
public static AnalysisResponse of(
Analysis analysis,
MockApplyStatus status,
int sequence,
+ List missingKeywords,
List questions
) {
return new AnalysisResponse(
@@ -33,6 +35,7 @@ public static AnalysisResponse of(
analysis.getImpact(),
analysis.getCompleteness(),
analysis.getFeedback(),
+ missingKeywords == null ? List.of() : missingKeywords,
questions
);
}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java
new file mode 100644
index 0000000..27464ac
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordResponse.java
@@ -0,0 +1,7 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.response;
+
+public record MissingKeywordResponse(
+ String keyword,
+ MissingKeywordSource source
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java
new file mode 100644
index 0000000..ced0646
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/MissingKeywordSource.java
@@ -0,0 +1,38 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+public enum MissingKeywordSource {
+ QUALIFICATION("qualification"),
+ PREFERENCE("preference"),
+ MAIN_TASK("mainTask");
+
+ private final String value;
+
+ MissingKeywordSource(String value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public String value() {
+ return value;
+ }
+
+ @JsonCreator
+ public static MissingKeywordSource fromJson(String value) {
+ return from(value).orElse(null);
+ }
+
+ public static Optional from(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ return Arrays.stream(values())
+ .filter(source -> source.value.equals(value.trim()))
+ .findFirst();
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java
new file mode 100644
index 0000000..04d23bf
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java
@@ -0,0 +1,37 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import lombok.Builder;
+
+import java.time.Instant;
+import java.util.UUID;
+
+@Builder
+public record AnalysisTaskMessage(
+ String messageId,
+ String taskType,
+ String taskId,
+ Long userId,
+ Long mockApplyId,
+ int retryCount,
+ int maxRetryCount,
+ Instant submittedAt
+) {
+
+ public static AnalysisTaskMessage of(
+ String taskId,
+ Long userId,
+ Long mockApplyId,
+ int maxRetryCount
+ ) {
+ return AnalysisTaskMessage.builder()
+ .messageId(UUID.randomUUID().toString())
+ .taskType("ANALYSIS")
+ .taskId(taskId)
+ .userId(userId)
+ .mockApplyId(mockApplyId)
+ .retryCount(0)
+ .maxRetryCount(Math.max(0, maxRetryCount))
+ .submittedAt(Instant.now())
+ .build();
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java
new file mode 100644
index 0000000..32eb1fd
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java
@@ -0,0 +1,15 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record AnalysisWorkerCompleteRequest(
+ @NotNull Long userId,
+ @NotNull Long mockApplyId,
+ @Valid @NotNull AnalysisLlmResponse llmResponse,
+ @NotBlank String workerId,
+ Long queueLatencyMillis
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java
new file mode 100644
index 0000000..0ec7a04
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java
@@ -0,0 +1,11 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record AnalysisWorkerContextRequest(
+ @NotBlank String taskId,
+ @NotNull Long userId,
+ @NotNull Long mockApplyId
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java
new file mode 100644
index 0000000..f92e7a9
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java
@@ -0,0 +1,25 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import java.util.List;
+
+public record AnalysisWorkerContextResponse(
+ Long userId,
+ Long mockApplyId,
+ String companyName,
+ String jobTitle,
+ String task,
+ String requirements,
+ String preferredQualifications,
+ String bigClassificationName,
+ String middleClassificationName,
+ String detailClassificationName,
+ List questions
+) {
+ public record AnalysisWorkerQuestionItem(
+ Long questionId,
+ String question,
+ String answer,
+ int charLimit
+ ) {
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java
new file mode 100644
index 0000000..1c9d902
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java
@@ -0,0 +1,15 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record AnalysisWorkerFailureRequest(
+ @NotBlank String errorMessage,
+ @NotNull FailureReason failureReason,
+ @Min(0) int retryCount,
+ @NotBlank String workerId,
+ Long queueLatencyMillis
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java
new file mode 100644
index 0000000..b8448db
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java
@@ -0,0 +1,15 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record AnalysisWorkerRetryRequest(
+ @NotBlank String errorMessage,
+ @NotNull FailureReason failureReason,
+ @Min(0) int retryCount,
+ @NotBlank String workerId,
+ Long queueLatencyMillis
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java
new file mode 100644
index 0000000..08ecd1f
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java
@@ -0,0 +1,13 @@
+package com.jobdri.jobdri_api.domain.analysis.dto.worker;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+import java.time.Instant;
+
+public record AnalysisWorkerRunningRequest(
+ @NotBlank String workerId,
+ @Min(0) int retryCount,
+ Instant submittedAt
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java
index 4080120..10e6132 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java
@@ -39,6 +39,10 @@ public class Analysis extends BaseEntity {
@Column(nullable = false, columnDefinition = "TEXT")
private String feedback;
+ @Builder.Default
+ @Column(name = "missing_keywords", nullable = false, columnDefinition = "TEXT DEFAULT '[]'")
+ private String missingKeywordsJson = "[]";
+
@Builder.Default
@OneToMany(mappedBy = "analysis", cascade = CascadeType.ALL, orphanRemoval = true)
private List questionAnalyses = new ArrayList<>();
@@ -50,6 +54,18 @@ public static Analysis create(
int impact,
int completeness,
String feedback
+ ) {
+ return create(mockApply, score, jobFit, impact, completeness, feedback, "[]");
+ }
+
+ public static Analysis create(
+ MockApply mockApply,
+ int score,
+ int jobFit,
+ int impact,
+ int completeness,
+ String feedback,
+ String missingKeywordsJson
) {
return Analysis.builder()
.mockApply(mockApply)
@@ -58,6 +74,7 @@ public static Analysis create(
.impact(impact)
.completeness(completeness)
.feedback(feedback)
+ .missingKeywordsJson(missingKeywordsJson == null ? "[]" : missingKeywordsJson)
.build();
}
}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java
index c9cc408..513ce37 100644
--- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java
@@ -11,6 +11,8 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
+import java.time.Duration;
+import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
@@ -47,13 +49,35 @@ public class AnalysisAsyncTask extends CreatedAtEntity {
@Column(length = 2000)
private String error;
+ @Enumerated(EnumType.STRING)
+ @Column(name = "failure_reason", length = 40)
+ private FailureReason failureReason;
+
+ @Column(name = "worker_id", length = 100)
+ private String workerId;
+
+ @Column(name = "retry_count", nullable = false)
+ private int retryCount;
+
+ @Column(name = "max_retry_count", nullable = false)
+ private int maxRetryCount;
+
+ @Column(name = "submitted_at")
+ private LocalDateTime submittedAt;
+
+ @Column(name = "last_attempt_at")
+ private LocalDateTime lastAttemptAt;
+
+ @Column(name = "queue_latency_millis")
+ private Long queueLatencyMillis;
+
@Column(name = "started_at")
private LocalDateTime startedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
- public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) {
+ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) {
AnalysisAsyncTask task = new AnalysisAsyncTask();
task.taskId = UUID.randomUUID().toString();
task.userId = userId;
@@ -61,6 +85,9 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) {
task.creditStatus = CreditStatus.NONE;
task.status = TaskStatus.PENDING;
task.message = "자소서 분석 비동기 작업이 접수되었습니다.";
+ task.retryCount = 0;
+ task.maxRetryCount = Math.max(0, maxRetryCount);
+ task.submittedAt = LocalDateTime.now();
return task;
}
@@ -77,26 +104,72 @@ public void markCreditReleased() {
this.creditStatus = CreditStatus.RELEASED;
}
- public void markRunning() {
+ public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) {
+ if (isTerminal()) {
+ return;
+ }
this.status = TaskStatus.RUNNING;
this.message = "자소서 분석을 진행 중입니다.";
- this.startedAt = LocalDateTime.now();
+ this.failureReason = null;
+ this.error = null;
+ this.workerId = workerId;
+ this.retryCount = Math.max(0, retryCount);
+ this.lastAttemptAt = LocalDateTime.now();
+ this.startedAt = this.lastAttemptAt;
+ if (messageSubmittedAt != null) {
+ this.queueLatencyMillis = Math.max(0L, Duration.between(messageSubmittedAt, Instant.now()).toMillis());
+ }
}
public void markSuccess() {
this.status = TaskStatus.SUCCEEDED;
this.message = "자소서 분석이 완료되었습니다.";
this.error = null;
+ this.failureReason = null;
this.completedAt = LocalDateTime.now();
}
- public void markFailed(String errorMessage) {
+ public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) {
+ if (isTerminal()) {
+ return;
+ }
+ if (retryCount >= maxRetryCount) {
+ markFailed(failureReason, errorMessage, retryCount);
+ return;
+ }
+ this.status = TaskStatus.PENDING;
+ this.message = "자소서 분석 재시도를 대기 중입니다.";
+ this.failureReason = failureReason;
+ this.error = errorMessage;
+ this.retryCount = Math.max(0, retryCount);
+ this.completedAt = null;
+ }
+
+ public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) {
+ if (status == TaskStatus.SUCCEEDED) {
+ return;
+ }
this.status = TaskStatus.FAILED;
this.message = "자소서 분석에 실패했습니다.";
+ this.failureReason = failureReason;
this.error = errorMessage;
+ this.retryCount = Math.max(0, retryCount);
this.completedAt = LocalDateTime.now();
}
+ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) {
+ if (workerId != null && !workerId.isBlank()) {
+ this.workerId = workerId;
+ }
+ if (queueLatencyMillis != null) {
+ this.queueLatencyMillis = Math.max(0L, queueLatencyMillis);
+ }
+ }
+
+ private boolean isTerminal() {
+ return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED;
+ }
+
public enum TaskStatus {
PENDING,
RUNNING,
@@ -110,4 +183,12 @@ public enum CreditStatus {
CONFIRMED,
RELEASED
}
+
+ public enum FailureReason {
+ RATE_LIMIT,
+ QUEUE_TIMEOUT,
+ OPENAI_TIMEOUT,
+ VALIDATION_ERROR,
+ INTERNAL_ERROR
+ }
}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java
new file mode 100644
index 0000000..064a85c
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java
@@ -0,0 +1,345 @@
+package com.jobdri.jobdri_api.domain.analysis.evaluation;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse;
+import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse;
+import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource;
+import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAiClient;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisImprovementRules;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisPromptInput;
+import com.jobdri.jobdri_api.domain.analysis.service.AnalysisResultConstants;
+import com.jobdri.jobdri_api.domain.analysis.service.JobCategoryEvaluationCriteriaProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EvaluationAnalysisBatchService {
+ private static final Long EVALUATION_QUESTION_ID = 1L;
+ private static final List REQUIRED_HEADERS = List.of(
+ "caseId",
+ "jobCategoryMiddle",
+ "jobCategorySmall",
+ "mainTasks",
+ "qualifications",
+ "preferences",
+ "question",
+ "answer"
+ );
+
+ private final AnalysisAiClient analysisAiClient;
+ private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider;
+ private final ObjectMapper objectMapper;
+
+ public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOException {
+ List cases = readCases(inputPath);
+ List results = new ArrayList<>();
+ int successCount = 0;
+
+ for (int i = 0; i < cases.size(); i++) {
+ EvaluationAnalysisCase evaluationCase = cases.get(i);
+ log.info("[{}/{}] {} analyzing...", i + 1, cases.size(), evaluationCase.caseId());
+
+ try {
+ EvaluationAnalysisResult result = analyzeCase(evaluationCase);
+ results.add(result);
+ if (!StringUtils.hasText(result.errorMessage())) {
+ successCount++;
+ }
+ } catch (Exception e) {
+ log.warn("[{}/{}] {} failed. message={}", i + 1, cases.size(), evaluationCase.caseId(), e.getMessage());
+ results.add(EvaluationAnalysisResult.failed(
+ evaluationCase,
+ safeErrorMessage(e),
+ createdAt()
+ ));
+ }
+ }
+
+ EvaluationCsvSupport.write(outputPath, results);
+ return new EvaluationBatchSummary(cases.size(), successCount, cases.size() - successCount, outputPath);
+ }
+
+ private List readCases(Path inputPath) throws IOException {
+ validateHeaders(EvaluationCsvSupport.readHeaders(inputPath));
+ return EvaluationCsvSupport.read(inputPath).stream()
+ .map(row -> new EvaluationAnalysisCase(
+ value(row, "caseId"),
+ value(row, "jobCategoryMiddle"),
+ value(row, "jobCategorySmall"),
+ value(row, "mainTasks"),
+ value(row, "qualifications"),
+ value(row, "preferences"),
+ value(row, "question"),
+ value(row, "answer")
+ ))
+ .toList();
+ }
+
+ private void validateHeaders(List headers) {
+ Set headerSet = new HashSet<>(headers);
+ List missingHeaders = REQUIRED_HEADERS.stream()
+ .filter(header -> !headerSet.contains(header))
+ .toList();
+ if (!missingHeaders.isEmpty()) {
+ throw new IllegalArgumentException("Evaluation CSV missing required headers: " + missingHeaders);
+ }
+ }
+
+ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) {
+ AnalysisPromptInput promptInput = new AnalysisPromptInput(
+ "평가용 회사",
+ evaluationCase.jobCategorySmall(),
+ evaluationCase.mainTasks(),
+ evaluationCase.qualifications(),
+ evaluationCase.preferences(),
+ List.of(new AnalysisPromptInput.QuestionAnswer(
+ EVALUATION_QUESTION_ID,
+ evaluationCase.question(),
+ evaluationCase.answer()
+ ))
+ );
+
+ AnalysisLlmResponse llmResponse = analysisAiClient.analyzeForEvaluation(
+ promptInput,
+ jobCategoryEvaluationCriteriaProvider
+ .findByMiddleName(evaluationCase.jobCategoryMiddle())
+ .orElse(null)
+ );
+
+ int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit());
+ int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact());
+ int completeness = validateScore("completeness", llmResponse == null ? null : llmResponse.completeness());
+ List missingKeywords = buildMissingKeywords(llmResponse);
+ List questionAnalyses = buildQuestionAnalyses(evaluationCase, llmResponse);
+
+ return new EvaluationAnalysisResult(
+ evaluationCase.caseId(),
+ evaluationCase.jobCategoryMiddle(),
+ evaluationCase.jobCategorySmall(),
+ calculateScore(jobFit, impact, completeness),
+ jobFit,
+ impact,
+ completeness,
+ normalizeFeedback(llmResponse.feedback()),
+ writeJson(missingKeywords),
+ writeJson(questionAnalyses),
+ writeJson(llmResponse),
+ "",
+ createdAt()
+ );
+ }
+
+ private List buildMissingKeywords(AnalysisLlmResponse llmResponse) {
+ if (llmResponse == null || llmResponse.missingKeywords() == null) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+ Set seenKeywords = new HashSet<>();
+
+ for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) {
+ if (item == null || !StringUtils.hasText(item.keyword())) {
+ continue;
+ }
+
+ String keyword = item.keyword().trim();
+ if (keyword.length() > AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH) {
+ continue;
+ }
+
+ Optional source = MissingKeywordSource.from(item.source());
+ if (source.isEmpty()) {
+ continue;
+ }
+
+ String dedupeKey = normalizeKeyword(keyword);
+ if (!seenKeywords.add(dedupeKey)) {
+ continue;
+ }
+
+ result.add(new MissingKeywordResponse(keyword, source.get()));
+ if (result.size() >= AnalysisResultConstants.MAX_MISSING_KEYWORDS) {
+ break;
+ }
+ }
+ return result;
+ }
+
+ private List buildQuestionAnalyses(
+ EvaluationAnalysisCase evaluationCase,
+ AnalysisLlmResponse llmResponse
+ ) {
+ if (llmResponse == null || llmResponse.questionAnalyses() == null) {
+ return List.of();
+ }
+
+ String answer = evaluationCase.answer();
+ if (!StringUtils.hasText(answer)) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+ Map analysisCountByQuestionId = new HashMap<>();
+ Map nextSearchIndexByQuestionId = new HashMap<>();
+ Set seenSentences = new HashSet<>();
+
+ for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) {
+ if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) {
+ continue;
+ }
+ if (!EVALUATION_QUESTION_ID.equals(item.questionId())) {
+ continue;
+ }
+
+ QuestionAnalysisStatus status = parseStatus(item.status());
+ if (status == null || status == QuestionAnalysisStatus.MISSING) {
+ continue;
+ }
+
+ int currentCount = analysisCountByQuestionId.getOrDefault(item.questionId(), 0);
+ if (currentCount >= AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION) {
+ continue;
+ }
+
+ String sentence = item.sentence();
+ String dedupeKey = item.questionId() + ":" + sentence.trim();
+ if (!seenSentences.add(dedupeKey)) {
+ continue;
+ }
+
+ int start = findNextSentenceStart(
+ answer,
+ sentence,
+ nextSearchIndexByQuestionId.getOrDefault(item.questionId(), 0)
+ );
+ if (start < 0) {
+ continue;
+ }
+
+ nextSearchIndexByQuestionId.put(item.questionId(), start + sentence.length());
+ analysisCountByQuestionId.put(item.questionId(), currentCount + 1);
+ result.add(new EvaluationQuestionAnalysisResult(
+ item.questionId(),
+ sentence,
+ status.name().toLowerCase(),
+ defaultString(item.reason()),
+ normalizeImprovement(item.improvement()),
+ start,
+ start + sentence.length()
+ ));
+ }
+ return result;
+ }
+
+ private int validateScore(String fieldName, Integer score) {
+ if (score == null
+ || score < AnalysisResultConstants.MIN_SCORE
+ || score > AnalysisResultConstants.MAX_SCORE) {
+ throw new IllegalArgumentException("자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다.");
+ }
+ return score;
+ }
+
+ private int calculateScore(int jobFit, int impact, int completeness) {
+ return (int) Math.round(
+ jobFit * AnalysisResultConstants.JOB_FIT_WEIGHT
+ + impact * AnalysisResultConstants.IMPACT_WEIGHT
+ + completeness * AnalysisResultConstants.COMPLETENESS_WEIGHT
+ );
+ }
+
+ private int findNextSentenceStart(String answer, String sentence, int fromIndex) {
+ int start = answer.indexOf(sentence, Math.max(0, fromIndex));
+ if (start >= 0) {
+ return start;
+ }
+ return answer.indexOf(sentence);
+ }
+
+ private QuestionAnalysisStatus parseStatus(String status) {
+ if (!StringUtils.hasText(status)) {
+ return null;
+ }
+
+ try {
+ return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private String writeJson(Object value) {
+ try {
+ return objectMapper.writeValueAsString(value == null ? List.of() : value);
+ } catch (JsonProcessingException e) {
+ log.warn("평가 결과 JSON 직렬화에 실패했습니다. 빈 배열로 대체합니다.", e);
+ return "[]";
+ }
+ }
+
+ private String normalizeFeedback(String feedback) {
+ if (StringUtils.hasText(feedback)) {
+ return feedback;
+ }
+ return "자소서 분석 결과를 확인해주세요.";
+ }
+
+ private String normalizeImprovement(String improvement) {
+ if (!StringUtils.hasText(improvement)) {
+ return "";
+ }
+
+ String normalized = improvement.trim();
+ if (AnalysisImprovementRules.isInstructionLike(normalized)) {
+ return "";
+ }
+ return normalized;
+ }
+
+ private String normalizeKeyword(String keyword) {
+ return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase();
+ }
+
+ private String defaultString(String value) {
+ return value == null ? "" : value;
+ }
+
+ private String value(Map row, String key) {
+ return row.getOrDefault(key, "");
+ }
+
+ private String safeErrorMessage(Exception e) {
+ String message = e.getMessage();
+ return StringUtils.hasText(message) ? message : e.getClass().getSimpleName();
+ }
+
+ private String createdAt() {
+ return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+ }
+
+ public record EvaluationBatchSummary(
+ int totalCount,
+ int successCount,
+ int failureCount,
+ Path outputPath
+ ) {
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java
new file mode 100644
index 0000000..bd32f5c
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java
@@ -0,0 +1,13 @@
+package com.jobdri.jobdri_api.domain.analysis.evaluation;
+
+record EvaluationAnalysisCase(
+ String caseId,
+ String jobCategoryMiddle,
+ String jobCategorySmall,
+ String mainTasks,
+ String qualifications,
+ String preferences,
+ String question,
+ String answer
+) {
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java
new file mode 100644
index 0000000..6978a20
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java
@@ -0,0 +1,35 @@
+package com.jobdri.jobdri_api.domain.analysis.evaluation;
+
+record EvaluationAnalysisResult(
+ String caseId,
+ String jobCategoryMiddle,
+ String jobCategorySmall,
+ Integer aiScore,
+ Integer aiJobFit,
+ Integer aiImpact,
+ Integer aiCompleteness,
+ String aiFeedback,
+ String aiMissingKeywordsJson,
+ String aiQuestionAnalysesJson,
+ String rawLlmResponseJson,
+ String errorMessage,
+ String createdAt
+) {
+ static EvaluationAnalysisResult failed(EvaluationAnalysisCase evaluationCase, String errorMessage, String createdAt) {
+ return new EvaluationAnalysisResult(
+ evaluationCase.caseId(),
+ evaluationCase.jobCategoryMiddle(),
+ evaluationCase.jobCategorySmall(),
+ null,
+ null,
+ null,
+ null,
+ "",
+ "[]",
+ "[]",
+ "",
+ errorMessage,
+ createdAt
+ );
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java
new file mode 100644
index 0000000..272ea6e
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java
@@ -0,0 +1,108 @@
+package com.jobdri.jobdri_api.domain.analysis.evaluation;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+@Component
+@Profile("analysis-eval")
+@RequiredArgsConstructor
+@Slf4j
+// 수동 실행 예:
+// ./gradlew bootRun --args='--spring.profiles.active=analysis-eval --evaluation.input=/path/evaluation_cases_검수.csv --evaluation.output=/path/evaluation_ai_results.csv --evaluation.confirm-openai-cost=true'
+public class EvaluationAnalysisRunner implements ApplicationRunner {
+
+ @Value("${evaluation.input:}")
+ private String inputPath;
+
+ @Value("${evaluation.output:}")
+ private String outputPath;
+
+ @Value("${evaluation.confirm-openai-cost:false}")
+ private boolean confirmOpenAiCost;
+
+ @Value("${openai.api.key:}")
+ private String openAiApiKey;
+
+ @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}")
+ private String analysisModel;
+
+ private final EvaluationAnalysisBatchService evaluationAnalysisBatchService;
+ private final ConfigurableApplicationContext applicationContext;
+ private final Environment environment;
+
+ @Override
+ public void run(ApplicationArguments args) throws Exception {
+ try {
+ validateProfiles();
+ validateExecutionProperties();
+ log.info("평가용 자소서 분석을 시작합니다. input={}, output={}, model={}", inputPath, outputPath, analysisModel);
+ EvaluationAnalysisBatchService.EvaluationBatchSummary summary =
+ evaluationAnalysisBatchService.run(Path.of(inputPath), Path.of(outputPath));
+ log.info(
+ "평가용 자소서 분석이 완료되었습니다. total={}, success={}, failure={}, output={}",
+ summary.totalCount(),
+ summary.successCount(),
+ summary.failureCount(),
+ summary.outputPath()
+ );
+ } catch (Exception e) {
+ log.error("평가용 자소서 분석 실행에 실패했습니다. message={}", e.getMessage(), e);
+ exitApplication(1);
+ throw e;
+ }
+ exitApplication(0);
+ }
+
+ private void exitApplication(int exitCode) {
+ Thread shutdownThread = new Thread(() -> {
+ int resolvedExitCode = SpringApplication.exit(applicationContext, () -> exitCode);
+ System.exit(resolvedExitCode);
+ });
+ shutdownThread.setDaemon(false);
+ shutdownThread.start();
+ }
+
+ void validateProfiles() {
+ List profiles = Arrays.asList(environment.getActiveProfiles());
+ if (profiles.contains("prod")) {
+ throw new IllegalStateException("analysis-eval must not run with prod profile.");
+ }
+ if (!profiles.contains("analysis-eval")) {
+ throw new IllegalStateException("Evaluation runner requires analysis-eval profile.");
+ }
+ }
+
+ void validateExecutionProperties() {
+ if (!StringUtils.hasText(inputPath)) {
+ throw new IllegalArgumentException("evaluation.input 값을 지정해야 합니다.");
+ }
+ if (!StringUtils.hasText(outputPath)) {
+ throw new IllegalArgumentException("evaluation.output 값을 지정해야 합니다.");
+ }
+ if (!Files.isRegularFile(Path.of(inputPath))) {
+ throw new IllegalArgumentException("evaluation.input 파일을 찾을 수 없습니다. path=" + inputPath);
+ }
+ if (!StringUtils.hasText(openAiApiKey)) {
+ throw new IllegalArgumentException("OPENAI_API_KEY 또는 openai.api.key 값을 지정해야 합니다.");
+ }
+ if (!confirmOpenAiCost) {
+ throw new IllegalArgumentException(
+ "외부 OpenAI API 비용 발생을 확인한 뒤 evaluation.confirm-openai-cost=true 값을 지정해야 합니다."
+ );
+ }
+ }
+}
diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java
new file mode 100644
index 0000000..c868dee
--- /dev/null
+++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java
@@ -0,0 +1,205 @@
+package com.jobdri.jobdri_api.domain.analysis.evaluation;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class EvaluationCsvSupport {
+ private EvaluationCsvSupport() {
+ }
+
+ static List