From 7ed720336cd2a1fa9223c4cc93dbf1f4dda0e1e6 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 15:56:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=81=EB=AC=B4=C2=B7=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=A1=B0=ED=9A=8C=20API(GET=20/api/v1/metaData/mat?= =?UTF-8?q?erials)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증 서버 GET /api/v1/auth/materials(계약 C-2) 호출과 응답 DTO - 마이페이지 응답(계약 C-1): MAJOR_ 접두사를 뗀 jobRole, 문서 순서대로 index - 옛 업로드 경로로 올린 PDF는 파일명·쪽수 없는 PORTFOLIO 문서 한 건으로 보여 준다 - 저장(PUT)은 비개발 API 명세(03 §3)를 받은 뒤 붙인다 Co-Authored-By: Claude Opus 5.5 --- .../controller/MetaDataController.java | 9 ++ .../dto/response/AuthMaterialsResponse.java | 24 +++++ .../dto/response/MaterialsResponse.java | 62 +++++++++++++ .../domain/metadata/service/MetaService.java | 6 ++ .../global/client/AuthServerApi.java | 4 + .../global/client/AuthServerClient.java | 10 +++ .../controller/MetaDataControllerTest.java | 11 +++ .../dto/response/MaterialsResponseTest.java | 75 ++++++++++++++++ .../client/AuthServerMaterialsWiringTest.java | 88 +++++++++++++++++++ 9 files changed, 289 insertions(+) create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java create mode 100644 src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java index 980b7f76..d52fd7fc 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; 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.MetaDataResponse; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; import repit.repit_api_server.domain.metadata.service.MetaService; @@ -42,6 +43,14 @@ public ResponseEntity getMetaData( return ResponseEntity.ok(metaService.getMetaData(authUser.token())); } + // 마이페이지 편집 화면이 처음 채울 저장값. 저장(PUT)은 비개발 API 명세(03 §3)를 받은 뒤 붙인다. + @GetMapping("/materials") + public ResponseEntity getMaterials( + @AuthenticationPrincipal AuthUser authUser + ) { + return ResponseEntity.ok(metaService.getMaterials(authUser.token())); + } + // 자료를 한 번도 올리지 않은 사용자는 인증 서버가 두 값을 null로 준다. 없는 것이지 오류가 아니다. @GetMapping("/isGit") diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java new file mode 100644 index 00000000..7d2c82a4 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/AuthMaterialsResponse.java @@ -0,0 +1,24 @@ +package repit.repit_api_server.domain.metadata.dto.response; + +import java.util.List; + +/** + * 인증 서버 {@code GET /api/v1/auth/materials} 응답(계약 C-2). + * + *

