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..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 @@ -1,25 +1,59 @@ 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 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; 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 @RequestMapping("/api/ai/portfolio-drafts") @RequiredArgsConstructor +@Validated public class PortfolioDraftController { - // 로그인 사용자의 포트폴리오 초안 생성 요청을 서비스 계층으로 위임한다. private final PortfolioDraftService portfolioDraftService; @PostMapping public PortfolioDraftResponse createDraft(@RequestBody @Valid PortfolioDraftCreateRequest request) { return portfolioDraftService.createDraft(request); } + + @GetMapping("/history") + public PageResponse getHistory( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size + ) { + return portfolioDraftService.getHistory( + PageRequest.of(page, size) + ); + } + + @GetMapping("/history/{historyId}") + 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/converter/PortfolioDraftProjectListConverter.java b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java new file mode 100644 index 0000000..688a255 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioDraftProjectListConverter.java @@ -0,0 +1,18 @@ +package com.bootsignal.domain.ai.portfolio.converter; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioDraftProject; +import com.bootsignal.global.converter.AbstractJsonListConverter; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class PortfolioDraftProjectListConverter extends AbstractJsonListConverter { + + private static final TypeReference> TYPE = new TypeReference<>() {}; + + @Override + 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 new file mode 100644 index 0000000..30e0cab --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/converter/PortfolioProjectExperienceRequestListConverter.java @@ -0,0 +1,18 @@ +package com.bootsignal.domain.ai.portfolio.converter; + +import com.bootsignal.domain.ai.portfolio.dto.PortfolioProjectExperienceRequest; +import com.bootsignal.global.converter.AbstractJsonListConverter; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class PortfolioProjectExperienceRequestListConverter extends AbstractJsonListConverter { + + private static final TypeReference> TYPE = new TypeReference<>() {}; + + @Override + protected TypeReference> typeReference() { + return TYPE; + } +} 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..1696ab3 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/dto/PortfolioDraftHistoryDetailResponse.java @@ -0,0 +1,41 @@ +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, + 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.getSkills(), + history.getProjects(), + history.getEducation(), + history.getCareerSummary(), + history.getTone(), + history.getIntroduction(), + history.getCoreCompetencies(), + history.getProjectDescriptions(), + history.getTechStackSummary(), + history.getImprovementSuggestions(), + 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..8efc7c6 --- /dev/null +++ b/src/main/java/com/bootsignal/domain/ai/portfolio/entity/PortfolioDraftHistory.java @@ -0,0 +1,99 @@ +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 jakarta.persistence.Column; +import jakarta.persistence.Convert; +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 { + + @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; + + @Convert(converter = StringListConverter.class) + @Column(name = "skills", nullable = false, columnDefinition = "text") + private List skills; + + @Convert(converter = PortfolioProjectExperienceRequestListConverter.class) + @Column(name = "projects", nullable = false, columnDefinition = "text") + private List 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; + + @Convert(converter = StringListConverter.class) + @Column(name = "core_competencies", columnDefinition = "text") + private List coreCompetencies; + + @Convert(converter = PortfolioDraftProjectListConverter.class) + @Column(name = "project_descriptions", columnDefinition = "text") + private List projectDescriptions; + + @Column(name = "tech_stack_summary", columnDefinition = "text") + private String techStackSummary; + + @Convert(converter = StringListConverter.class) + @Column(name = "improvement_suggestions", columnDefinition = "text") + private List 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 = 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 = content.coreCompetencies(); + history.projectDescriptions = content.projectDescriptions(); + history.techStackSummary = content.techStackSummary(); + history.improvementSuggestions = content.improvementSuggestions(); + return history; + } +} 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/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 88ce404..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 @@ -6,25 +6,37 @@ 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 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; + private final PortfolioDraftHistoryWriter portfolioDraftHistoryWriter; + @Transactional public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { User user = getAuthenticatedUser(); AgentExecutionResult result = agentHarness.execute(AgentExecutionContext.of( @@ -42,9 +54,41 @@ public PortfolioDraftResponse createDraft(PortfolioDraftCreateRequest request) { if (!(draft instanceof PortfolioDraftContent content)) { throw new BootSignalException(ErrorCode.AI_OUTPUT_INVALID, "포트폴리오 초안 결과를 찾을 수 없습니다."); } + + try { + portfolioDraftHistoryWriter.save(result.executionId().toString(), user.getId(), request, content); + } catch (Exception e) { + log.warn("포트폴리오 이력 저장 실패 executionId={}", result.executionId(), e); + } + 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); + } + + @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()); 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..195fadb 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, @@ -28,56 +29,18 @@ 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, - title, - institutionName, - trngAreaCd, - courseMan, - selfPaymentAmount, - stdgScor, - totalTrainingDays, - totalTrainingHours, - ncsName, - profileImageUrl, - null, - null, - null, - null, - null, - null - ); - } - - 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( + id, course.getId(), + repSession != null ? repSession.getId() : null, course.getTrprId(), + repSession != null ? repSession.getTrprDegr() : null, course.getTitle(), course.getSubTitle(), // 기관명은 subTitle에 비정규화됨 course.getTrngAreaCd(), @@ -87,7 +50,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, + reviewRating, + repSession != null ? repSession.getEmploymentRate() : null ); } @@ -101,6 +70,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/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/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..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,6 +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.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; @@ -43,6 +45,12 @@ class PortfolioDraftServiceTest { @Mock private UserRepository userRepository; + @Mock + private PortfolioDraftHistoryRepository portfolioDraftHistoryRepository; + + @Mock + private PortfolioDraftHistoryWriter portfolioDraftHistoryWriter; + @AfterEach void clearSecurityContext() { SecurityContextHolder.clearContext(); @@ -52,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); + PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository, portfolioDraftHistoryRepository, portfolioDraftHistoryWriter); PortfolioDraftContent content = new PortfolioDraftContent( "백엔드 개발자 포트폴리오 소개입니다.", List.of("API 구현"), @@ -84,11 +92,12 @@ void createDraftRunsPortfolioAgentForAuthenticatedUser() { assertThat(context.inputSummary()).contains("목표 직무: 백엔드 개발자"); assertThat(context.input()).containsEntry("targetJob", "백엔드 개발자"); assertThat(context.input()).containsEntry("tone", PortfolioDraftTone.PROFESSIONAL); + verify(portfolioDraftHistoryWriter).save(any(String.class), any(), any(PortfolioDraftCreateRequest.class), any(PortfolioDraftContent.class)); } @Test void createDraftThrowsUnauthorizedWithoutAuthentication() { - PortfolioDraftService service = new PortfolioDraftService(agentHarness, userRepository); + 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