Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/general-job-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 일반 직군 API 구현

기준: origin/main `6acc8e4` (2026-10-20 fetch). 계약 단일 출처: 상위 작업 디렉터리 `Doc/general-job-impl-spec/01-overview.md`, 상세 `03-api.md`, 비개발 선행 `Doc/job-family-impl-spec/03-api.md`.

- [가정] 사용자 전체 병렬 구현 요청에 따라 비개발 선행 미완료 C-3·C-5·C-7·C-8을 이 PR에서 먼저 구현한다. 병합 순서 auth → Analysis → Chat → API → Web은 유지한다.
- [가정] 기존 record 생성자 호출 호환을 위해 새 컴포넌트는 끝에 추가하고 기존 인자 생성자를 유지한다. JSON의 의미와 계약 값은 동일하다.
- [가정] 자료 분석 가능 판정과 `/profile` 변환은 AnalysisLaunchService에 모은다. 수동 재분석도 저장된 materials 전체를 사용한다.
- [가정] 옛 dataUpload는 현재 자료 API 검증·저장을 재사용한다. 직무 없음은 BACKEND, 문서 종류 PORTFOLIO, 새 file0 한 건으로 변환한다. 기존 사용자 정보를 덮어쓰는 별도 옛 저장 호출은 사용하지 않는다.
- 마이그레이션: 기존 최신 V38 확인. 비개발 V39, 일반 직군 V40 추가. 운영 DB·배포·병합은 수행하지 않는다.

검증 결과와 PR은 완료 시 기록한다.

## G-A31 시작 직무 스냅샷

최초 `prepareInterview`에서 일반 직군이면 외부 분석/테일러 호출 전에 `InterviewJobSnapshotService.capture`를 별도 짧은 트랜잭션으로 실행한다. 면접 행을 PESSIMISTIC_WRITE로 잠그고 소유자·상태·기존 tailor를 확인한다. 공유 persona를 수정하지 않고 TECH를 면접 전용으로 복제해 현재 auth major/jobTitle을 기록한다. SOLO 대표 ID 또는 MULTI 첫 링크만 교체한다. clone에 `interview_snapshot=true`, 원래 표시 이름을 `snapshot_source_name`으로 남긴다. 두 컬럼은 V40에 포함한다. 일반 선택 목록/직접 면접관 조회/새 면접에서 snapshot clone 선택은 막고, 면접 권한을 확인하는 목록·채점·Chat은 clone을 사용한다. 실제 면접관 표시 이름은 원래 이름을 유지한다.

복제본이 이미 연결됐거나 tailor가 있거나 면접이 종료됐으면 재기록하지 않는다. 외부 접수 전에 실패해 tailor가 없어도 clone은 확정값으로 남는다. 즉시 테일러·대기 재개·재시도는 snapshot major/jobTitle을 읽는다. 질문 세트 출처 직무와 snapshot 직무가 다르면 409로 막고 신규 PENDING을 만들지 않는다. 질문 풀의 면접별 세트 예약은 기존 멱등 규칙대로 남는다. IT 기존 경로는 유지한다.

## 계약 대조와 검증

- 01 §3.1: JobRole·Major 15개(개발3·비개발6·일반6), 일반 GitHub 숨김·보완 없음·자소서 허용.
- 01 §3.3: WORK_SAMPLE은 일반에서만 허용. 공통 RESUME·CAREER_DESCRIPTION 및 PORTFOLIO와 합쳐 일반 4종.
- 01 §3.4: 직무명 30자·자소서 5개/문항200/답2000, 일반은 이력서 외 자료 또는 자소서 필수. Reason 신규6개 대응.
- 신규 profile DTO는 jobRole/documents/githubUrls/supplements/callbackUrl, 일반만 jobTitle/coverLetters. IT에는 두 새 키를 생략한다. 비개발 선행 분석 차단·skipped 제거.
- Flyway V1~V40을 격리 PostgreSQL 16에 적용하고 스냅샷 생성·동시 시작·원본/완료 면접 보존을 검증했다. Testcontainers 대신 기존 프로젝트 SpringBootTest 및 일회용 Docker PostgreSQL을 사용했다.
- `JAVA_HOME` Java21, DB `127.0.0.1:55439`, 콜백 `localhost:18039`, 외부 auth/AI/Chat는 `127.0.0.1:9`, 더미 S3 자격으로 `./gradlew test`: **948개 통과, 실패0·skip0**.
- 자체 리뷰와 루트/독립 리뷰 반영: 콜백 직무명 소실, 공유 persona 변경 위험, 직무 불일치의 고립 PENDING, OSIV stale FK 재복제 위험을 수정했다. 코드 리뷰 전용 실행 도구는 제공되지 않아 저장소 인계의 자체 리뷰 기준을 적용했다.
- `git diff --check` 통과. 최종 fetch 기준 origin/main은 `6acc8e4`로 동일.
- 이슈: https://github.com/Alpha-origin/Repit-api-server/issues/113. 브랜치는 상위 작업에서 지정한 `codex/general-job-api`, PR 제목은 저장소 템플릿의 브랜치명 동일 규칙을 따른다.

