diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index ded02d16..786a4fbf 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -41,11 +41,16 @@ "장점과 단점 / 상사와의 갈등 해결 / 원하는 부서 미배치 시 대응 / 개인의 비전 / 갈등 해결 경험 / " "가장 몰입한 경험 / 본인을 뽑아야 하는 이유·강점 / 10년 후 모습 / 마지막으로 하고 싶은 말 / " "왜 다른 곳이 아닌 이 회사인가(지원동기).\n" + "- **산업이 주어지면 그 현장의 언어로 묻습니다.** 같은 직군이라도 산업이 다르면 묻는 것이 " + "다릅니다 — 같은 '생산·품질' 이라도 반도체는 수율·공정 편차·클린룸을, 건설은 공정표·안전관리·" + "협력업체를 다룹니다. 다만 **지원자 자료에 근거가 없는 산업 지식을 지어내 묻지 않습니다** — " + "산업은 질문의 맥락이지 새로운 근거가 아닙니다. '(지정 없음)' 이면 이 지침을 무시합니다.\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." ) HUMAN_PROMPT = ( "직군(복수 가능): {job_categories}\n" + "산업: {industry}\n" "면접 모드: {mode}\n" "최대 질문 수: {max_questions}\n\n" "지원자 자기소개 답변 (질문 생성의 1차 근거):\n" diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 920096f1..1be16a3a 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -88,6 +88,7 @@ async def generate( target_company_name: str | None = None, target_job_description: str | None = None, focus_areas: list[str] | None = None, + industry: str | None = None, ) -> GeneratedQuestionPool: ... @@ -107,6 +108,7 @@ async def generate( target_company_name: str | None = None, target_job_description: str | None = None, focus_areas: list[str] | None = None, + industry: str | None = None, ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { @@ -120,6 +122,8 @@ async def generate( target_company_name, target_job_description ), "focus_areas": _format_focus_areas(focus_areas), + # 비어 있으면 "(지정 없음)" — 프롬프트가 산업 지침을 무시하도록. + "industry": (industry or "").strip() or "(지정 없음)", } ) if not isinstance(result, GeneratedQuestionPool): diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index dbaa87e0..e64424db 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -115,6 +115,7 @@ async def _process( target_company_name=req.target_company_name, target_job_description=req.target_job_description, focus_areas=req.focus_areas, + industry=req.industry, ) # 실패는 예외로 위 가드에 넘어가므로 여기 도달 = 성공 — # FAILED 콜백 직전에 "마무리하고 있어요" 가 스치는 오해가 구조적으로 없다. diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 61b04f08..4df97412 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -49,6 +49,9 @@ class GenerateQuestionsRequest(BaseModel): # 약점 집중 재도전에서만 채워진다. TECHNICAL|LOGIC|COMMUNICATION 중 1~2개. # 지난 면접에서 낮았던 평가 축이며, 그 영역을 검증하는 질문을 우선 배치한다. focus_areas: list[str] = [] + # 희망 산업(자유 입력, 선택). 직군만으로는 맥락이 얇다 — 같은 "생산·품질" 이라도 + # 반도체 공정과 건설 현장은 묻는 것이 전혀 다르다. 비어 있으면 프롬프트가 무시한다. + industry: str | None = None class GeneratedQuestion(BaseModel): diff --git a/ai/tests/test_question_generation.py b/ai/tests/test_question_generation.py new file mode 100644 index 00000000..808b3a94 --- /dev/null +++ b/ai/tests/test_question_generation.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import pytest + +from ai_server.chain.question_generation_chain import GeneratedQuestionPool + +# ── 산업 맥락 ──────────────────────────────────────────────────────────────── +# +# 직군만으로는 질문 맥락이 얇다. 같은 "생산·품질" 이라도 반도체 공정과 건설 현장은 묻는 +# 것이 전혀 다르다. 산업은 자유 입력이라 CHECK·Literal 이 없고, 그래서 **프롬프트까지 +# 실제로 도달하는지**를 테스트가 봐야 한다. + + +def _capture_chain(): + """ainvoke 에 들어온 입력을 그대로 잡아두는 가짜 체인.""" + seen: dict = {} + + class _Chain: + async def ainvoke(self, payload): + seen.update(payload) + return GeneratedQuestionPool(questions=[]) + + return _Chain(), seen + + +@pytest.mark.asyncio +async def test_industry_reaches_the_prompt(): + from ai_server.chain.question_generation_chain import LlmQuestionGenerator + + chain, seen = _capture_chain() + await LlmQuestionGenerator(chain).generate( + job_categories=["MANUFACTURING"], + mode="TECHNICAL", + max_questions=3, + context="", + industry="반도체", + ) + + assert seen["industry"] == "반도체" + + +@pytest.mark.asyncio +async def test_missing_industry_becomes_explicit_none(): + """빈 값을 그대로 흘리면 프롬프트에 'None' 이나 빈 줄이 박힌다 — + LLM 이 그걸 산업 이름으로 읽을 수 있다. 명시적으로 '(지정 없음)' 을 넣는다.""" + from ai_server.chain.question_generation_chain import LlmQuestionGenerator + + for value in (None, "", " "): + chain, seen = _capture_chain() + await LlmQuestionGenerator(chain).generate( + job_categories=["SALES"], + mode="PERSONALITY", + max_questions=3, + context="", + industry=value, + ) + assert seen["industry"] == "(지정 없음)", f"industry={value!r}" + + +def test_prompt_has_industry_slot_and_guidance(): + """슬롯만 있고 지침이 없으면 LLM 이 산업을 장식으로 흘려보낸다.""" + from ai_server.chain.prompts.question_generation import HUMAN_PROMPT, SYSTEM_PROMPT + + assert "{industry}" in HUMAN_PROMPT + # 산업을 근거로 착각해 없는 지식을 지어내면 안 된다 — 그 경계가 지침에 있어야 한다. + assert "지어내" in SYSTEM_PROMPT diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5184a6fc..ef8d21b0 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -634,6 +634,26 @@ docker compose up -d `/api/system/health` 의 `scheduler` 가 DOWN. DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로 막히면 안 된다. `BACKUP` 과 같이 **informational** 이라 aggregate 는 끌어내리지 않는다(스케줄러가 멈춰도 면접·로그인·조회는 된다). +- **사용자 취업 프로필 본 구현 (`profile` 슬라이스)**: 희망 직군(다중)·희망 산업(자유 입력)· + 경력 수준. `GET/PUT /api/users/me/job-profile`. 면접을 만들 때마다 직군을 다시 고르게 했고, + 직군만으로는 질문 맥락이 얇았다 — **같은 '생산·품질' 이라도 반도체 공정과 건설 현장은 묻는 + 것이 전혀 다르다.** + - **왜 별도 슬라이스인가**: `User` 에 직접 달면 user 가 `JobCategory`(session 슬라이스)를 + 참조해 **user→session→user 순환**이 생기고 ArchUnit 이 막는다(`OAuthProvider` 를 + `user.domain` 에 둔 것과 같은 이유). `profile → {user, session}` 이고 역방향이 없다. + 실제로 `User` 에 붙였다가 `top_level_domain_slices_are_free_of_cycles` 로 걸렸다. + - **산업에는 CHECK 를 걸지 않는다.** 직군은 평가 관점을 큐레이션해야 해서 유한 집합이지만, + 산업은 프롬프트 맥락으로만 쓰이므로 열거하면 빠진 산업의 지원자가 또 배제된다 + (반도체·건설·토목을 넣어도 조선·방산·바이오…). 프론트는 `datalist` 로 제안만 한다. + - **null 은 "그대로 두기", 빈 값은 "지우기"**. 계정 화면이 일부만 보내도 나머지가 날아가면 + 안 된다. 비우는 수단이 따로 있어야 해서 빈 목록/빈 문자열을 지우기로 쓴다. + - **프로필 → 세션은 프론트 프리필로 잇는다.** `SessionService` 가 프로필을 읽으면 + session→profile 이 되어 다시 순환이다. 프론트가 프로필을 읽어 기본값을 채우고 사용자가 + 그 자리에서 바꿀 수 있으며, 세션은 **실제로 쓰인 값**만 `interview_sessions.industry` 에 + 기록한다(프로필은 나중에 바뀌므로 세션이 스스로 남겨야 한다 — 직군 복사와 같은 이유). + - 산업은 `generate.questions` payload 에 실려 질문 프롬프트의 맥락이 된다. 프롬프트는 + "산업은 질문의 맥락이지 새로운 근거가 아니다 — 자료에 없는 산업 지식을 지어내 묻지 않는다" + 를 명시한다. 비어 있으면 `(지정 없음)` 이 들어가 지침이 무시된다. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index fa9a1d74..4e756f5b 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -44,6 +44,9 @@ }, { "name" : "Session Feedback", "description" : "세션 종합 피드백 (US-24)" + }, { + "name" : "Users (Job Profile)", + "description" : "희망 직군·산업·경력 — 면접 생성 시 기본값으로 쓰인다." }, { "name" : "Internal: Document Embeddings", "description" : "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출." @@ -70,6 +73,84 @@ "description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)." } ], "paths" : { + "/api/users/me/job-profile" : { + "get" : { + "tags" : [ "Users (Job Profile)" ], + "summary" : "내 취업 프로필 조회", + "description" : "프로필을 채운 적 없으면 404 가 아니라 빈 프로필(filled=false)을 반환한다.", + "operationId" : "getMyJobProfile", + "responses" : { + "200" : { + "description" : "프로필 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileResponse" + } + } + } + } + } + }, + "put" : { + "tags" : [ "Users (Job Profile)" ], + "summary" : "내 취업 프로필 저장", + "description" : "생략한(null) 항목은 그대로 둔다. 비우려면 빈 목록 / 빈 문자열을 보낸다.", + "operationId" : "updateMyJobProfile", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileUpdateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "저장 후 최신 프로필 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileResponse" + } + } + } + }, + "400" : { + "description" : "검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/JobProfileResponse" + } + } + } + } + } + } + }, "/api/sessions/{sessionId}/messages/{messageId}/bookmark" : { "put" : { "tags" : [ "Session Messages" ], @@ -3259,6 +3340,49 @@ }, "components" : { "schemas" : { + "JobProfileUpdateRequest" : { + "type" : "object", + "properties" : { + "desiredJobCategories" : { + "type" : "array", + "items" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ] + } + }, + "desiredIndustry" : { + "type" : "string", + "maxLength" : 100, + "minLength" : 0 + }, + "careerLevel" : { + "type" : "string", + "enum" : [ "NEW", "EXPERIENCED", "INTERN", "CAREER_CHANGE" ] + } + } + }, + "JobProfileResponse" : { + "type" : "object", + "properties" : { + "desiredJobCategories" : { + "type" : "array", + "items" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ] + } + }, + "desiredIndustry" : { + "type" : "string" + }, + "careerLevel" : { + "type" : "string", + "enum" : [ "NEW", "EXPERIENCED", "INTERN", "CAREER_CHANGE" ] + }, + "filled" : { + "type" : "boolean" + } + } + }, "QuestionBookmarkRequest" : { "type" : "object", "properties" : { @@ -3435,6 +3559,11 @@ "type" : "string", "maxLength" : 20000, "minLength" : 0 + }, + "industry" : { + "type" : "string", + "maxLength" : 100, + "minLength" : 0 } }, "required" : [ "jobCategories", "mode" ] diff --git a/backend/src/main/java/com/stackup/stackup/profile/application/UserJobProfileService.java b/backend/src/main/java/com/stackup/stackup/profile/application/UserJobProfileService.java new file mode 100644 index 00000000..30436f0e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/application/UserJobProfileService.java @@ -0,0 +1,40 @@ +package com.stackup.stackup.profile.application; + +import com.stackup.stackup.profile.application.dto.JobProfileResponse; +import com.stackup.stackup.profile.application.dto.JobProfileUpdateRequest; +import com.stackup.stackup.profile.domain.UserJobProfile; +import com.stackup.stackup.profile.domain.UserJobProfileRepository; +import com.stackup.stackup.session.domain.JobCategory; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserJobProfileService { + + private final UserJobProfileRepository repository; + + /** 프로필이 없으면 빈 것을 돌려준다 — "아직 안 채움" 은 오류가 아니다. */ + public JobProfileResponse get(Long userId) { + return repository.findByUserId(userId) + .map(JobProfileResponse::from) + .orElseGet(JobProfileResponse::empty); + } + + @Transactional + public JobProfileResponse update(Long userId, JobProfileUpdateRequest request) { + UserJobProfile profile = repository.findByUserId(userId) + .orElseGet(() -> UserJobProfile.empty(userId)); + + Set categories = request.desiredJobCategories() == null + ? null + : new LinkedHashSet<>(request.desiredJobCategories()); + + profile.update(categories, request.desiredIndustry(), request.careerLevel()); + return JobProfileResponse.from(repository.save(profile)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileResponse.java b/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileResponse.java new file mode 100644 index 00000000..e5a9f3ad --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileResponse.java @@ -0,0 +1,29 @@ +package com.stackup.stackup.profile.application.dto; + +import com.stackup.stackup.profile.domain.CareerLevel; +import com.stackup.stackup.profile.domain.UserJobProfile; +import com.stackup.stackup.session.domain.JobCategory; +import java.util.List; +import java.util.Set; + +public record JobProfileResponse( + List desiredJobCategories, + String desiredIndustry, + CareerLevel careerLevel, + boolean filled +) { + public static JobProfileResponse from(UserJobProfile profile) { + Set categories = profile.getDesiredJobCategories(); + return new JobProfileResponse( + List.copyOf(categories), + profile.getDesiredIndustry(), + profile.getCareerLevel(), + profile.isFilled() + ); + } + + /** 프로필을 만든 적 없는 사용자. 404 대신 빈 프로필을 준다 — 없는 것도 정상 상태다. */ + public static JobProfileResponse empty() { + return new JobProfileResponse(List.of(), null, null, false); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileUpdateRequest.java b/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileUpdateRequest.java new file mode 100644 index 00000000..8df743aa --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/application/dto/JobProfileUpdateRequest.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.profile.application.dto; + +import com.stackup.stackup.profile.domain.CareerLevel; +import com.stackup.stackup.session.domain.JobCategory; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 프로필 갱신 요청. 전부 선택값이고 null 은 "그대로 두기" 다 — 계정 화면이 일부만 + * 보내도 나머지가 날아가면 안 된다. 비우려면 빈 값(빈 목록 / 빈 문자열)을 보낸다. + */ +public record JobProfileUpdateRequest( + List desiredJobCategories, + + // 자유 입력이라 길이만 막는다. 산업명은 길어야 수십 자다. + @Size(max = 100, message = "희망 산업은 100자를 넘을 수 없습니다.") + String desiredIndustry, + + CareerLevel careerLevel +) {} diff --git a/backend/src/main/java/com/stackup/stackup/profile/domain/CareerLevel.java b/backend/src/main/java/com/stackup/stackup/profile/domain/CareerLevel.java new file mode 100644 index 00000000..003ac835 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/domain/CareerLevel.java @@ -0,0 +1,19 @@ +package com.stackup.stackup.profile.domain; + +/** 경력 수준. 질문 난이도와 묻는 대상(가정 vs 실제 경험)을 가른다. */ +public enum CareerLevel { + NEW("신입"), + EXPERIENCED("경력"), + INTERN("인턴·체험형"), + CAREER_CHANGE("직무 전환"); + + private final String koreanLabel; + + CareerLevel(String koreanLabel) { + this.koreanLabel = koreanLabel; + } + + public String koreanLabel() { + return koreanLabel; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfile.java b/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfile.java new file mode 100644 index 00000000..d6313998 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfile.java @@ -0,0 +1,105 @@ +package com.stackup.stackup.profile.domain; + +import com.stackup.stackup.session.domain.JobCategory; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자 취업 프로필 — 희망 직군 · 희망 산업 · 경력 수준. + * + *

왜 별도 슬라이스인가: {@code User} 에 직접 달면 user 가 {@link JobCategory}(session + * 슬라이스)를 참조해 user→session→user 순환이 생기고 ArchUnit 이 막는다 + * (OAuthProvider 를 user.domain 에 둔 것과 같은 이유). profile 이 user·session 을 의존하고 + * 역방향이 없으므로 순환이 없다. + * + *

왜 필요한가: 면접을 만들 때마다 직군을 다시 고르게 했고, 직군만으로는 질문 맥락이 + * 얇았다 — 같은 '생산·품질' 이라도 반도체 공정과 건설 현장은 묻는 것이 전혀 다르다. + * + *

모든 항목이 선택값이다. 프로필을 안 채운 사용자도 지금처럼 면접을 만들 수 있어야 한다. + */ +@Getter +@Entity +@Table(name = "user_job_profiles") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserJobProfile { + + /** users.id 를 그대로 PK 로 쓴다(1:1). 별도 시퀀스를 둘 이유가 없다. */ + @Id + @Column(name = "user_id") + private Long userId; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "user_job_profile_categories", + joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "job_category", nullable = false, length = 30) + @Enumerated(EnumType.STRING) + private Set desiredJobCategories = new LinkedHashSet<>(); + + /** + * 희망 산업. 자유 입력이다 — 열거하면 빠진 산업의 지원자가 또 배제된다 + * (반도체·건설·토목을 넣어도 조선·방산·바이오…). 평가 관점을 큐레이션해야 하는 직군과 + * 달리 프롬프트 맥락으로만 쓰이므로 제약이 필요 없다. + */ + @Column(name = "desired_industry", length = 100) + private String desiredIndustry; + + @Enumerated(EnumType.STRING) + @Column(name = "career_level", length = 20) + private CareerLevel careerLevel; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + public static UserJobProfile empty(Long userId) { + UserJobProfile p = new UserJobProfile(); + p.userId = userId; + return p; + } + + /** + * 프로필 갱신. + * + *

null 은 "지우기" 가 아니라 "그대로 두기" 다 — 계정 화면이 일부 항목만 보내도 + * 나머지가 날아가면 안 된다. 산업을 비우려면 빈 문자열을 보낸다(직군은 빈 목록). + */ + public void update( + Set desiredJobCategories, + String desiredIndustry, + CareerLevel careerLevel + ) { + if (desiredJobCategories != null) { + this.desiredJobCategories = new LinkedHashSet<>(desiredJobCategories); + } + if (desiredIndustry != null) { + String trimmed = desiredIndustry.trim(); + this.desiredIndustry = trimmed.isEmpty() ? null : trimmed; + } + if (careerLevel != null) { + this.careerLevel = careerLevel; + } + this.updatedAt = Instant.now(); + } + + /** 한 번이라도 채웠는가. 가입 직후 안내를 띄울지 판단한다. */ + public boolean isFilled() { + return !desiredJobCategories.isEmpty() || desiredIndustry != null || careerLevel != null; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfileRepository.java b/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfileRepository.java new file mode 100644 index 00000000..a303d4b7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/domain/UserJobProfileRepository.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.profile.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJobProfileRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/stackup/stackup/profile/presentation/UserJobProfileController.java b/backend/src/main/java/com/stackup/stackup/profile/presentation/UserJobProfileController.java new file mode 100644 index 00000000..e43d257b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/profile/presentation/UserJobProfileController.java @@ -0,0 +1,57 @@ +package com.stackup.stackup.profile.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.profile.application.UserJobProfileService; +import com.stackup.stackup.profile.application.dto.JobProfileResponse; +import com.stackup.stackup.profile.application.dto.JobProfileUpdateRequest; +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 lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Users (Job Profile)", description = "희망 직군·산업·경력 — 면접 생성 시 기본값으로 쓰인다.") +@RestController +@RequiredArgsConstructor +public class UserJobProfileController { + + private final UserJobProfileService service; + + @Operation( + operationId = "getMyJobProfile", + summary = "내 취업 프로필 조회", + description = "프로필을 채운 적 없으면 404 가 아니라 빈 프로필(filled=false)을 반환한다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 반환"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/api/users/me/job-profile") + public JobProfileResponse get(@AuthenticationPrincipal UserPrincipal principal) { + return service.get(principal.userId()); + } + + @Operation( + operationId = "updateMyJobProfile", + summary = "내 취업 프로필 저장", + description = "생략한(null) 항목은 그대로 둔다. 비우려면 빈 목록 / 빈 문자열을 보낸다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "저장 후 최신 프로필 반환"), + @ApiResponse(responseCode = "400", description = "검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PutMapping("/api/users/me/job-profile") + public JobProfileResponse update( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody JobProfileUpdateRequest request + ) { + return service.update(principal.userId(), request); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java index 99809768..5cef49ec 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -80,7 +80,8 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { contextDocumentIds, answer.getContent(), session.getTargetCompanyName(), - session.getTargetJobDescription() + session.getTargetJobDescription(), + session.getIndustry() )); log.info("self-intro answered — requesting question pool. sessionId={}", session.getId()); return; 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 2dbe8c9b..36119585 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 @@ -105,7 +105,8 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { event.selfIntroAnswer(), event.targetCompanyName(), event.targetJobDescription(), - focusAreas + focusAreas, + event.industry() ); publisher.publishToAi( properties.routingKeys().generateQuestions(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java index 41087549..aa1b2b85 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java @@ -137,7 +137,8 @@ private void requestQuestionPool(Long userId, InterviewSession session, String s contextDocumentIds(session.getId()), selfIntroAnswer, session.getTargetCompanyName(), - session.getTargetJobDescription() + session.getTargetJobDescription(), + session.getIndustry() )); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 0458a4a8..4924ad37 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -90,6 +90,8 @@ public SessionResult create(Long userId, SessionCreateCommand command) { command.generalQuestionCount(), command.maxFollowupsPerQuestion() )); + // 산업은 모드와 무관하게 보관한다 — 질문 맥락은 어느 모드에서나 쓸모가 있다. + session.assignIndustry(command.industry()); // 타깃 회사/JD 는 직무 맞춤 모드에서만 보관(다른 모드 입력값은 무시). if (command.mode() == SessionMode.JOB_TAILORED) { session.assignTargetRole(command.targetCompanyName(), command.targetJobDescription()); @@ -136,7 +138,9 @@ public SessionResult retry(Long userId, Long sourceSessionId, boolean focusOnWea source.getMaxFollowupsPerQuestion(), reusableDocumentIds, source.getTargetCompanyName(), - source.getTargetJobDescription() + source.getTargetJobDescription(), + // 재도전은 원본의 산업 맥락도 승계한다 — 같은 조건으로 다시 하는 게 목적이다. + source.getIndustry() )); if (!focusOnWeakness) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java index c49676d1..934d9426 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java @@ -21,7 +21,10 @@ public record GenerateQuestionsPayload( String targetCompanyName, String targetJobDescription, // 약점 집중 재도전에서만 채워진다(SessionFocusArea name 목록). 비어 있으면 일반 면접. - List focusAreas + List focusAreas, + // 희망 산업(자유 입력, 선택). 직군만으로는 맥락이 얇다 — 같은 생산·품질이라도 + // 반도체 공정과 건설 현장은 묻는 것이 다르다. 비어 있으면 프롬프트가 무시한다. + String industry ) { public record DocumentContext( Long documentId, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java index 067bcdc6..dcafbe7c 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java @@ -16,6 +16,8 @@ public record SessionCreateCommand( List contextDocumentIds, // 직무 맞춤 모드 전용. 지원 회사명 + 채용공고(JD) 원문. String targetCompanyName, - String targetJobDescription + String targetJobDescription, + // 희망 산업(자유 입력, 선택). 질문 맥락에 실린다. + String industry ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java index d59cca72..59da0633 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java @@ -18,6 +18,8 @@ public record SelfIntroAnsweredEvent( String selfIntroAnswer, // 직무 맞춤 모드 전용 타깃 회사/JD. 다른 모드는 null. String targetCompanyName, - String targetJobDescription + String targetJobDescription, + // 희망 산업(선택). 질문 맥락에 실린다. + String industry ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 28ca9147..3503a634 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -90,6 +90,11 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "target_job_description", columnDefinition = "text") private String targetJobDescription; + // 이 면접이 어떤 산업 맥락에서 진행됐는지. 보통 사용자 프로필에서 채워 오지만, + // 프로필은 나중에 바뀌므로 세션이 스스로 기록해야 한다(직군을 복사해 두는 것과 같은 이유). + @Column(name = "industry", length = 100) + private String industry; + // 약점 집중 재도전에서 겨냥할 평가 축. SessionFocusArea enum name 의 JSON 배열 // (예: ["LOGIC","COMMUNICATION"]). 지정 없으면 null — 일반 면접과 동일하게 동작한다. // 직렬화/역직렬화는 application 레이어가 한다(SessionFeedback.improvementKeywords 와 같은 방식). @@ -188,6 +193,11 @@ public void assignFocusAreas(String focusAreasJson) { this.focusAreas = focusAreasJson; } + // 희망 산업 부여. assignTargetRole 과 같은 이유로 별도 메서드. + public void assignIndustry(String industry) { + this.industry = (industry == null || industry.isBlank()) ? null : industry.trim(); + } + // 직무 맞춤 모드의 타깃 회사/JD 부여. create 시그니처를 늘리지 않으려 별도 메서드로 둔다. public void assignTargetRole(String companyName, String jobDescription) { this.targetCompanyName = companyName; diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index 6f53141e..b9ab49bb 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -28,7 +28,10 @@ public record SessionCreateRequest( List contextDocumentIds, // 직무 맞춤(JOB_TAILORED) 모드 전용. 회사명 + 채용공고(JD). 그 모드일 때 JD 필수(서비스에서 검증). @Size(max = 200) String targetCompanyName, - @Size(max = 20000) String targetJobDescription + @Size(max = 20000) String targetJobDescription, + // 희망 산업(자유 입력). 같은 직군이라도 반도체 공정과 건설 현장은 묻는 것이 전혀 다르다. + // 보통 프론트가 내 프로필에서 채워 보내고, 사용자가 이 면접에서만 바꿀 수도 있다. + @Size(max = 100) String industry ) { public SessionCreateCommand toCommand() { return new SessionCreateCommand( @@ -42,7 +45,8 @@ public SessionCreateCommand toCommand() { maxFollowupsPerQuestion, contextDocumentIds, targetCompanyName, - targetJobDescription + targetJobDescription, + industry ); } } diff --git a/backend/src/main/resources/db/migration/V36__user_job_profile.sql b/backend/src/main/resources/db/migration/V36__user_job_profile.sql new file mode 100644 index 00000000..64cdcb90 --- /dev/null +++ b/backend/src/main/resources/db/migration/V36__user_job_profile.sql @@ -0,0 +1,43 @@ +-- 사용자 취업 프로필: 희망 직군(다중) · 희망 산업(자유 입력) · 경력 수준. +-- +-- 왜 필요한가 +-- 1) 면접을 만들 때마다 직군을 다시 고르게 했다. 대부분 사용자는 매번 같은 값을 넣는다. +-- 2) 직군만으로는 질문 맥락이 얇다. 같은 '생산·품질' 이라도 반도체 공정과 건설 현장은 +-- 묻는 것이 전혀 다르다. 산업을 알면 질문이 그 현장의 언어로 나온다. +-- +-- 산업은 **CHECK 를 걸지 않는다.** 직군은 평가 관점을 큐레이션해야 해서 유한 집합이지만, +-- 산업은 프롬프트 맥락으로만 쓰이므로 열거하면 빠진 산업의 지원자가 또 배제된다 +-- (반도체·건설·토목을 넣어도 조선·방산·바이오… 끝이 없다). 자유 입력 + 제안 목록으로 둔다. +-- +-- users 테이블에 컬럼을 붙이지 않고 별도 테이블로 둔다: 프로필은 선택 기능이라 대부분 NULL 이 +-- 되고, 무엇보다 도메인 슬라이스를 분리해야 한다 — User 가 JobCategory(session 슬라이스)를 +-- 참조하면 user→session→user 순환이 생겨 ArchUnit 이 막는다(OAuthProvider 를 user.domain 에 +-- 둔 것과 같은 이유). profile 슬라이스가 user·session 을 의존하고 역방향이 없어 순환이 없다. + +CREATE TABLE user_job_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + desired_industry VARCHAR(100), + career_level VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_user_job_profiles_career_level + CHECK (career_level IS NULL + OR career_level IN ('NEW', 'EXPERIENCED', 'INTERN', 'CAREER_CHANGE')) +); + +CREATE TABLE user_job_profile_categories ( + user_id BIGINT NOT NULL + REFERENCES user_job_profiles(user_id) ON DELETE CASCADE, + job_category VARCHAR(30) NOT NULL, + CONSTRAINT uq_user_job_profile_category UNIQUE (user_id, job_category), + CONSTRAINT chk_user_job_profile_category + CHECK (job_category IN ( + 'FRONTEND', 'BACKEND', 'INFRA', 'DBA', 'MOBILE', 'DATA_AI', 'SECURITY', 'QA', + 'PLANNING', 'MARKETING', 'SALES', 'HR', 'FINANCE', 'DESIGN', + 'MANUFACTURING', 'RND', 'CUSTOMER_SERVICE', 'LOGISTICS', 'LEGAL', 'PUBLIC' + )) +); + +-- 세션에도 그때 쓴 산업을 남긴다. 프로필은 나중에 바뀔 수 있으므로 "이 면접이 어떤 맥락에서 +-- 진행됐는지" 는 세션이 스스로 기록해야 한다(직군을 세션에 복사해 두는 것과 같은 이유). +ALTER TABLE interview_sessions ADD COLUMN IF NOT EXISTS industry VARCHAR(100); diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index 09ca7f5a..2b31f593 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -16,6 +16,7 @@ import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.user.domain.UserRepository; import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import com.stackup.stackup.profile.domain.UserJobProfileRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -74,6 +75,9 @@ class StackupApplicationTests { @MockitoBean private AiRequestLogRepository aiRequestLogRepository; + @MockitoBean + private UserJobProfileRepository userJobProfileRepository; + @Test void contextLoads() { } 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 034a39df..10b14850 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 @@ -21,6 +21,7 @@ import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.user.domain.UserRepository; import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import com.stackup.stackup.profile.domain.UserJobProfileRepository; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -68,6 +69,7 @@ class OpenApiSpecExportTest { @MockitoBean private SessionQuestionPoolRepository sessionQuestionPoolRepository; @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; @MockitoBean private AiRequestLogRepository aiRequestLogRepository; + @MockitoBean private UserJobProfileRepository userJobProfileRepository; @Test void exportOpenApiSpec() throws Exception { diff --git a/backend/src/test/java/com/stackup/stackup/common/security/AuthEndpointsPermitAllTest.java b/backend/src/test/java/com/stackup/stackup/common/security/AuthEndpointsPermitAllTest.java index 5f86cde8..4f136a93 100644 --- a/backend/src/test/java/com/stackup/stackup/common/security/AuthEndpointsPermitAllTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/security/AuthEndpointsPermitAllTest.java @@ -21,6 +21,7 @@ import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.user.domain.UserRepository; import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import com.stackup.stackup.profile.domain.UserJobProfileRepository; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -62,6 +63,7 @@ class AuthEndpointsPermitAllTest { @MockitoBean private SessionQuestionPoolRepository sessionQuestionPoolRepository; @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; @MockitoBean private AiRequestLogRepository aiRequestLogRepository; + @MockitoBean private UserJobProfileRepository userJobProfileRepository; @Test void unauthenticatedError_keepsKoreanMessageReadable() throws Exception { diff --git a/backend/src/test/java/com/stackup/stackup/profile/application/UserJobProfileServiceTest.java b/backend/src/test/java/com/stackup/stackup/profile/application/UserJobProfileServiceTest.java new file mode 100644 index 00000000..4801a577 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/profile/application/UserJobProfileServiceTest.java @@ -0,0 +1,69 @@ +package com.stackup.stackup.profile.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.profile.application.dto.JobProfileResponse; +import com.stackup.stackup.profile.application.dto.JobProfileUpdateRequest; +import com.stackup.stackup.profile.domain.CareerLevel; +import com.stackup.stackup.profile.domain.UserJobProfile; +import com.stackup.stackup.profile.domain.UserJobProfileRepository; +import com.stackup.stackup.session.domain.JobCategory; +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; + +@ExtendWith(MockitoExtension.class) +class UserJobProfileServiceTest { + + @Mock UserJobProfileRepository repository; + @InjectMocks UserJobProfileService service; + + @Test + void 프로필이_없으면_404_가_아니라_빈_프로필이다() { + // "아직 안 채움" 은 오류가 아니다 — 프로필 없이도 면접은 만들 수 있어야 한다. + when(repository.findByUserId(1L)).thenReturn(Optional.empty()); + + JobProfileResponse response = service.get(1L); + + assertThat(response.filled()).isFalse(); + assertThat(response.desiredJobCategories()).isEmpty(); + assertThat(response.desiredIndustry()).isNull(); + } + + @Test + void 처음_저장하면_새로_만든다() { + when(repository.findByUserId(1L)).thenReturn(Optional.empty()); + when(repository.save(any(UserJobProfile.class))).thenAnswer(inv -> inv.getArgument(0)); + + JobProfileResponse response = service.update(1L, new JobProfileUpdateRequest( + List.of(JobCategory.MANUFACTURING), "반도체", CareerLevel.NEW)); + + assertThat(response.desiredJobCategories()).containsExactly(JobCategory.MANUFACTURING); + assertThat(response.desiredIndustry()).isEqualTo("반도체"); + assertThat(response.careerLevel()).isEqualTo(CareerLevel.NEW); + assertThat(response.filled()).isTrue(); + } + + @Test + void 일부만_보내면_나머지는_유지된다() { + UserJobProfile existing = UserJobProfile.empty(1L); + existing.update(List.of(JobCategory.SALES).stream().collect( + java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)), + "금융", CareerLevel.EXPERIENCED); + when(repository.findByUserId(1L)).thenReturn(Optional.of(existing)); + when(repository.save(any(UserJobProfile.class))).thenAnswer(inv -> inv.getArgument(0)); + + JobProfileResponse response = service.update(1L, + new JobProfileUpdateRequest(null, null, CareerLevel.CAREER_CHANGE)); + + assertThat(response.desiredJobCategories()).containsExactly(JobCategory.SALES); + assertThat(response.desiredIndustry()).isEqualTo("금융"); + assertThat(response.careerLevel()).isEqualTo(CareerLevel.CAREER_CHANGE); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfilePersistenceTest.java b/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfilePersistenceTest.java new file mode 100644 index 00000000..c8a982bb --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfilePersistenceTest.java @@ -0,0 +1,84 @@ +package com.stackup.stackup.profile.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.LinkedHashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * V36 마이그레이션과 매핑이 실제 PostgreSQL 에서 맞는지 본다. + * + *

엔티티만 맞고 마이그레이션이 틀리면 그 실패는 사용자가 프로필을 저장하는 순간에만 + * 드러난다. 산업에 CHECK 를 걸지 않은 것도 여기서 확인한다 — 목록 밖 값이 막히면 "자유 입력" + * 이라고 해 놓고 실제로는 막는 것이 된다. + */ +@PostgresRepositoryTest +class UserJobProfilePersistenceTest { + + @Autowired UserRepository userRepository; + @Autowired UserJobProfileRepository profileRepository; + + private Long newUserId(long githubId, String name) { + return userRepository.save(User.createGithubUser(githubId, name, null, null, "t")).getId(); + } + + @Test + void 프로필이_저장되고_다시_읽힌다() { + Long userId = newUserId(96001L, "profile-user"); + + UserJobProfile profile = UserJobProfile.empty(userId); + profile.update(new LinkedHashSet<>(Set.of(JobCategory.MANUFACTURING)), "반도체", + CareerLevel.NEW); + profileRepository.saveAndFlush(profile); + + UserJobProfile found = profileRepository.findByUserId(userId).orElseThrow(); + assertThat(found.getDesiredJobCategories()).containsExactly(JobCategory.MANUFACTURING); + assertThat(found.getDesiredIndustry()).isEqualTo("반도체"); + assertThat(found.getCareerLevel()).isEqualTo(CareerLevel.NEW); + } + + @Test + void 제안_목록에_없는_산업도_그대로_저장된다() { + // 산업에 CHECK 를 걸지 않은 이유 그 자체 — 열거하면 빠진 산업의 지원자가 배제된다. + Long userId = newUserId(96002L, "rare-industry"); + + UserJobProfile profile = UserJobProfile.empty(userId); + profile.update(null, "수직농업 스마트팜", null); + profileRepository.saveAndFlush(profile); + + assertThat(profileRepository.findByUserId(userId).orElseThrow().getDesiredIndustry()) + .isEqualTo("수직농업 스마트팜"); + } + + @Test + void 개발_비개발_직군을_함께_저장할_수_있다() { + Long userId = newUserId(96003L, "mixed-profile"); + + UserJobProfile profile = UserJobProfile.empty(userId); + profile.update(new LinkedHashSet<>( + java.util.List.of(JobCategory.BACKEND, JobCategory.PLANNING)), null, null); + profileRepository.saveAndFlush(profile); + + assertThat(profileRepository.findByUserId(userId).orElseThrow().getDesiredJobCategories()) + .containsExactlyInAnyOrder(JobCategory.BACKEND, JobCategory.PLANNING); + } + + @Test + void 모든_경력_수준이_CHECK_를_통과한다() { + long id = 96100L; + for (CareerLevel level : CareerLevel.values()) { + Long userId = newUserId(id++, "career-" + level.name()); + UserJobProfile profile = UserJobProfile.empty(userId); + profile.update(null, null, level); + profileRepository.saveAndFlush(profile); + } + + assertThat(profileRepository.count()).isEqualTo(CareerLevel.values().length); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfileTest.java b/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfileTest.java new file mode 100644 index 00000000..b58ad831 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/profile/domain/UserJobProfileTest.java @@ -0,0 +1,63 @@ +package com.stackup.stackup.profile.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.session.domain.JobCategory; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class UserJobProfileTest { + + @Test + void 생략한_항목은_그대로_둔다() { + // 계정 화면이 일부만 보내도 나머지가 날아가면 안 된다. + UserJobProfile profile = UserJobProfile.empty(1L); + profile.update(Set.of(JobCategory.SALES), "반도체", CareerLevel.NEW); + + profile.update(null, null, null); + + assertThat(profile.getDesiredJobCategories()).containsExactly(JobCategory.SALES); + assertThat(profile.getDesiredIndustry()).isEqualTo("반도체"); + assertThat(profile.getCareerLevel()).isEqualTo(CareerLevel.NEW); + } + + @Test + void 빈_문자열은_지우기다() { + UserJobProfile profile = UserJobProfile.empty(1L); + profile.update(null, "건설·토목", null); + + profile.update(null, " ", null); + + // null 이 "그대로 두기" 라서, 비우는 수단이 따로 있어야 한다. + assertThat(profile.getDesiredIndustry()).isNull(); + } + + @Test + void 산업은_앞뒤_공백을_떼고_저장한다() { + UserJobProfile profile = UserJobProfile.empty(1L); + + profile.update(null, " 반도체 ", null); + + assertThat(profile.getDesiredIndustry()).isEqualTo("반도체"); + } + + @Test + void 빈_목록으로_직군을_비울_수_있다() { + UserJobProfile profile = UserJobProfile.empty(1L); + profile.update(Set.of(JobCategory.HR), null, null); + + profile.update(Set.of(), null, null); + + assertThat(profile.getDesiredJobCategories()).isEmpty(); + } + + @Test + void 아무것도_안_채우면_filled_가_거짓이다() { + // 가입 직후 안내를 띄울지 판단하는 값이다. + assertThat(UserJobProfile.empty(1L).isFilled()).isFalse(); + + UserJobProfile onlyIndustry = UserJobProfile.empty(2L); + onlyIndustry.update(null, "금융", null); + assertThat(onlyIndustry.isFilled()).isTrue(); + } +} 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 87917042..f75dffc7 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 @@ -52,7 +52,7 @@ private SelfIntroAnsweredEvent event(Integer generalQuestionCount) { private SelfIntroAnsweredEvent event(Integer generalQuestionCount, List contextDocumentIds) { return new SelfIntroAnsweredEvent( 7L, 99L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), - 10, generalQuestionCount, contextDocumentIds, "자기소개 답변입니다.", null, null + 10, generalQuestionCount, contextDocumentIds, "자기소개 답변입니다.", null, null, null ); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index 53bd96c3..e927d11b 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -61,7 +61,7 @@ void create_savesSessionAndPublishesEvent() { SessionResult result = service.create(1L, new SessionCreateCommand( "title", "memo", SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(), null, null - )); + , null)); assertThat(result.id()).isEqualTo(100L); assertThat(result.status()).isEqualTo(SessionStatus.READY); @@ -77,7 +77,7 @@ void create_generatesTitleFromModeAndJobWhenBlank() { SessionResult result = service.create(1L, new SessionCreateCommand( " ", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(), null, null - )); + , null)); assertThat(result.title()).isEqualTo("백엔드 기술 면접"); } @@ -91,7 +91,7 @@ void create_keepsProvidedTitle() { SessionResult result = service.create(1L, new SessionCreateCommand( "내가 정한 제목", null, SessionMode.INTEGRATED, List.of(JobCategory.FRONTEND), 5, 30, null, null, List.of(), null, null - )); + , null)); assertThat(result.title()).isEqualTo("내가 정한 제목"); } @@ -111,7 +111,7 @@ void create_linksAnalyzedContextDocuments() { SessionResult result = service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(7L, 7L), null, null - )); + , null)); assertThat(result.contextDocumentIds()).containsExactly(7L); verify(contextRepository).save(any(SessionContext.class)); @@ -128,7 +128,7 @@ void create_rejectsNonAnalyzedDocument() { assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(8L), null, null - ))).isInstanceOf(DomainException.class); + , null))).isInstanceOf(DomainException.class); } @Test @@ -141,7 +141,7 @@ void create_rejectsMaxQuestionsBelowGeneralCount() { assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 2, 30, 5, 2, List.of(), null, null - ))).isInstanceOf(DomainException.class); + , null))).isInstanceOf(DomainException.class); verify(sessionRepository, never()).save(any(InterviewSession.class)); } @@ -155,7 +155,7 @@ void create_jobTailoredRequiresJobDescription() { assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( "t", null, SessionMode.JOB_TAILORED, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(), "토스", " " - ))).isInstanceOf(DomainException.class); + , null))).isInstanceOf(DomainException.class); } @Test @@ -171,7 +171,7 @@ void create_jobTailoredStoresCompanyAndJd() { SessionResult result = service.create(1L, new SessionCreateCommand( " ", null, SessionMode.JOB_TAILORED, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(), "토스", "백엔드 엔지니어. Kotlin/Spring, 대용량 결제." - )); + , null)); assertThat(result.targetCompanyName()).isEqualTo("토스"); assertThat(result.targetJobDescription()).contains("결제"); diff --git a/docs/database.md b/docs/database.md index 9015048e..4391c025 100644 --- a/docs/database.md +++ b/docs/database.md @@ -417,3 +417,23 @@ public class InterviewSession { - 면접 대상 회사 관리 (`target_companies`) 추가 시 본 문서 §1, §2, §3에 반영. + +--- + +## 사용자 취업 프로필 (V36) + +| 테이블 | 용도 | +|---|---| +| `user_job_profiles` | `user_id` PK/FK(1:1) · `desired_industry` · `career_level` | +| `user_job_profile_categories` | 희망 직군 다중 (`session_job_categories` 와 같은 모양) | +| `interview_sessions.industry` | 그 면접에 **실제로 쓰인** 산업 | + +**`desired_industry` 에는 CHECK 가 없다.** 직군은 평가 관점을 큐레이션해야 해서 유한 집합이지만, +산업은 프롬프트 맥락으로만 쓰이므로 열거하면 빠진 산업의 지원자가 배제된다(반도체·건설·토목을 +넣어도 조선·방산·바이오…). 프론트가 `datalist` 로 제안만 한다. + +`career_level` 은 CHECK 로 막는다(`NEW`/`EXPERIENCED`/`INTERN`/`CAREER_CHANGE`) — 질문 난이도를 +가르는 값이라 오타가 조용히 흘러가면 안 된다. + +세션이 `industry` 를 따로 갖는 이유: 프로필은 나중에 바뀐다. "이 면접이 어떤 맥락에서 +진행됐는지" 는 세션이 스스로 기록해야 한다(직군을 세션에 복사해 두는 것과 같은 이유). diff --git a/frontend/src/domain/session/model/careerLevel.ts b/frontend/src/domain/session/model/careerLevel.ts new file mode 100644 index 00000000..ee2d81e3 --- /dev/null +++ b/frontend/src/domain/session/model/careerLevel.ts @@ -0,0 +1,12 @@ +export const CAREER_LEVELS = [ + { value: 'NEW', label: '신입' }, + { value: 'EXPERIENCED', label: '경력' }, + { value: 'INTERN', label: '인턴·체험형' }, + { value: 'CAREER_CHANGE', label: '직무 전환' }, +] as const + +export type CareerLevel = (typeof CAREER_LEVELS)[number]['value'] + +export const CAREER_LEVEL_LABEL: Record = Object.fromEntries( + CAREER_LEVELS.map((o) => [o.value, o.label] as const), +) as Record diff --git a/frontend/src/domain/session/model/industry.ts b/frontend/src/domain/session/model/industry.ts new file mode 100644 index 00000000..203f557e --- /dev/null +++ b/frontend/src/domain/session/model/industry.ts @@ -0,0 +1,39 @@ +/** + * 희망 산업 제안 목록. + * + * **열거가 아니라 제안이다.** 산업은 자유 입력이라 여기 없어도 그대로 저장된다 — + * 직군처럼 목록으로 막으면 반도체·건설·토목을 넣어도 조선·방산·바이오가 빠지고, 빠진 + * 사람은 또 고를 게 없어진다. 자주 나올 법한 것만 먼저 보여 타이핑을 줄이는 용도다. + */ +export const INDUSTRY_SUGGESTIONS = [ + '반도체', + '전자·디스플레이', + '자동차·모빌리티', + '조선·해양', + '기계·로봇', + '건설·토목', + '플랜트·엔지니어링', + '화학·소재', + '제약·바이오', + '의료기기', + '식품·음료', + '패션·뷰티', + '유통·이커머스', + '물류·운송', + '금융·은행', + '증권·자산운용', + '보험', + '핀테크', + 'IT·소프트웨어', + '게임', + '통신', + '미디어·콘텐츠', + '광고·마케팅', + '교육', + '공공·공기업', + '에너지·발전', + '환경', + '항공·우주·방위', + '농축수산', + '컨설팅·서비스', +] as const diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 55acd2b0..595267ca 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Button } from '@/shared/ui/Button' import { Stepper } from '@/shared/ui/Stepper' import { Heading } from '@/shared/ui' import type { JobCategory, SessionCreateRequest, SessionMode } from '@/domain/session' import { ModeSelector } from './ModeSelector' import { JobCategorySelector } from './JobCategorySelector' +import { IndustryField } from '@/features/profile/ui/IndustryField' +import { fetchJobProfile } from '@/features/profile/api/jobProfileApi' import { ContextDocumentPicker } from './ContextDocumentPicker' import type { DocOption } from './ContextDocumentPicker' @@ -30,6 +32,25 @@ export function InterviewSetupForm({ const [selected, setSelected] = useState([]) const [companyName, setCompanyName] = useState('') const [jobDescription, setJobDescription] = useState('') + const [industry, setIndustry] = useState('') + + // 내 프로필로 기본값을 채운다. 매번 같은 직군을 다시 고르게 할 이유가 없다. + // 사용자가 이미 건드린 값은 덮지 않는다 — 프로필은 기본값이지 강제값이 아니다. + useEffect(() => { + let alive = true + fetchJobProfile() + .then((p) => { + if (!alive) return + setJobCategories((prev) => (prev.length > 0 ? prev : p.desiredJobCategories)) + setIndustry((prev) => (prev ? prev : (p.desiredIndustry ?? ''))) + }) + .catch(() => { + // 프로필을 못 불러와도 면접은 만들 수 있어야 한다 — 조용히 넘어간다. + }) + return () => { + alive = false + } + }, []) const isJobTailored = mode === 'JOB_TAILORED' @@ -64,6 +85,7 @@ export function InterviewSetupForm({ contextDocumentIds: selected, targetCompanyName: isJobTailored ? companyName.trim() || undefined : undefined, targetJobDescription: isJobTailored ? jobDescription.trim() : undefined, + industry: industry.trim() || undefined, }) } @@ -151,6 +173,12 @@ export function InterviewSetupForm({ 복수 선택 가능 +

면접 구성 diff --git a/frontend/src/features/profile/api/jobProfileApi.ts b/frontend/src/features/profile/api/jobProfileApi.ts new file mode 100644 index 00000000..f3d12b2d --- /dev/null +++ b/frontend/src/features/profile/api/jobProfileApi.ts @@ -0,0 +1,29 @@ +import { apiClient } from '@/shared/api' +import type { JobCategory } from '@/domain/session' +import type { CareerLevel } from '@/domain/session/model/careerLevel' + +export type JobProfile = { + desiredJobCategories: JobCategory[] + desiredIndustry: string | null + careerLevel: CareerLevel | null + /** 한 번이라도 채웠는가. 가입 직후 안내를 띄울지 판단한다. */ + filled: boolean +} + +export async function fetchJobProfile(): Promise { + const { data } = await apiClient.get('/api/users/me/job-profile') + return data +} + +/** + * 생략한(undefined) 항목은 서버가 그대로 둔다. 비우려면 빈 목록 / 빈 문자열을 보낸다 — + * 계정 화면이 일부만 보내도 나머지가 날아가지 않아야 한다. + */ +export async function saveJobProfile(patch: { + desiredJobCategories?: JobCategory[] + desiredIndustry?: string + careerLevel?: CareerLevel +}): Promise { + const { data } = await apiClient.put('/api/users/me/job-profile', patch) + return data +} diff --git a/frontend/src/features/profile/ui/IndustryField.tsx b/frontend/src/features/profile/ui/IndustryField.tsx new file mode 100644 index 00000000..9a3f1da0 --- /dev/null +++ b/frontend/src/features/profile/ui/IndustryField.tsx @@ -0,0 +1,51 @@ +import { useId } from 'react' + +import { INDUSTRY_SUGGESTIONS } from '@/domain/session/model/industry' + +/** + * 희망 산업 입력. + * + * `datalist` 라서 **제안은 보여주되 목록 밖 값도 그대로 받는다.** 드롭다운(select)으로 막으면 + * 직군이 개발 4종뿐이던 때와 같은 문제가 생긴다 — 빠진 산업의 지원자는 고를 게 없다. + */ +export function IndustryField({ + value, + onChange, + label = '희망 산업', + hint = '목록에 없어도 직접 입력할 수 있어요. 질문이 그 현장의 언어로 나옵니다.', +}: { + value: string + onChange: (value: string) => void + label?: string + hint?: string +}) { + const inputId = useId() + const listId = `${inputId}-industries` + const hintId = `${inputId}-hint` + + return ( +
+ + onChange(e.target.value)} + placeholder="예: 반도체, 건설·토목, 금융" + aria-describedby={hintId} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg placeholder:text-fg-subtle focus:border-brand focus:outline-none" + /> + + {INDUSTRY_SUGGESTIONS.map((s) => ( + +

+ {hint} +

+
+ ) +} diff --git a/frontend/src/features/profile/ui/JobProfileForm.tsx b/frontend/src/features/profile/ui/JobProfileForm.tsx new file mode 100644 index 00000000..3e37d5f3 --- /dev/null +++ b/frontend/src/features/profile/ui/JobProfileForm.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' + +import { fetchJobProfile, saveJobProfile, type JobProfile } from '../api/jobProfileApi' +import { IndustryField } from './IndustryField' +import { CAREER_LEVELS, type CareerLevel } from '@/domain/session/model/careerLevel' +import { JobCategorySelector } from '@/features/interview/ui/setup/JobCategorySelector' +import { Button } from '@/shared/ui/Button' +import type { JobCategory } from '@/domain/session' + +/** + * 취업 프로필 — 희망 직군·산업·경력. + * + * 면접을 만들 때마다 직군을 다시 고르게 했고, 직군만으로는 질문 맥락이 얇았다. 여기서 한 번 + * 채우면 면접 생성 화면이 기본값으로 채우고, 사용자는 그 자리에서 바꿀 수 있다. + */ +export function JobProfileForm({ onSaved }: { onSaved?: (profile: JobProfile) => void }) { + const [categories, setCategories] = useState([]) + const [industry, setIndustry] = useState('') + const [career, setCareer] = useState('') + const [status, setStatus] = useState<'loading' | 'idle' | 'saving' | 'saved' | 'error'>( + 'loading', + ) + + useEffect(() => { + let alive = true + fetchJobProfile() + .then((p) => { + if (!alive) return + setCategories(p.desiredJobCategories) + setIndustry(p.desiredIndustry ?? '') + setCareer(p.careerLevel ?? '') + setStatus('idle') + }) + .catch(() => alive && setStatus('error')) + return () => { + alive = false + } + }, []) + + const toggle = (value: JobCategory) => + setCategories((prev) => + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value], + ) + + const save = async () => { + setStatus('saving') + try { + const saved = await saveJobProfile({ + desiredJobCategories: categories, + // 빈 문자열은 "지우기" 다(null 은 "그대로 두기"). + desiredIndustry: industry, + careerLevel: career === '' ? undefined : career, + }) + setStatus('saved') + onSaved?.(saved) + } catch { + setStatus('error') + } + } + + if (status === 'loading') { + return

불러오는 중…

+ } + + return ( +
+
+

희망 직군

+ +
+ + + +
+ 경력 수준 +
+ {CAREER_LEVELS.map((o) => ( + + ))} +
+
+ +
+ + {status === 'saved' && 저장했어요.} + {status === 'error' && ( + 저장하지 못했어요. 잠시 후 다시 시도해 주세요. + )} +
+
+ ) +} diff --git a/frontend/src/pages/Workspace/ui/AccountView.tsx b/frontend/src/pages/Workspace/ui/AccountView.tsx index 18acb991..a2597d00 100644 --- a/frontend/src/pages/Workspace/ui/AccountView.tsx +++ b/frontend/src/pages/Workspace/ui/AccountView.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useAuth, useDeleteAccount, useLogout } from '@/features/auth' import { Button } from '@/shared/ui/Button' import { ConfirmDialog } from '@/shared/ui' +import { JobProfileForm } from '@/features/profile/ui/JobProfileForm' // 탈퇴가 실제로 무엇을 하고 무엇을 하지 않는지 (docs/security.md §5.3). // "정말 삭제할까요?" 만 묻고 넘어가면 사용자는 GitHub 권한이 남는다는 걸 끝내 모른다. @@ -39,6 +40,17 @@ export function AccountView() {
+
+

취업 프로필

+

+ 여기 채워 두면 새 면접을 만들 때 자동으로 채워집니다. 산업까지 알면 질문이 그 + 현장의 언어로 나옵니다 — 같은 생산·품질이라도 반도체와 건설은 묻는 것이 다릅니다. +

+
+ +
+
+

회원 탈퇴

diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 99e874b1..a8ad1cb0 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1110,6 +1110,8 @@ export interface components { contextDocumentIds?: number[]; targetCompanyName?: string; targetJobDescription?: string; + /** @description 희망 산업(자유 입력, 선택). 질문 맥락에 실린다. */ + industry?: string; }; SessionResponse: { /** Format: int64 */ @@ -1140,6 +1142,8 @@ export interface components { contextDocumentIds?: number[]; targetCompanyName?: string; targetJobDescription?: string; + /** @description 희망 산업(자유 입력, 선택). 질문 맥락에 실린다. */ + industry?: string; focusAreas?: string[]; /** Format: date-time */ createdAt?: string;