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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<PortfolioDraftHistoryResponse> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<PortfolioDraftProject> {

private static final TypeReference<List<PortfolioDraftProject>> TYPE = new TypeReference<>() {};

@Override
protected TypeReference<List<PortfolioDraftProject>> typeReference() {
return TYPE;
}
}
Original file line number Diff line number Diff line change
@@ -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<PortfolioProjectExperienceRequest> {

private static final TypeReference<List<PortfolioProjectExperienceRequest>> TYPE = new TypeReference<>() {};

@Override
protected TypeReference<List<PortfolioProjectExperienceRequest>> typeReference() {
return TYPE;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> skills,
List<PortfolioProjectExperienceRequest> projects,
String education,
String careerSummary,
String tone,
String introduction,
List<String> coreCompetencies,
List<PortfolioDraftProject> projectDescriptions,
String techStackSummary,
List<String> 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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> skills;

@Convert(converter = PortfolioProjectExperienceRequestListConverter.class)
@Column(name = "projects", nullable = false, columnDefinition = "text")
private List<PortfolioProjectExperienceRequest> 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<String> coreCompetencies;

@Convert(converter = PortfolioDraftProjectListConverter.class)
@Column(name = "project_descriptions", columnDefinition = "text")
private List<PortfolioDraftProject> projectDescriptions;

@Column(name = "tech_stack_summary", columnDefinition = "text")
private String techStackSummary;

@Convert(converter = StringListConverter.class)
@Column(name = "improvement_suggestions", columnDefinition = "text")
private List<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 = 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<PortfolioDraftHistory, Long> {

Page<PortfolioDraftHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);

Optional<PortfolioDraftHistory> findByIdAndUserId(Long id, Long userId);
}
Original file line number Diff line number Diff line change
@@ -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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<PortfolioDraftHistoryResponse> 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<String, Object> toInput(User user, PortfolioDraftCreateRequest request) {
Map<String, Object> input = new LinkedHashMap<>();
input.put("targetJob", request.targetJob());
Expand Down
Loading
Loading