major는 MAJOR_ 접두사가 붙은 저장값이다. legacyPortfolio는 새 형식으로 저장한 적 없는 + * 옛 사용자의 PDF 주소이고, 그때 documents는 빈 목록으로 온다. + */ +public record AuthMaterialsResponse( + String major, + List documents, + List gitUrls, + List supplements, + String legacyPortfolio) { + + public record DocumentEntry(String type, String fileUrl, String fileName, Integer pageCount) { + } + + public record SupplementEntry(String title, Integer documentIndex, + String goal, String role, String decision, String outcome) { + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java new file mode 100644 index 00000000..24ce218f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponse.java @@ -0,0 +1,62 @@ +package repit.repit_api_server.domain.metadata.dto.response; + +import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@code GET /api/v1/metaData/materials} 응답(계약 C-1). 목록은 null 대신 빈 목록이다. + * + *

jobRole은 MAJOR_ 접두사를 뗀 직무 코드이고, 직무를 고르지 않았으면 null이다. + * 문서 순서가 곧 index이고, 보완 입력의 documentIndex가 이 값을 가리킨다. + */ +public record MaterialsResponse( + String jobRole, + List documents, + List gitUrls, + List supplements) { + + /** 옛 업로드 경로(dataUpload)로 올린 PDF는 이 종류의 문서 한 건으로 보여 준다. */ + static final String LEGACY_DOCUMENT_TYPE = "PORTFOLIO"; + + public record MaterialDocument(int index, String type, String fileUrl, String fileName, Integer pageCount) { + } + + public record Supplement(String title, Integer documentIndex, + String goal, String role, String decision, String outcome) { + } + + public static MaterialsResponse empty() { + return new MaterialsResponse(null, List.of(), List.of(), List.of()); + } + + public static MaterialsResponse from(AuthMaterialsResponse auth) { + if (auth == null) { + return empty(); + } + return new MaterialsResponse( + UserJobMajors.normalize(auth.major()), + documentsOf(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()); + } + + private static List documentsOf(AuthMaterialsResponse auth) { + List saved = auth.documents() == null ? List.of() : auth.documents(); + if (saved.isEmpty()) { + // 옛 사용자는 파일명·쪽수를 저장한 적이 없어 null로 둔다. + return auth.legacyPortfolio() == null || auth.legacyPortfolio().isBlank() + ? List.of() + : List.of(new MaterialDocument(0, LEGACY_DOCUMENT_TYPE, auth.legacyPortfolio(), null, null)); + } + List documents = new ArrayList<>(saved.size()); + for (int i = 0; i < saved.size(); i++) { + AuthMaterialsResponse.DocumentEntry d = saved.get(i); + documents.add(new MaterialDocument(i, d.type(), d.fileUrl(), d.fileName(), d.pageCount())); + } + return List.copyOf(documents); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java index 212f0232..e1a865e7 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MetaService.java @@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile; import repit.repit_api_server.global.client.AuthServerClient; import repit.repit_api_server.domain.metadata.dto.request.MetaDataRequest; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; @@ -58,6 +59,11 @@ public MetaDataResponse getMetaData(String token) { return authServerClient.getMetaData(token); } + // 직무·문서·GitHub·보완 입력 반환. 옛 업로드 경로로 올린 PDF는 PORTFOLIO 문서 한 건으로 바꾼다. + public MaterialsResponse getMaterials(String token) { + return MaterialsResponse.from(authServerClient.getMaterials(token)); + } + // S3 file 업로드 private String uploadFile(MultipartFile file) throws IOException { // 원본 파일명과 확장자 추출 diff --git a/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java b/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java index 7757d4da..42f08fe8 100644 --- a/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java +++ b/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java @@ -5,6 +5,7 @@ import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; import repit.repit_api_server.domain.metadata.dto.request.MetaDataRequest; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.global.common.ApiResponse; import repit.repit_api_server.global.response.UserResponse; @@ -18,6 +19,9 @@ void createMetaData(@RequestHeader("Authorization") String authorization, @GetExchange("/api/v1/auth/getMetaData") ApiResponse getMetaData(@RequestHeader("Authorization") String authorization); + @GetExchange("/api/v1/auth/materials") + ApiResponse getMaterials(@RequestHeader("Authorization") String authorization); + @GetExchange("/api/v1/users/me") ApiResponse getUser(@RequestHeader("Authorization") String authorization); } diff --git a/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java b/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java index 9f4f0cc0..8891b46f 100644 --- a/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java +++ b/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java @@ -4,6 +4,7 @@ import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; import repit.repit_api_server.domain.metadata.dto.request.MetaDataRequest; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; import repit.repit_api_server.global.common.ApiResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.global.response.UserResponse; @@ -36,6 +37,15 @@ public MetaDataResponse getMetaData(String authorization) { return response == null ? null : response.getData(); } + // Auth 서버에서 직무·문서·GitHub·보완 입력 반환 + public AuthMaterialsResponse getMaterials(String authorization) { + ApiResponse response = executor.execute(SERVER_NAME, + () -> authServerApi.getMaterials(authorization), + this::resolveMessage, true); + + return response == null ? null : response.getData(); + } + public UserResponse getUser(String authorization) { ApiResponse response = executor.execute(SERVER_NAME, () -> authServerApi.getUser(authorization), diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java index 43c64f01..72259add 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java @@ -5,6 +5,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; 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.MetaService; @@ -55,4 +56,14 @@ class MetaDataControllerTest { assertThat(controller.isGit(USER).getBody()).isTrue(); assertThat(controller.isPortfolio(USER).getBody()).isTrue(); } + + @Test + void 저장된_직무와_자료를_사용자_토큰으로_받아_그대로_돌려준다() { + MaterialsResponse materials = new MaterialsResponse("QA_ENGINEER", + List.of(new MaterialsResponse.MaterialDocument(0, "TEST_PLAN", "https://s3/plan.pdf", "plan.pdf", 12)), + List.of(), List.of()); + when(metaService.getMaterials("Bearer token")).thenReturn(materials); + + assertThat(controller.getMaterials(USER).getBody()).isSameAs(materials); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java b/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java new file mode 100644 index 00000000..b8ca6e3c --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsResponseTest.java @@ -0,0 +1,75 @@ +package repit.repit_api_server.domain.metadata.dto.response; + +import org.junit.jupiter.api.Test; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse.MaterialDocument; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse.Supplement; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 인증 서버의 저장값(C-2)을 마이페이지 응답(C-1)으로 바꾸는 규칙. + */ +class MaterialsResponseTest { + + @Test + void 새_형식으로_저장한_자료는_접두사를_떼고_문서_순서대로_index를_붙인다() { + AuthMaterialsResponse auth = new AuthMaterialsResponse( + "MAJOR_PRODUCT_MANAGER", + List.of(new DocumentEntry("PRD", "https://s3/prd.pdf", "회원가입-PRD.pdf", 9), + new DocumentEntry("POLICY_DOC", "https://s3/policy.pdf", "정책서.pdf", 6)), + List.of(), + List.of(new SupplementEntry("회원가입 개편", 0, "이탈 줄이기", "정책 정리", "단계 축소", null)), + null); + + MaterialsResponse response = MaterialsResponse.from(auth); + + assertThat(response.jobRole()).isEqualTo("PRODUCT_MANAGER"); + assertThat(response.documents()).containsExactly( + new MaterialDocument(0, "PRD", "https://s3/prd.pdf", "회원가입-PRD.pdf", 9), + new MaterialDocument(1, "POLICY_DOC", "https://s3/policy.pdf", "정책서.pdf", 6)); + assertThat(response.gitUrls()).isEmpty(); + assertThat(response.supplements()).containsExactly( + new Supplement("회원가입 개편", 0, "이탈 줄이기", "정책 정리", "단계 축소", null)); + } + + @Test + void 옛_사용자의_PDF는_파일명과_쪽수가_없는_PORTFOLIO_문서_한_건이다() { + AuthMaterialsResponse auth = new AuthMaterialsResponse( + null, List.of(), List.of("https://github.com/a/repo"), List.of(), "https://s3/legacy.pdf"); + + MaterialsResponse response = MaterialsResponse.from(auth); + + assertThat(response.jobRole()).isNull(); + assertThat(response.documents()).containsExactly( + new MaterialDocument(0, "PORTFOLIO", "https://s3/legacy.pdf", null, null)); + assertThat(response.gitUrls()).containsExactly("https://github.com/a/repo"); + } + + @Test + void 아무것도_저장하지_않았으면_직무는_null이고_목록은_비어_있다() { + AuthMaterialsResponse auth = new AuthMaterialsResponse(null, null, null, null, null); + + MaterialsResponse response = MaterialsResponse.from(auth); + + assertThat(response).isEqualTo(MaterialsResponse.empty()); + assertThat(response.documents()).isEmpty(); + assertThat(response.gitUrls()).isEmpty(); + assertThat(response.supplements()).isEmpty(); + } + + @Test + void 빈_문자열_PDF_주소는_문서로_보지_않는다() { + AuthMaterialsResponse auth = new AuthMaterialsResponse("MAJOR_BACKEND", List.of(), List.of(), List.of(), " "); + + assertThat(MaterialsResponse.from(auth).documents()).isEmpty(); + } + + @Test + void 인증_서버가_빈_응답을_주면_빈_자료다() { + assertThat(MaterialsResponse.from(null)).isEqualTo(MaterialsResponse.empty()); + } +} diff --git a/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java b/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java new file mode 100644 index 00000000..2fbd7f39 --- /dev/null +++ b/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java @@ -0,0 +1,88 @@ +package repit.repit_api_server.global.client; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.client.RestClient; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; +import repit.repit_api_server.global.config.HttpInterfaceConfig; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 인증 서버 {@code GET /api/v1/auth/materials}(계약 C-2)의 실제 응답 모양을 설정으로 만든 클라이언트가 읽는지 확인한다. + * 인증 서버는 응답에 message·null 필드를 함께 보내므로 그대로 흉내 낸다. + */ +class AuthServerMaterialsWiringTest { + + private static final String RESPONSE_BODY = """ + {"success": true, "message": null, "data": { + "major": "MAJOR_QA_ENGINEER", + "documents": [{"type": "TEST_PLAN", "fileUrl": "https://s3/plan.pdf", "fileName": "결제-테스트계획.pdf", "pageCount": 12}], + "gitUrls": ["https://github.com/a/qa"], + "supplements": [{"title": "결제 QA", "documentIndex": 0, "goal": "중복 결제 막기", "role": null, "decision": null, "outcome": null}], + "legacyPortfolio": null}} + """; + + private HttpServer authServer; + private final AtomicReference receivedAuthorization = new AtomicReference<>(); + + @BeforeEach + void 가짜_인증_서버를_띄운다() throws Exception { + authServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + authServer.createContext("/api/v1/auth/materials", exchange -> { + receivedAuthorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = RESPONSE_BODY.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + authServer.start(); + } + + @AfterEach + void 정리한다() { + authServer.stop(0); + } + + @Test + void 사용자_토큰을_넘기고_직무_문서_GitHub_보완_입력을_읽는다() { + contextRunner().run(context -> { + AuthServerClient client = new AuthServerClient(context.getBean(AuthServerApi.class), new ExternalApiExecutor()); + + AuthMaterialsResponse response = client.getMaterials("Bearer user-token"); + + assertThat(receivedAuthorization.get()).isEqualTo("Bearer user-token"); + assertThat(response.major()).isEqualTo("MAJOR_QA_ENGINEER"); + assertThat(response.documents()).containsExactly( + new DocumentEntry("TEST_PLAN", "https://s3/plan.pdf", "결제-테스트계획.pdf", 12)); + assertThat(response.gitUrls()).containsExactly("https://github.com/a/qa"); + assertThat(response.supplements()).containsExactly( + new SupplementEntry("결제 QA", 0, "중복 결제 막기", null, null, null)); + assertThat(response.legacyPortfolio()).isNull(); + }); + } + + private ApplicationContextRunner contextRunner() { + String baseUrl = "http://127.0.0.1:" + authServer.getAddress().getPort(); + return new ApplicationContextRunner() + .withBean("restClientBuilder", RestClient.Builder.class, RestClient::builder) + .withUserConfiguration(HttpInterfaceConfig.class) + .withPropertyValues( + "ai-server.base-url=" + baseUrl, + "auth-server.base-url=" + baseUrl, + "chat-server.base-url=" + baseUrl + ); + } +}