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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
implementation 'software.amazon.awssdk:s3:2.25.26'
// 자료 PDF의 쪽수를 세고 손상·암호화를 가려낸다(비개발 직군 명세 가정 A-8).
implementation 'org.apache.pdfbox:pdfbox:3.0.4'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.flywaydb:flyway-database-postgresql'
developmentOnly 'me.paulschwarz:springboot4-dotenv:5.1.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,12 @@ private void replay(String jobId) {


// 이미 올려둔 자료로 분석만 다시 요청한다. 자료를 올리는 길과 같은 접수를 거친다.
// 옛 metaData의 파일 주소가 아니라 저장된 자료의 포트폴리오 문서로 요청한다.
@PostMapping("/generate")
public ResponseEntity<GenerateResponse> generate(
@AuthenticationPrincipal AuthUser authUser
) {
MetaDataResponse forRequest = metaService.getMetaData(authUser.token());
MetaDataResponse forRequest = metaService.getMaterials(authUser.token()).toAnalysisInput();
return ResponseEntity.ok(analysisLaunchService.launch(authUser.id(), authUser.user().getMajor(), forRequest));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,35 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload;
import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse;
import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse;
import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse;
import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse;
import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService;
import repit.repit_api_server.domain.metadata.service.MaterialsService;
import repit.repit_api_server.domain.metadata.service.MetaService;
import repit.repit_api_server.global.auth.AuthUser;

import java.io.IOException;
import java.util.List;
import java.util.Map;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/metaData")
public class MetaDataController {
private final MetaService metaService;
private final AnalysisLaunchService analysisLaunchService;
private final MaterialsService materialsService;

@PostMapping(value = "/dataUpload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
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);

// 올린 자료로 곧바로 분석을 시작한다. 접수까지 함께 해야 이 분석에 주인이 남고,
Expand All @@ -43,14 +50,27 @@ public ResponseEntity<MetaDataResponse> getMetaData(
return ResponseEntity.ok(metaService.getMetaData(authUser.token()));
}

// 마이페이지 편집 화면이 처음 채울 저장값. 저장(PUT)은 비개발 API 명세(03 §3)를 받은 뒤 붙인다.
// 마이페이지 편집 화면이 처음 채울 저장값.
@GetMapping("/materials")
public ResponseEntity<MaterialsResponse> getMaterials(
@AuthenticationPrincipal AuthUser authUser
) {
return ResponseEntity.ok(metaService.getMaterials(authUser.token()));
}

/**
* 직무·문서·GitHub·보완 입력을 한 번에 저장한다. payload 파트(JSON)와 새로 첨부한 PDF(file0~file4)가 온다.
* 규칙에 걸리면 422와 사유 목록(MaterialErrorResponse)이 나간다.
*/
@PutMapping(value = "/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<MaterialsSaveResponse> putMaterials(
@AuthenticationPrincipal AuthUser authUser,
@RequestPart("payload") MaterialsPayload payload,
@RequestParam Map<String, MultipartFile> files
) {
return ResponseEntity.ok(materialsService.replace(authUser, payload, files));
}


// 자료를 한 번도 올리지 않은 사용자는 인증 서버가 두 값을 null로 준다. 없는 것이지 오류가 아니다.
@GetMapping("/isGit")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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.SupplementEntry;

import java.util.List;

/**
* 인증 서버 {@code PUT /api/v1/auth/materials} 요청(계약 C-2). 넷을 인증 서버가 한 트랜잭션으로 바꾼다.
*
* @param major MAJOR_ 접두사를 붙인 저장값(MAJOR_QA_ENGINEER)
*/
public record AuthMaterialsRequest(
String major,
List<DocumentEntry> documents,
List<String> gitUrls,
List<SupplementEntry> supplements) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package repit.repit_api_server.domain.metadata.dto.request;

import java.util.List;

/**
* {@code PUT /api/v1/metaData/materials}의 {@code payload} 파트(계약 C-1).
*
* <p>문서 순서가 곧 index다. 저장된 문서는 {@code source: "EXISTING"} + {@code fileUrl},
* 새로 첨부한 파일은 {@code source: "NEW"} + {@code fileKey}("file0"~"file4", 같은 요청의 파트 이름)로 온다.
*/
public record MaterialsPayload(
String jobRole,
List<DocumentInput> documents,
List<String> gitUrls,
List<SupplementInput> supplements) {

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

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

public record SupplementInput(String title, Integer documentIndex,
String goal, String role, String decision, String outcome) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package repit.repit_api_server.domain.metadata.dto.response;

import java.util.List;

/**
* {@code PUT /api/v1/metaData/materials} 422 응답(계약 C-1). 걸린 규칙을 모아 한 번에 돌려준다.
*
* <p>field는 요청 위치(documents[1], gitUrls[0], supplements 등)이고, Web이 행·영역마다 문구를 띄우는 데 쓴다.
*/
public record MaterialErrorResponse(String message, List<MaterialError> errors) {

public static final String MESSAGE = "자료를 저장하지 못했습니다.";

public record MaterialError(String field, Reason reason, String detail) {
}

/** Web 문구 표(비개발 06 §3.4)와 같은 사유 목록. */
public enum Reason {
JOB_ROLE_REQUIRED,
NO_DOCUMENT,
REQUIRED_DOC_MISSING,
TOO_MANY_DOCUMENTS,
DOC_TYPE_NOT_ALLOWED,
NOT_PDF,
BROKEN_PDF,
ENCRYPTED_PDF,
FILE_TOO_LARGE,
DUPLICATE_FILE,
TOO_MANY_PAGES,
MATERIALS_RESET_REQUIRED,
EXISTING_NOT_FOUND,
GITHUB_REQUIRED,
GITHUB_NOT_ALLOWED,
INVALID_GITHUB_URL,
SUPPLEMENT_NOT_ALLOWED,
SUPPLEMENT_INVALID
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ public record MaterialsResponse(
List<String> gitUrls,
List<Supplement> supplements) {

/** 옛 업로드 경로(dataUpload)로 올린 PDF는 이 종류의 문서 한 건으로 보여 준다. */
static final String LEGACY_DOCUMENT_TYPE = "PORTFOLIO";
/**
* 지금 분석 요청(/profile)이 portfolioUrl로 받는 문서 종류.
* 옛 업로드 경로(dataUpload)로 올린 PDF도 이 종류의 문서 한 건으로 보여 준다.
*/
public static final String PORTFOLIO_TYPE = "PORTFOLIO";

public record MaterialDocument(int index, String type, String fileUrl, String fileName, Integer pageCount) {
}
Expand All @@ -27,6 +30,19 @@ public record Supplement(String title, Integer documentIndex,
String goal, String role, String decision, String outcome) {
}

/**
* 지금 /profile 요청 모양(포트폴리오 문서 + GitHub)으로 옮긴다. 분석을 시작하는 모든 길이 이것을 쓴다.
* 문서 순서는 사용자가 정하므로 첫 문서가 아니라 종류로 찾는다. 여러 개면 앞의 것이고, 없으면 fileUrl이 null이다.
*/
public MetaDataResponse toAnalysisInput() {
String portfolioUrl = documents.stream()
.filter(d -> PORTFOLIO_TYPE.equals(d.type()))
.map(MaterialDocument::fileUrl)
.findFirst()
.orElse(null);
return new MetaDataResponse(gitUrls, portfolioUrl);
}

public static MaterialsResponse empty() {
return new MaterialsResponse(null, List.of(), List.of(), List.of());
}
Expand All @@ -50,7 +66,7 @@ private static List<MaterialDocument> documentsOf(AuthMaterialsResponse auth) {
// 옛 사용자는 파일명·쪽수를 저장한 적이 없어 null로 둔다.
return auth.legacyPortfolio() == null || auth.legacyPortfolio().isBlank()
? List.of()
: List.of(new MaterialDocument(0, LEGACY_DOCUMENT_TYPE, auth.legacyPortfolio(), null, null));
: List.of(new MaterialDocument(0, PORTFOLIO_TYPE, auth.legacyPortfolio(), null, null));
}
List<MaterialDocument> documents = new ArrayList<>(saved.size());
for (int i = 0; i < saved.size(); i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package repit.repit_api_server.domain.metadata.dto.response;

/**
* {@code PUT /api/v1/metaData/materials} 200 응답(계약 C-1).
*
* <p>analysis가 null이면 분석 재료가 바뀌지 않아 분석을 다시 요청하지 않았다는 뜻이다.
*/
public record MaterialsSaveResponse(MaterialsResponse materials, Analysis analysis) {

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) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package repit.repit_api_server.domain.metadata.exception;

import lombok.Getter;
import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError;

import java.util.List;

/** 직무·자료 저장 요청이 규칙에 걸렸다. 422와 사유 목록으로 나간다. */
@Getter
public class MaterialValidationException extends RuntimeException {

private final List<MaterialError> errors;

public MaterialValidationException(List<MaterialError> errors) {
super("자료 검증 실패: " + errors);
this.errors = List.copyOf(errors);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse;
import repit.repit_api_server.domain.metadata.entity.enums.AnalysisResultType;
import repit.repit_api_server.domain.metadata.repository.AnalysisDataRepository;
import repit.repit_api_server.domain.metadata.support.JobRole;
import repit.repit_api_server.domain.userdata.question.service.QuestionPoolService;
import repit.repit_api_server.global.auth.AuthUser;
import repit.repit_api_server.global.client.AiServerClient;
import repit.repit_api_server.global.exception.BusinessException;
import repit.repit_api_server.global.exception.ExternalApiException;

import java.time.LocalDateTime;
Expand Down Expand Up @@ -44,15 +46,28 @@ public class AnalysisLaunchService {
private final AnalysisDataRepository analysisDataRepository;
private final QuestionPoolService questionPoolService;

static final String NON_DEV_NOT_SUPPORTED = "비개발 직무는 아직 자료 분석을 지원하지 않습니다.";

@Value("${app.callback-base-url}")
private String callbackBaseUrl;

/**
* 지금 /profile 요청 모양으로 분석할 수 있는 직무가 아니면 422로 막는다(비개발, 계약 C-3 전).
* 자료를 저장한 뒤 분석하는 길은 저장 전에 이것으로 먼저 막는다.
*/
public void requireSupportedJob(String major) {
if (!JobRole.supportsCurrentProfileRequest(major)) {
throw BusinessException.unprocessable(NON_DEV_NOT_SUPPORTED);
}
}

/**
* 종합 데이터 분석(/profile)을 시작한다. 질문은 이 결과가 도착한 뒤 사이클로 따로 만든다.
*
* @param major 사용자 전공. 분석 서버가 탐색 우선순위에만 쓰고, 없으면 비워 보낸다.
* @param major 사용자 전공. 분석 서버가 탐색 우선순위에만 쓰고, 없으면 비워 보낸다. 비개발 직무면 422.
*/
public GenerateResponse launch(Long userId, String major, MetaDataResponse metaData) {
requireSupportedJob(major);
ProfileRequest request = ProfileRequest.builder()
.major(major == null || major.isBlank() ? null : major)
.portfolioUrl(metaData.getFileUrl())
Expand Down Expand Up @@ -82,16 +97,20 @@ public GenerateResponse launch(Long userId, String major, MetaDataResponse metaD
// ponytail: 잠근 채 인증·분석 서버를 부르므로 그동안 DB 커넥션을 붙잡는다. 사용자당 드물게 한 번 도는 길이라 감수한다.
@Transactional
public void launchIfMissing(AuthUser authUser) {
// 비개발 직무는 지금 요청 모양으로 맞는 분석이 나오지 않는다. 면접 준비는 그 전에 422로 막는다(InterviewService).
if (!JobRole.supportsCurrentProfileRequest(authUser.user().getMajor())) {
return;
}
if (!needsProfile(authUser.id())) {
return;
}
analysisDataRepository.lockProfileLaunch(authUser.id());
if (!needsProfile(authUser.id())) {
return;
}
MetaDataResponse saved = metaService.getMetaData(authUser.token());
if (saved == null || saved.getFileUrl() == null || saved.getFileUrl().isBlank()
|| saved.getGitUrls() == null || saved.getGitUrls().isEmpty()) {
// 옛 metaData의 파일 주소가 아니라 저장된 자료의 포트폴리오 문서로 요청한다. 자료 저장 뒤 분석하는 길과 같다.
MetaDataResponse saved = metaService.getMaterials(authUser.token()).toAnalysisInput();
if (saved.getFileUrl() == null || saved.getFileUrl().isBlank() || saved.getGitUrls().isEmpty()) {
return;
}
log.info("쓸 수 있는 종합 데이터가 없어 저장된 자료로 분석을 요청합니다. userId={}", authUser.id());
Expand Down
Loading
Loading