From ee15086a5dd53d42dabc59d95312dae7db3a7d02 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 12:38:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20AI=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=C2=B7=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=20=EB=AA=A9=EB=A1=9D=20=EA=B8=B0=EC=88=98=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portfolio_draft_history 테이블 추가 (V5 Flyway 마이그레이션) - PortfolioDraftHistory 엔티티, 레포지토리 추가 - 포트폴리오 생성 시 입력 정보와 초안 내용을 이력 테이블에 저장 - GET /api/ai/portfolio-drafts/history: 생성 이력 목록 조회 (페이징) - GET /api/ai/portfolio-drafts/history/{historyId}: 이력 단건 상세 조회 - CourseListResponse에 trprDegr(기수) 필드 추가 및 from() 팩토리 개선 Closes #108 --- .../controller/PortfolioDraftController.java | 23 +++ .../PortfolioDraftHistoryDetailResponse.java | 43 ++++++ .../dto/PortfolioDraftHistoryResponse.java | 22 +++ .../entity/PortfolioDraftHistory.java | 133 ++++++++++++++++++ .../PortfolioDraftHistoryRepository.java | 14 ++ .../service/PortfolioDraftService.java | 31 ++++ .../domain/course/dto/CourseListResponse.java | 14 +- .../V5__add_portfolio_draft_history.sql | 22 +++ .../service/PortfolioDraftServiceTest.java | 8 +- 9 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryResponse.java create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/repository/PortfolioDraftHistoryRepository.java create mode 100644 src/main/resources/db/migration/V5__add_portfolio_draft_history.sql diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java index 10636cb..63acd9d 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java @@ -1,13 +1,21 @@ package com.bootsignal.domain.ai.portfolio.controller; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftCreateRequest; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftHistoryDetailResponse; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftHistoryResponse; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftResponse; import com.bootsignal.domain.ai.portfolio.service.PortfolioDraftService; +import com.bootsignal.global.dto.PageResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -22,4 +30,19 @@ public class PortfolioDraftController { public PortfolioDraftResponse createDraft(@RequestBody @Valid PortfolioDraftCreateRequest request) { return portfolioDraftService.createDraft(request); } + + @GetMapping("/history") + public PageResponse getHistory( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return portfolioDraftService.getHistory( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + } + + @GetMapping("/history/{historyId}") + public PortfolioDraftHistoryDetailResponse getHistoryDetail(@PathVariable Long historyId) { + return portfolioDraftService.getHistoryDetail(historyId); + } } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java new file mode 100644 index 0000000..ee5f184 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java @@ -0,0 +1,43 @@ +package com.bootsignal.domain.ai.portfolio.dto; + +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; +import java.time.LocalDateTime; +import java.util.List; + +public record PortfolioDraftHistoryDetailResponse( + Long historyId, + String executionId, + // 사용자 입력 정보 + String targetJob, + List skills, + List projects, + String education, + String careerSummary, + String tone, + // AI 생성 초안 + String introduction, + List coreCompetencies, + List projectDescriptions, + String techStackSummary, + List improvementSuggestions, + LocalDateTime createdAt +) { + public static PortfolioDraftHistoryDetailResponse from(PortfolioDraftHistory history) { + return new PortfolioDraftHistoryDetailResponse( + history.getId(), + history.getExecutionId(), + history.getTargetJob(), + history.getSkillList(), + history.getProjectList(), + history.getEducation(), + history.getCareerSummary(), + history.getTone(), + history.getIntroduction(), + history.getCoreCompetencyList(), + history.getProjectDescriptionList(), + history.getTechStackSummary(), + history.getImprovementSuggestionList(), + history.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryResponse.java b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryResponse.java new file mode 100644 index 0000000..c56c893 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryResponse.java @@ -0,0 +1,22 @@ +package com.bootsignal.domain.ai.portfolio.dto; + +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; +import java.time.LocalDateTime; + +public record PortfolioDraftHistoryResponse( + Long historyId, + String executionId, + String targetJob, + String tone, + LocalDateTime createdAt +) { + public static PortfolioDraftHistoryResponse from(PortfolioDraftHistory history) { + return new PortfolioDraftHistoryResponse( + history.getId(), + history.getExecutionId(), + history.getTargetJob(), + history.getTone(), + history.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java b/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java new file mode 100644 index 0000000..617dc7c --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java @@ -0,0 +1,133 @@ +package com.bootsignal.domain.ai.portfolio.entity; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftContent; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftCreateRequest; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftProject; +import com.bootsignal.global.entity.BaseEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "portfolio_draft_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PortfolioDraftHistory extends BaseEntity { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "execution_id", nullable = false, length = 36) + private String executionId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "target_job", nullable = false, length = 100) + private String targetJob; + + @Column(name = "skills", nullable = false, columnDefinition = "text") + private String skills; + + @Column(name = "projects", nullable = false, columnDefinition = "text") + private String projects; + + @Column(name = "education", length = 500) + private String education; + + @Column(name = "career_summary", columnDefinition = "text") + private String careerSummary; + + @Column(name = "tone", nullable = false, length = 20) + private String tone; + + @Column(name = "introduction", columnDefinition = "text") + private String introduction; + + @Column(name = "core_competencies", columnDefinition = "text") + private String coreCompetencies; + + @Column(name = "project_descriptions", columnDefinition = "text") + private String projectDescriptions; + + @Column(name = "tech_stack_summary", columnDefinition = "text") + private String techStackSummary; + + @Column(name = "improvement_suggestions", columnDefinition = "text") + private String improvementSuggestions; + + public static PortfolioDraftHistory of( + String executionId, + Long userId, + PortfolioDraftCreateRequest request, + PortfolioDraftContent content + ) { + PortfolioDraftHistory history = new PortfolioDraftHistory(); + history.executionId = executionId; + history.userId = userId; + history.targetJob = request.targetJob(); + history.skills = toJson(request.skills()); + history.projects = toJson(request.projects()); + history.education = request.education(); + history.careerSummary = request.careerSummary(); + history.tone = request.resolvedTone().name(); + history.introduction = content.introduction(); + history.coreCompetencies = toJson(content.coreCompetencies()); + history.projectDescriptions = toJson(content.projectDescriptions()); + history.techStackSummary = content.techStackSummary(); + history.improvementSuggestions = toJson(content.improvementSuggestions()); + return history; + } + + public List getSkillList() { + return fromJson(skills, new TypeReference<>() {}); + } + + public List getProjectList() { + return fromJson(projects, new TypeReference<>() {}); + } + + public List getCoreCompetencyList() { + return fromJson(coreCompetencies, new TypeReference<>() {}); + } + + public List getProjectDescriptionList() { + return fromJson(projectDescriptions, new TypeReference<>() {}); + } + + public List getImprovementSuggestionList() { + return fromJson(improvementSuggestions, new TypeReference<>() {}); + } + + private static String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 직렬화 실패", e); + } + } + + private static T fromJson(String json, TypeReference type) { + if (json == null) { + return null; + } + try { + return MAPPER.readValue(json, type); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 역직렬화 실패", e); + } + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/repository/PortfolioDraftHistoryRepository.java b/src/main/java/com/bootsignal/domain/ai/portfolio/repository/PortfolioDraftHistoryRepository.java new file mode 100644 index 0000000..02b7e59 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/repository/PortfolioDraftHistoryRepository.java @@ -0,0 +1,14 @@ +package com.bootsignal.domain.ai.portfolio.repository; + +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PortfolioDraftHistoryRepository extends JpaRepository { + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java index 88ce404..d071543 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java @@ -6,25 +6,35 @@ import com.bootsignal.domain.ai.harness.AgentHarness; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftContent; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftCreateRequest; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftHistoryDetailResponse; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftHistoryResponse; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftResponse; +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; +import com.bootsignal.domain.ai.portfolio.repository.PortfolioDraftHistoryRepository; import com.bootsignal.domain.user.entity.User; import com.bootsignal.domain.user.repository.UserRepository; +import com.bootsignal.global.dto.PageResponse; import com.bootsignal.global.exception.BootSignalException; import com.bootsignal.global.exception.ErrorCode; import com.bootsignal.global.security.SecurityUtil; import java.util.LinkedHashMap; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class PortfolioDraftService { // 인증된 사용자 정보를 실행 컨텍스트에 담아 포트폴리오 Agent 실행을 요청한다. private final AgentHarness agentHarness; private final UserRepository userRepository; + private final PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + @Transactional public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { User user = getAuthenticatedUser(); AgentExecutionResult result = agentHarness.execute(AgentExecutionContext.of( @@ -42,9 +52,30 @@ public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { if (!(draft instanceof PortfolioDraftContent content)) { throw new BootSignalException(ErrorCode.AI_OUTPUT_INVALID, "포트폴리오 초안 결과를 찾을 수 없습니다."); } + + portfolioDraftHistoryRepository.save( + PortfolioDraftHistory.of(result.executionId().toString(), user.getId(), request, content) + ); + return PortfolioDraftResponse.from(result.executionId(), content); } + public PageResponse getHistory(Pageable pageable) { + User user = getAuthenticatedUser(); + return PageResponse.from( + portfolioDraftHistoryRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable) + .map(PortfolioDraftHistoryResponse::from) + ); + } + + public PortfolioDraftHistoryDetailResponse getHistoryDetail(Long historyId) { + User user = getAuthenticatedUser(); + PortfolioDraftHistory history = portfolioDraftHistoryRepository + .findByIdAndUserId(historyId, user.getId()) + .orElseThrow(() -> new BootSignalException(ErrorCode.NOT_FOUND)); + return PortfolioDraftHistoryDetailResponse.from(history); + } + private Map toInput(User user, PortfolioDraftCreateRequest request) { Map input = new LinkedHashMap<>(); input.put("targetJob", request.targetJob()); diff --git a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java index 6e01648..94f5137 100644 --- a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java +++ b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java @@ -11,6 +11,7 @@ public record CourseListResponse( Long courseId, Long courseSessionId, String trprId, + Integer trprDegr, String title, String institutionName, String trngAreaCd, @@ -48,6 +49,7 @@ public CourseListResponse( id, null, trprId, + null, title, institutionName, trngAreaCd, @@ -77,7 +79,10 @@ public static CourseListResponse from(Course course, CourseSession repSession) { : null; return new CourseListResponse( course.getId(), + course.getId(), + repSession != null ? repSession.getId() : null, course.getTrprId(), + repSession != null ? repSession.getTrprDegr() : null, course.getTitle(), course.getSubTitle(), // 기관명은 subTitle에 비정규화됨 course.getTrngAreaCd(), @@ -87,7 +92,13 @@ public static CourseListResponse from(Course course, CourseSession repSession) { repSession != null ? repSession.getTotalTrainingDays() : null, repSession != null ? repSession.getTotalTrainingHours() : null, course.getNcsName(), - profileImageUrl + profileImageUrl, + repSession != null ? repSession.getTraStartDate() : null, + repSession != null ? repSession.getTraEndDate() : null, + repSession != null ? repSession.getEiEmplRate3() : null, + repSession != null ? repSession.getEiEmplRate6() : null, + null, + repSession != null ? repSession.getEmploymentRate() : null ); } @@ -101,6 +112,7 @@ public static CourseListResponse from(CourseSession session, BigDecimal reviewRa course != null ? course.getId() : null, session.getId(), session.getTrprId(), + session.getTrprDegr(), course != null ? course.getTitle() : null, course != null ? course.getSubTitle() : null, course != null ? course.getTrngAreaCd() : null, diff --git a/src/main/resources/db/migration/V5__add_portfolio_draft_history.sql b/src/main/resources/db/migration/V5__add_portfolio_draft_history.sql new file mode 100644 index 0000000..8b7c0d3 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_portfolio_draft_history.sql @@ -0,0 +1,22 @@ +CREATE TABLE `portfolio_draft_history` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `execution_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `user_id` bigint NOT NULL, + `target_job` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `skills` text COLLATE utf8mb4_unicode_ci NOT NULL, + `projects` text COLLATE utf8mb4_unicode_ci NOT NULL, + `education` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `career_summary` text COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tone` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `introduction` text COLLATE utf8mb4_unicode_ci, + `core_competencies` text COLLATE utf8mb4_unicode_ci, + `project_descriptions` text COLLATE utf8mb4_unicode_ci, + `tech_stack_summary` text COLLATE utf8mb4_unicode_ci, + `improvement_suggestions` text COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_portfolio_draft_history_execution_id` (`execution_id`), + KEY `idx_portfolio_draft_history_user_id` (`user_id`), + CONSTRAINT `fk_portfolio_draft_history_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java index b934acb..a6555ce 100644 --- a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java +++ b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java @@ -17,6 +17,7 @@ import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftResponse; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftTone; import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; +import com.bootsignal.domain.ai.portfolio.repository.PortfolioDraftHistoryRepository; import com.bootsignal.domain.user.entity.User; import com.bootsignal.domain.user.repository.UserRepository; import com.bootsignal.global.exception.BootSignalException; @@ -43,6 +44,9 @@ class PortfolioDraftServiceTest { @Mock private UserRepository userRepository; + @Mock + private PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + @AfterEach void clearSecurityContext() { SecurityContextHolder.clearContext(); @@ -52,7 +56,7 @@ void clearSecurityContext() { void createDraftRunsPortfolioAgentForAuthenticatedUser() { User user = User.signupLocal("writer@example.com", "encoded-password", "writer"); setAuthentication(user.getEmail()); - PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository); + PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository); PortfolioDraftContent content = new PortfolioDraftContent( "백엔드 개발자 포트폴리오 소개입니다.", List.of("API 구현"), @@ -88,7 +92,7 @@ void createDraftRunsPortfolioAgentForAuthenticatedUser() { @Test void createDraftThrowsUnauthorizedWithoutAuthentication() { - PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository); + PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository); assertThatThrownBy(() -> service.createDraft(request())) .isInstanceOf(BootSignalException.class) From 70789d58e1db146ea32488456633df5468035e5e Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 12:41:15 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/ai/portfolio-drafts/history/{historyId} - 본인 이력만 삭제 가능, 타인 이력 접근 시 404 반환 --- .../portfolio/controller/PortfolioDraftController.java | 9 +++++++++ .../ai/portfolio/service/PortfolioDraftService.java | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java index 63acd9d..4e4f2b5 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java @@ -10,12 +10,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @@ -45,4 +48,10 @@ public PageResponse getHistory( public PortfolioDraftHistoryDetailResponse getHistoryDetail(@PathVariable Long historyId) { return portfolioDraftService.getHistoryDetail(historyId); } + + @DeleteMapping("/history/{historyId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteHistory(@PathVariable Long historyId) { + portfolioDraftService.deleteHistory(historyId); + } } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java index d071543..8926ff3 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java @@ -76,6 +76,15 @@ public PortfolioDraftHistoryDetailResponse getHistoryDetail(Long historyId) { return PortfolioDraftHistoryDetailResponse.from(history); } + @Transactional + public void deleteHistory(Long historyId) { + User user = getAuthenticatedUser(); + PortfolioDraftHistory history = portfolioDraftHistoryRepository + .findByIdAndUserId(historyId, user.getId()) + .orElseThrow(() -> new BootSignalException(ErrorCode.NOT_FOUND)); + portfolioDraftHistoryRepository.delete(history); + } + private Map toInput(User user, PortfolioDraftCreateRequest request) { Map input = new LinkedHashMap<>(); input.put("targetJob", request.targetJob()); From 799d7d0f7aa7164c4ca640cc7d218d11d93ccb76 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 13:35:42 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A7=80=EC=A0=81=20=EC=82=AC=ED=95=AD=208?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [Fix 1] 이력 저장 실패가 AI 응답 반환을 막지 않도록 save() 예외 흡수 처리 - [Fix 2·4·5] PortfolioDraftHistory의 자체 ObjectMapper 제거 → @Convert 패턴으로 교체 - List 필드: 기존 StringListConverter 재사용 - projects 필드: PortfolioProjectExperienceRequestListConverter 신규 추가 - projectDescriptions 필드: PortfolioDraftProjectListConverter 신규 추가 - [Fix 3] 이력 저장 호출 누락 시 감지하도록 테스트에 verify(save) 추가 - [Fix 4] PortfolioDraftHistoryDetailResponse.projects 타입을 List에서 List로 변경하여 타입 안전성 복원 - [Fix 6] 컨트롤러의 중복 Sort 제거 — 정렬은 레포지토리 메서드명으로 단일 관리 - [Fix 7] CourseListResponse.from(Course, CourseSession)에 reviewRating 파라미터 추가 하여 null 고정 문제 해소 - [Fix 8] CourseListResponse.from(Course, CourseSession)에서 id와 courseId 불일치 수정 — id는 세션이 있으면 세션 PK, 없으면 과정 PK로 from(CourseSession, BigDecimal)와 일관성 유지 --- .../controller/PortfolioDraftController.java | 3 +- .../PortfolioDraftProjectListConverter.java | 41 ++++++++++ ...ProjectExperienceRequestListConverter.java | 41 ++++++++++ .../PortfolioDraftHistoryDetailResponse.java | 14 ++-- .../entity/PortfolioDraftHistory.java | 74 +++++-------------- .../service/PortfolioDraftService.java | 14 +++- .../domain/course/dto/CourseListResponse.java | 12 ++- .../service/PortfolioDraftServiceTest.java | 2 + 8 files changed, 126 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java index 4e4f2b5..41cbfbd 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java @@ -9,7 +9,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -40,7 +39,7 @@ public PageResponse getHistory( @RequestParam(defaultValue = "10") int size ) { return portfolioDraftService.getHistory( - PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + PageRequest.of(page, size) ); } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java new file mode 100644 index 0000000..de2795f --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java @@ -0,0 +1,41 @@ +package com.bootsignal.domain.ai.portfolio.converter; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftProject; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class PortfolioDraftProjectListConverter + implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> TYPE = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalStateException("List 직렬화 실패", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) { + return List.of(); + } + try { + return MAPPER.readValue(dbData, TYPE); + } catch (JsonProcessingException e) { + throw new IllegalStateException("List 역직렬화 실패", e); + } + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java new file mode 100644 index 0000000..601b924 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java @@ -0,0 +1,41 @@ +package com.bootsignal.domain.ai.portfolio.converter; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class PortfolioProjectExperienceRequestListConverter + implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> TYPE = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalStateException("List 직렬화 실패", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) { + return List.of(); + } + try { + return MAPPER.readValue(dbData, TYPE); + } catch (JsonProcessingException e) { + throw new IllegalStateException("List 역직렬화 실패", e); + } + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java index ee5f184..1696ab3 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java @@ -7,14 +7,12 @@ public record PortfolioDraftHistoryDetailResponse( Long historyId, String executionId, - // 사용자 입력 정보 String targetJob, List skills, - List projects, + List projects, String education, String careerSummary, String tone, - // AI 생성 초안 String introduction, List coreCompetencies, List projectDescriptions, @@ -27,16 +25,16 @@ public static PortfolioDraftHistoryDetailResponse from(PortfolioDraftHistory his history.getId(), history.getExecutionId(), history.getTargetJob(), - history.getSkillList(), - history.getProjectList(), + history.getSkills(), + history.getProjects(), history.getEducation(), history.getCareerSummary(), history.getTone(), history.getIntroduction(), - history.getCoreCompetencyList(), - history.getProjectDescriptionList(), + history.getCoreCompetencies(), + history.getProjectDescriptions(), history.getTechStackSummary(), - history.getImprovementSuggestionList(), + history.getImprovementSuggestions(), history.getCreatedAt() ); } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java b/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java index 617dc7c..8efc7c6 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java @@ -1,13 +1,15 @@ package com.bootsignal.domain.ai.portfolio.entity; +import com.bootsignal.domain.ai.portfolio.converter.PortfolioDraftProjectListConverter; +import com.bootsignal.domain.ai.portfolio.converter.PortfolioProjectExperienceRequestListConverter; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftContent; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftCreateRequest; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftProject; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; +import com.bootsignal.global.converter.StringListConverter; import com.bootsignal.global.entity.BaseEntity; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -24,8 +26,6 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PortfolioDraftHistory extends BaseEntity { - private static final ObjectMapper MAPPER = new ObjectMapper(); - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -39,11 +39,13 @@ public class PortfolioDraftHistory extends BaseEntity { @Column(name = "target_job", nullable = false, length = 100) private String targetJob; + @Convert(converter = StringListConverter.class) @Column(name = "skills", nullable = false, columnDefinition = "text") - private String skills; + private List skills; + @Convert(converter = PortfolioProjectExperienceRequestListConverter.class) @Column(name = "projects", nullable = false, columnDefinition = "text") - private String projects; + private List projects; @Column(name = "education", length = 500) private String education; @@ -57,17 +59,20 @@ public class PortfolioDraftHistory extends BaseEntity { @Column(name = "introduction", columnDefinition = "text") private String introduction; + @Convert(converter = StringListConverter.class) @Column(name = "core_competencies", columnDefinition = "text") - private String coreCompetencies; + private List coreCompetencies; + @Convert(converter = PortfolioDraftProjectListConverter.class) @Column(name = "project_descriptions", columnDefinition = "text") - private String projectDescriptions; + private List projectDescriptions; @Column(name = "tech_stack_summary", columnDefinition = "text") private String techStackSummary; + @Convert(converter = StringListConverter.class) @Column(name = "improvement_suggestions", columnDefinition = "text") - private String improvementSuggestions; + private List improvementSuggestions; public static PortfolioDraftHistory of( String executionId, @@ -79,55 +84,16 @@ public static PortfolioDraftHistory of( history.executionId = executionId; history.userId = userId; history.targetJob = request.targetJob(); - history.skills = toJson(request.skills()); - history.projects = toJson(request.projects()); + history.skills = request.skills(); + history.projects = request.projects(); history.education = request.education(); history.careerSummary = request.careerSummary(); history.tone = request.resolvedTone().name(); history.introduction = content.introduction(); - history.coreCompetencies = toJson(content.coreCompetencies()); - history.projectDescriptions = toJson(content.projectDescriptions()); + history.coreCompetencies = content.coreCompetencies(); + history.projectDescriptions = content.projectDescriptions(); history.techStackSummary = content.techStackSummary(); - history.improvementSuggestions = toJson(content.improvementSuggestions()); + history.improvementSuggestions = content.improvementSuggestions(); return history; } - - public List getSkillList() { - return fromJson(skills, new TypeReference<>() {}); - } - - public List getProjectList() { - return fromJson(projects, new TypeReference<>() {}); - } - - public List getCoreCompetencyList() { - return fromJson(coreCompetencies, new TypeReference<>() {}); - } - - public List getProjectDescriptionList() { - return fromJson(projectDescriptions, new TypeReference<>() {}); - } - - public List getImprovementSuggestionList() { - return fromJson(improvementSuggestions, new TypeReference<>() {}); - } - - private static String toJson(Object value) { - try { - return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { - throw new IllegalStateException("JSON 직렬화 실패", e); - } - } - - private static T fromJson(String json, TypeReference type) { - if (json == null) { - return null; - } - try { - return MAPPER.readValue(json, type); - } catch (JsonProcessingException e) { - throw new IllegalStateException("JSON 역직렬화 실패", e); - } - } } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java index 8926ff3..362d1a9 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java @@ -20,16 +20,17 @@ import java.util.LinkedHashMap; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PortfolioDraftService { - // 인증된 사용자 정보를 실행 컨텍스트에 담아 포트폴리오 Agent 실행을 요청한다. private final AgentHarness agentHarness; private final UserRepository userRepository; private final PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; @@ -53,9 +54,14 @@ public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { throw new BootSignalException(ErrorCode.AI_OUTPUT_INVALID, "포트폴리오 초안 결과를 찾을 수 없습니다."); } - portfolioDraftHistoryRepository.save( - PortfolioDraftHistory.of(result.executionId().toString(), user.getId(), request, content) - ); + // 이력 저장 실패가 AI 응답 반환을 막지 않도록 예외를 흡수한다. + try { + portfolioDraftHistoryRepository.save( + PortfolioDraftHistory.of(result.executionId().toString(), user.getId(), request, content) + ); + } catch (Exception e) { + log.warn("포트폴리오 이력 저장 실패 executionId={}", result.executionId(), e); + } return PortfolioDraftResponse.from(result.executionId(), content); } diff --git a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java index 94f5137..8486941 100644 --- a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java +++ b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java @@ -69,16 +69,14 @@ public CourseListResponse( ); } - public static CourseListResponse from(Course course) { - return from(course, null); - } - - public static CourseListResponse from(Course course, CourseSession repSession) { + public static CourseListResponse from(Course course, CourseSession repSession, BigDecimal reviewRating) { String profileImageUrl = course.getInstitution() != null ? course.getInstitution().getProfileImageUrl() : null; + // id는 세션이 있으면 세션 PK, 없으면 과정 PK — from(CourseSession, BigDecimal) 와 동일한 규칙 + Long id = repSession != null ? repSession.getId() : course.getId(); return new CourseListResponse( - course.getId(), + id, course.getId(), repSession != null ? repSession.getId() : null, course.getTrprId(), @@ -97,7 +95,7 @@ public static CourseListResponse from(Course course, CourseSession repSession) { repSession != null ? repSession.getTraEndDate() : null, repSession != null ? repSession.getEiEmplRate3() : null, repSession != null ? repSession.getEiEmplRate6() : null, - null, + reviewRating, repSession != null ? repSession.getEmploymentRate() : null ); } diff --git a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java index a6555ce..fe0c30b 100644 --- a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java +++ b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java @@ -17,6 +17,7 @@ import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftResponse; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftTone; import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; import com.bootsignal.domain.ai.portfolio.repository.PortfolioDraftHistoryRepository; import com.bootsignal.domain.user.entity.User; import com.bootsignal.domain.user.repository.UserRepository; @@ -88,6 +89,7 @@ void createDraftRunsPortfolioAgentForAuthenticatedUser() { assertThat(context.inputSummary()).contains("목표 직무: 백엔드 개발자"); assertThat(context.input()).containsEntry("targetJob", "백엔드 개발자"); assertThat(context.input()).containsEntry("tone", PortfolioDraftTone.PROFESSIONAL); + verify(portfolioDraftHistoryRepository).save(any(PortfolioDraftHistory.class)); } @Test From eac9526417e8c041fbc88a553037dad7526b57e8 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 13:49:30 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=202=EC=B0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A7=80=EC=A0=81=20=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?3=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PortfolioDraftHistoryWriter(@Transactional REQUIRES_NEW) 추출로 이력 저장 실패 시 외부 트랜잭션이 rollback-only로 오염되던 문제 해결 - AbstractJsonListConverter 도입으로 세 Converter 중복 제거 및 ObjectMapper 인스턴스 단일화 - CourseListResponse 12-arg 편의 생성자 삭제 (테스트 전용 dead code) 및 CourseControllerTest를 정식 생성자 사용으로 전환 --- .../PortfolioDraftProjectListConverter.java | 31 ++------------ ...ProjectExperienceRequestListConverter.java | 31 ++------------ .../service/PortfolioDraftHistoryWriter.java | 26 ++++++++++++ .../service/PortfolioDraftService.java | 6 +-- .../domain/course/dto/CourseListResponse.java | 40 ------------------- .../converter/AbstractJsonListConverter.java | 38 ++++++++++++++++++ .../global/converter/StringListConverter.java | 29 ++------------ .../service/PortfolioDraftServiceTest.java | 11 +++-- .../controller/CourseControllerTest.java | 9 +++-- 9 files changed, 90 insertions(+), 131 deletions(-) create mode 100644 src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftHistoryWriter.java create mode 100644 src/main/java/com/bootsignal/global/converter/AbstractJsonListConverter.java diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java index de2795f..688a255 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java @@ -1,41 +1,18 @@ package com.bootsignal.domain.ai.portfolio.converter; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftProject; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.bootsignal.global.converter.AbstractJsonListConverter; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import java.util.List; @Converter -public class PortfolioDraftProjectListConverter - implements AttributeConverter, String> { +public class PortfolioDraftProjectListConverter extends AbstractJsonListConverter { - private static final ObjectMapper MAPPER = new ObjectMapper(); private static final TypeReference> TYPE = new TypeReference<>() {}; @Override - public String convertToDatabaseColumn(List attribute) { - if (attribute == null) { - return null; - } - try { - return MAPPER.writeValueAsString(attribute); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 직렬화 실패", e); - } - } - - @Override - public List convertToEntityAttribute(String dbData) { - if (dbData == null) { - return List.of(); - } - try { - return MAPPER.readValue(dbData, TYPE); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 역직렬화 실패", e); - } + protected TypeReference> typeReference() { + return TYPE; } } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java index 601b924..30e0cab 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java @@ -1,41 +1,18 @@ package com.bootsignal.domain.ai.portfolio.converter; import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.bootsignal.global.converter.AbstractJsonListConverter; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import java.util.List; @Converter -public class PortfolioProjectExperienceRequestListConverter - implements AttributeConverter, String> { +public class PortfolioProjectExperienceRequestListConverter extends AbstractJsonListConverter { - private static final ObjectMapper MAPPER = new ObjectMapper(); private static final TypeReference> TYPE = new TypeReference<>() {}; @Override - public String convertToDatabaseColumn(List attribute) { - if (attribute == null) { - return null; - } - try { - return MAPPER.writeValueAsString(attribute); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 직렬화 실패", e); - } - } - - @Override - public List convertToEntityAttribute(String dbData) { - if (dbData == null) { - return List.of(); - } - try { - return MAPPER.readValue(dbData, TYPE); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 역직렬화 실패", e); - } + protected TypeReference> typeReference() { + return TYPE; } } diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftHistoryWriter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftHistoryWriter.java new file mode 100644 index 0000000..ed4a074 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftHistoryWriter.java @@ -0,0 +1,26 @@ +package com.bootsignal.domain.ai.portfolio.service; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftContent; +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftCreateRequest; +import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; +import com.bootsignal.domain.ai.portfolio.repository.PortfolioDraftHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PortfolioDraftHistoryWriter { + + private final PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + + // 외부 트랜잭션과 독립된 새 트랜잭션으로 실행 — 저장 실패 시 외부 트랜잭션에 영향을 주지 않는다. + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(String executionId, Long userId, + PortfolioDraftCreateRequest request, PortfolioDraftContent content) { + portfolioDraftHistoryRepository.save( + PortfolioDraftHistory.of(executionId, userId, request, content) + ); + } +} diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java index 362d1a9..3f4b0de 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftService.java @@ -34,6 +34,7 @@ public class PortfolioDraftService { private final AgentHarness agentHarness; private final UserRepository userRepository; private final PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + private final PortfolioDraftHistoryWriter portfolioDraftHistoryWriter; @Transactional public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { @@ -54,11 +55,8 @@ public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { throw new BootSignalException(ErrorCode.AI_OUTPUT_INVALID, "포트폴리오 초안 결과를 찾을 수 없습니다."); } - // 이력 저장 실패가 AI 응답 반환을 막지 않도록 예외를 흡수한다. try { - portfolioDraftHistoryRepository.save( - PortfolioDraftHistory.of(result.executionId().toString(), user.getId(), request, content) - ); + portfolioDraftHistoryWriter.save(result.executionId().toString(), user.getId(), request, content); } catch (Exception e) { log.warn("포트폴리오 이력 저장 실패 executionId={}", result.executionId(), e); } diff --git a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java index 8486941..195fadb 100644 --- a/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java +++ b/src/main/java/com/bootsignal/domain/course/dto/CourseListResponse.java @@ -29,46 +29,6 @@ public record CourseListResponse( BigDecimal reviewRating, BigDecimal employmentRate ) { - // 12-인자 생성자 (기존 코드 하위 호환성 유지) - public CourseListResponse( - Long id, - String trprId, - String title, - String institutionName, - String trngAreaCd, - Integer courseMan, - Integer selfPaymentAmount, - BigDecimal stdgScor, - Integer totalTrainingDays, - Integer totalTrainingHours, - String ncsName, - String profileImageUrl - ) { - this( - id, - id, - null, - trprId, - null, - title, - institutionName, - trngAreaCd, - courseMan, - selfPaymentAmount, - stdgScor, - totalTrainingDays, - totalTrainingHours, - ncsName, - profileImageUrl, - null, - null, - null, - null, - null, - null - ); - } - public static CourseListResponse from(Course course, CourseSession repSession, BigDecimal reviewRating) { String profileImageUrl = course.getInstitution() != null ? course.getInstitution().getProfileImageUrl() diff --git a/src/main/java/com/bootsignal/global/converter/AbstractJsonListConverter.java b/src/main/java/com/bootsignal/global/converter/AbstractJsonListConverter.java new file mode 100644 index 0000000..0122989 --- /dev/null +++ b/src/main/java/com/bootsignal/global/converter/AbstractJsonListConverter.java @@ -0,0 +1,38 @@ +package com.bootsignal.global.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import java.util.List; + +public abstract class AbstractJsonListConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + protected abstract TypeReference> typeReference(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 직렬화 실패", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) { + return List.of(); + } + try { + return MAPPER.readValue(dbData, typeReference()); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 역직렬화 실패", e); + } + } +} diff --git a/src/main/java/com/bootsignal/global/converter/StringListConverter.java b/src/main/java/com/bootsignal/global/converter/StringListConverter.java index 3c3e74e..3392172 100644 --- a/src/main/java/com/bootsignal/global/converter/StringListConverter.java +++ b/src/main/java/com/bootsignal/global/converter/StringListConverter.java @@ -1,39 +1,16 @@ package com.bootsignal.global.converter; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import java.util.List; @Converter -public class StringListConverter implements AttributeConverter, String> { +public class StringListConverter extends AbstractJsonListConverter { - private static final ObjectMapper MAPPER = new ObjectMapper(); private static final TypeReference> TYPE = new TypeReference<>() {}; @Override - public String convertToDatabaseColumn(List attribute) { - if (attribute == null) { - return null; - } - try { - return MAPPER.writeValueAsString(attribute); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 직렬화 실패", e); - } - } - - @Override - public List convertToEntityAttribute(String dbData) { - if (dbData == null) { - return List.of(); - } - try { - return MAPPER.readValue(dbData, TYPE); - } catch (JsonProcessingException e) { - throw new IllegalStateException("List 역직렬화 실패", e); - } + protected TypeReference> typeReference() { + return TYPE; } } diff --git a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java index fe0c30b..1bade45 100644 --- a/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java +++ b/src/test/java/com/bootsignal/domain/ai/portfolio/service/PortfolioDraftServiceTest.java @@ -17,8 +17,8 @@ import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftResponse; import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftTone; import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; -import com.bootsignal.domain.ai.portfolio.entity.PortfolioDraftHistory; import com.bootsignal.domain.ai.portfolio.repository.PortfolioDraftHistoryRepository; +import com.bootsignal.domain.ai.portfolio.service.PortfolioDraftHistoryWriter; import com.bootsignal.domain.user.entity.User; import com.bootsignal.domain.user.repository.UserRepository; import com.bootsignal.global.exception.BootSignalException; @@ -48,6 +48,9 @@ class PortfolioDraftServiceTest { @Mock private PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + @Mock + private PortfolioDraftHistoryWriter portfolioDraftHistoryWriter; + @AfterEach void clearSecurityContext() { SecurityContextHolder.clearContext(); @@ -57,7 +60,7 @@ void clearSecurityContext() { void createDraftRunsPortfolioAgentForAuthenticatedUser() { User user = User.signupLocal("writer@example.com", "encoded-password", "writer"); setAuthentication(user.getEmail()); - PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository); + PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository, portfolioDraftHistoryWriter); PortfolioDraftContent content = new PortfolioDraftContent( "백엔드 개발자 포트폴리오 소개입니다.", List.of("API 구현"), @@ -89,12 +92,12 @@ void createDraftRunsPortfolioAgentForAuthenticatedUser() { assertThat(context.inputSummary()).contains("목표 직무: 백엔드 개발자"); assertThat(context.input()).containsEntry("targetJob", "백엔드 개발자"); assertThat(context.input()).containsEntry("tone", PortfolioDraftTone.PROFESSIONAL); - verify(portfolioDraftHistoryRepository).save(any(PortfolioDraftHistory.class)); + verify(portfolioDraftHistoryWriter).save(any(String.class), any(), any(PortfolioDraftCreateRequest.class), any(PortfolioDraftContent.class)); } @Test void createDraftThrowsUnauthorizedWithoutAuthentication() { - PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository); + PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository, portfolioDraftHistoryWriter); assertThatThrownBy(() -> service.createDraft(request())) .isInstanceOf(BootSignalException.class) diff --git a/src/test/java/com/bootsignal/domain/course/controller/CourseControllerTest.java b/src/test/java/com/bootsignal/domain/course/controller/CourseControllerTest.java index c08a883..4f7ef49 100644 --- a/src/test/java/com/bootsignal/domain/course/controller/CourseControllerTest.java +++ b/src/test/java/com/bootsignal/domain/course/controller/CourseControllerTest.java @@ -44,9 +44,12 @@ class CourseControllerTest { @Test void getCoursesReturnsPageResponse() throws Exception { CourseListResponse course = new CourseListResponse( - 1L, "TR001", "Spring Boot Course", "Boot Camp Center", - "01", 30, 0, BigDecimal.valueOf(4.5), - 100, 800, "SW Dev", "http://image.url" + 1L, 1L, null, + "TR001", null, + "Spring Boot Course", "Boot Camp Center", "01", + 30, 0, BigDecimal.valueOf(4.5), + 100, 800, "SW Dev", "http://image.url", + null, null, null, null, null, null ); PageResponse response = new PageResponse<>( List.of(course), 0, 20, 1L, 1, false From 92dd4abb8d840c171efe10530c3088a9e8623a34 Mon Sep 17 00:00:00 2001 From: yongseong123 Date: Wed, 24 Jun 2026 14:11:49 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=81=AC=EA=B8=B0=20=EC=83=81=ED=95=9C(10?= =?UTF-8?q?0)=20=EB=B0=8F=20=ED=95=98=ED=95=9C(1)=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portfolio/controller/PortfolioDraftController.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java index 41cbfbd..81ff6c3 100644 --- a/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/controller/PortfolioDraftController.java @@ -7,9 +7,12 @@ import com.bootsignal.domain.ai.portfolio.service.PortfolioDraftService; import com.bootsignal.global.dto.PageResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,9 +26,9 @@ @RestController @RequestMapping("/api/ai/portfolio-drafts") @RequiredArgsConstructor +@Validated public class PortfolioDraftController { - // 로그인 사용자의 포트폴리오 초안 생성 요청을 서비스 계층으로 위임한다. private final PortfolioDraftService portfolioDraftService; @PostMapping @@ -35,8 +38,8 @@ public PortfolioDraftResponse createDraft(@RequestBody @Valid PortfolioDraftCrea @GetMapping("/history") public PageResponse getHistory( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size ) { return portfolioDraftService.getHistory( PageRequest.of(page, size)