## 남은 통합 검증

Auth·Analysis·Chat 선행 PR 병합 뒤 일반6직군 E2E, 유료 모델 실제 분석·이미지 PDF·꼬리질문·채점 품질 검증은 별도다. 이번 작업은 API 코드·격리 DB 테스트까지이며 배포·병합·운영 DB 변경은 하지 않았다.
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,7 @@ private void replay(String jobId) {
public ResponseEntity<GenerateResponse> generate(
@AuthenticationPrincipal AuthUser authUser
) {
MetaDataResponse forRequest = metaService.getMaterials(authUser.token()).toAnalysisInput();
return ResponseEntity.ok(analysisLaunchService.launch(authUser.id(), authUser.user().getMajor(), forRequest));
return ResponseEntity.ok(analysisLaunchService.launchMaterials(authUser.id(), metaService.getMaterials(authUser.token())));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,18 @@ public class MetaDataController {
public ResponseEntity<GenerateResponse> upload(@AuthenticationPrincipal AuthUser authUser,
@RequestPart("file") MultipartFile file,
@RequestParam List<String> gitUrls) throws IOException {
// 분석할 수 없는 직무(비개발)면 자료를 저장하기 전에 422로 막는다. 저장만 되고 분석은 실패하는 일을 막는다.
analysisLaunchService.requireSupportedJob(authUser.user().getMajor());
MetaDataResponse metaData = metaService.dataUpload(authUser.token(), file, gitUrls);

// 올린 자료로 곧바로 분석을 시작한다. 접수까지 함께 해야 이 분석에 주인이 남고,
// 주인이 없으면 나중에 면접을 열 때 이 결과를 찾지 못한다.
return ResponseEntity.ok(analysisLaunchService.launch(authUser.id(), authUser.user().getMajor(), metaData));
String jobRole = repit.repit_api_server.domain.userdata.persona.support.UserJobMajors
.normalize(authUser.user().getMajor());
MaterialsPayload payload = new MaterialsPayload(jobRole == null ? "BACKEND" : jobRole,
List.of(new MaterialsPayload.DocumentInput("PORTFOLIO", "NEW", "file0", null)),
gitUrls, List.of());
MaterialsSaveResponse saved = materialsService.replace(authUser, payload, Map.of("file0", file));
if (saved.analysis() == null || saved.analysis().jobId() == null) {
throw repit.repit_api_server.global.exception.BusinessException.unprocessable(
"자료는 저장했지만 분석을 요청하지 못했습니다. 다시 분석해 주세요.");
}
return ResponseEntity.ok(GenerateResponse.builder().jobId(saved.analysis().jobId())
.status(saved.analysis().status()).build());
}


Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package repit.repit_api_server.domain.metadata.dto.request;

import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry;
import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.CoverLetterEntry;
import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry;

import java.util.List;
Expand All @@ -14,5 +15,11 @@ public record AuthMaterialsRequest(
String major,
List<DocumentEntry> documents,
List<String> gitUrls,
List<SupplementEntry> supplements) {
List<SupplementEntry> supplements,
String jobTitle,
List<CoverLetterEntry> coverLetters) {

public AuthMaterialsRequest(String major, List<DocumentEntry> documents, List<String> gitUrls, List<SupplementEntry> supplements) {
this(major, documents, gitUrls, supplements, null, List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ public record MaterialsPayload(
String jobRole,
List<DocumentInput> documents,
List<String> gitUrls,
List<SupplementInput> supplements) {
List<SupplementInput> supplements,
String jobTitle,
List<CoverLetterInput> coverLetters) {

public MaterialsPayload(String jobRole, List<DocumentInput> documents, List<String> gitUrls, List<SupplementInput> supplements) {
this(jobRole, documents, gitUrls, supplements, null, List.of());
}

public static final String SOURCE_NEW = "NEW";
public static final String SOURCE_EXISTING = "EXISTING";

public record CoverLetterInput(String question, String answer) {
}

public record DocumentInput(String type, String source, String fileKey, String fileUrl) {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
package repit.repit_api_server.domain.metadata.dto.request;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

/** 분석 서버 POST /profile 요청. 자료를 한 번 분석해 질문 생성의 재료인 종합 데이터를 만든다. */
/** 분석 서버 POST /profile 요청(계약 C-3·G-C3). 저장된 자료 전체를 분석한다. */
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProfileRequest {
// 사용자 전공. 탐색 우선순위에만 쓰이고 없으면 비워 보낸다.
private String major;
private String portfolioUrl;
private String jobRole;
private List<Document> documents;
private List<String> githubUrls;
private List<Supplement> supplements;
private String callbackUrl;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String jobTitle;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private List<CoverLetter> coverLetters;

@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public static class Document {
private String type;
private String url;
private String title;
}

@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public static class Supplement {
private String title;
private Integer documentIndex;
private String goal;
private String role;
private String decision;
private String outcome;
}

@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public static class CoverLetter {
private String question;
private String answer;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ public record AuthMaterialsResponse(
List<DocumentEntry> documents,
List<String> gitUrls,
List<SupplementEntry> supplements,
String legacyPortfolio) {
String legacyPortfolio,
String jobTitle,
List<CoverLetterEntry> coverLetters) {

public AuthMaterialsResponse(String major, List<DocumentEntry> documents, List<String> gitUrls, List<SupplementEntry> supplements, String legacyPortfolio) {
this(major, documents, gitUrls, supplements, legacyPortfolio, null, List.of());
}

public record CoverLetterEntry(String question, String answer) {
}

public record DocumentEntry(String type, String fileUrl, String fileName, Integer pageCount) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public record MaterialError(String field, Reason reason, String detail) {
/** Web 문구 표(비개발 06 §3.4)와 같은 사유 목록. */
public enum Reason {
JOB_ROLE_REQUIRED,
JOB_TITLE_REQUIRED,
JOB_TITLE_INVALID,
JOB_TITLE_NOT_ALLOWED,
MATERIAL_REQUIRED,
COVER_LETTER_INVALID,
COVER_LETTER_NOT_ALLOWED,
NO_DOCUMENT,
REQUIRED_DOC_MISSING,
TOO_MANY_DOCUMENTS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,23 @@ public record MaterialsResponse(
String jobRole,
List<MaterialDocument> documents,
List<String> gitUrls,
List<Supplement> supplements) {
List<Supplement> supplements,
String jobTitle,
List<CoverLetter> coverLetters) {

public MaterialsResponse(String jobRole, List<MaterialDocument> documents, List<String> gitUrls, List<Supplement> supplements) {
this(jobRole, documents, gitUrls, supplements, null, List.of());
}

/**
* 지금 분석 요청(/profile)이 portfolioUrl로 받는 문서 종류.
* 옛 포트폴리오 업로드를 문서 목록으로 변환할 때 쓰는 종류.
* 옛 업로드 경로(dataUpload)로 올린 PDF도 이 종류의 문서 한 건으로 보여 준다.
*/
public static final String PORTFOLIO_TYPE = "PORTFOLIO";

public record CoverLetter(String question, String answer) {
}

public record MaterialDocument(int index, String type, String fileUrl, String fileName, Integer pageCount) {
}

Expand All @@ -31,7 +40,7 @@ public record Supplement(String title, Integer documentIndex,
}

/**
* 지금 /profile 요청 모양(포트폴리오 문서 + GitHub)으로 옮긴다. 분석을 시작하는 모든 길이 이것을 쓴다.
* 옛 포트폴리오·GitHub 업로드 경로와의 호환을 위해 기존 자료 형식으로 옮긴다.
* 문서 순서는 사용자가 정하므로 첫 문서가 아니라 종류로 찾는다. 여러 개면 앞의 것이고, 없으면 fileUrl이 null이다.
*/
public MetaDataResponse toAnalysisInput() {
Expand All @@ -57,7 +66,10 @@ public static MaterialsResponse from(AuthMaterialsResponse auth) {
auth.gitUrls() == null ? List.of() : auth.gitUrls(),
auth.supplements() == null ? List.of() : auth.supplements().stream()
.map(s -> new Supplement(s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome()))
.toList());
.toList(),
auth.jobTitle(),
auth.coverLetters() == null ? List.of() : auth.coverLetters().stream()
.map(c -> new CoverLetter(c.question(), c.answer())).toList());
}

private static List<MaterialDocument> documentsOf(AuthMaterialsResponse auth) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ public record MaterialsSaveResponse(MaterialsResponse materials, Analysis analys
public static final String ANALYSIS_ACCEPTED = "accepted";
/** 저장은 끝났지만 분석 요청이 실패했다. 자료는 그대로 두고 다시 분석하게 한다. */
public static final String ANALYSIS_FAILED = "failed";
/** 저장은 끝났지만 아직 분석하지 않는 직무라 요청하지 않았다(비개발, 분석 서버 새 요청 모양 C-3 전). */
public static final String ANALYSIS_SKIPPED = "skipped";

public record Analysis(String jobId, String status) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public class ProfileStatusResponse {
public static final String INVALID_MATERIAL = "INVALID_MATERIAL";
/** 코드에서 확인되는 근거가 모자라 질문을 만들 수 없다(422). */
public static final String INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE";
/** 필수 문서에서 읽은 내용이 없음. */
public static final String UNREADABLE_DOCUMENT = "UNREADABLE_DOCUMENT";
/** 분석 서버 오류이거나 결과를 받지 못했다. */
public static final String INTERNAL = "INTERNAL";

Expand Down Expand Up @@ -53,7 +55,7 @@ public static ProfileStatusResponse none() {
* <p>웹은 사유마다 안내가 다르다 — 저장소 공개 설정, 자료 교체, 자료 보강, 다시 시도.
*/
public static String reasonOf(Integer statusCode, String code, String message) {
if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, INTERNAL).contains(code)) {
if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, UNREADABLE_DOCUMENT, INTERNAL).contains(code)) {
return code;
}
if (statusCode == null) {
Expand Down
Loading
Loading