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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/com/devpick/DevpickApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class DevpickApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -31,7 +32,8 @@ public class AiAnswerController {
})
@PostMapping
public ApiResponse<AiAnswerResponse> generateAiAnswer(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "게시글 ID (UUID)", required = true) @PathVariable UUID postId) {
return ApiResponse.ok(aiAnswerService.generateOrGetAnswer(postId));
return ApiResponse.ok(aiAnswerService.generateOrGetAnswer(userId, postId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;

import java.util.UUID;

@Tag(name = "AI Question", description = "AI 질문 개선")
@RestController
@RequestMapping("/posts")
Expand All @@ -29,7 +32,9 @@ public class AiQuestionController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "AI 서버 오류")
})
@PostMapping("/refine")
public ApiResponse<QuestionRefineResponse> refine(@Valid @RequestBody QuestionRefineRequest request) {
return ApiResponse.ok(aiQuestionService.refine(request));
public ApiResponse<QuestionRefineResponse> refine(
@AuthenticationPrincipal UUID userId,
@Valid @RequestBody QuestionRefineRequest request) {
return ApiResponse.ok(aiQuestionService.refine(userId, request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.devpick.domain.community.repository.AiAnswerRepository;
import com.devpick.domain.community.repository.AiQuestionRepository;
import com.devpick.domain.community.repository.PostRepository;
import com.devpick.domain.subscription.service.PlanLimitService;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,9 +27,16 @@ public class AiAnswerService {
private final AiQuestionRepository aiQuestionRepository;
private final PostRepository postRepository;
private final AiAnswerClient aiAnswerClient;
private final UserRepository userRepository;
private final PlanLimitService planLimitService;

@Transactional
public AiAnswerResponse generateOrGetAnswer(UUID postId) {
public AiAnswerResponse generateOrGetAnswer(UUID userId, UUID postId) {
if (userId != null) {
userRepository.findById(userId).ifPresent(user ->
planLimitService.checkAndIncrementAiDaily(userId, user.getPlanType()));
}

Post post = postRepository.findById(postId)
.orElseThrow(() -> new DevpickException(ErrorCode.COMMUNITY_POST_NOT_FOUND));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
import com.devpick.domain.community.entity.Post;
import com.devpick.domain.community.repository.AiQuestionRepository;
import com.devpick.domain.community.repository.PostRepository;
import com.devpick.domain.subscription.service.PlanLimitService;
import com.devpick.domain.user.repository.UserRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
Expand All @@ -23,9 +27,15 @@ public class AiQuestionService {
private final AiQuestionRepository aiQuestionRepository;
private final PostRepository postRepository;
private final ObjectMapper objectMapper;
private final UserRepository userRepository;
private final PlanLimitService planLimitService;

@Transactional
public QuestionRefineResponse refine(QuestionRefineRequest request) {
public QuestionRefineResponse refine(UUID userId, QuestionRefineRequest request) {
if (userId != null) {
userRepository.findById(userId).ifPresent(user ->
planLimitService.checkAndIncrementAiDaily(userId, user.getPlanType()));
}
QuestionRefineResponse response = aiQuestionClient.refine(request);

// postId가 있으면 AiQuestion에 결과 저장 (AI 답변 생성 시 refined 데이터 활용)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public ApiResponse<AiQuizResponse> getQuiz(
@Parameter(description = "퀴즈 레벨. 생략 시 로그인 사용자는 프로필 경력 수준, 비로그인은 JUNIOR", example = "MIDDLE")
@RequestParam(required = false) String level) {
String resolved = userService.resolvePreferredAiLevel(userId, level);
userService.checkAiLevelAccess(userId, resolved);
return ApiResponse.ok(aiQuizService.getQuiz(userId, contentId, resolved));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public ApiResponse<AiSummaryResponse> getSummary(
@Parameter(description = "요약 레벨. 생략 시 로그인 사용자는 프로필 경력 수준, 비로그인은 JUNIOR", example = "MIDDLE")
@RequestParam(required = false) String level) {
String resolved = userService.resolvePreferredAiLevel(userId, level);
userService.checkAiLevelAccess(userId, resolved);
return ApiResponse.ok(aiSummaryService.getSummary(userId, contentId, resolved));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,15 @@ public ApiResponse<Void> unbookmark(
return ApiResponse.ok();
}

@Operation(summary = "부족 역량 보완 추천")
@Operation(summary = "부족 역량 보완 — 마지막 저장 결과 조회")
@GetMapping("/" + JOB_ID + "/skill-gap")
public ApiResponse<SkillGapResponse> getSkillGap(
@AuthenticationPrincipal UUID userId,
@PathVariable UUID jobId) {
return ApiResponse.ok(jobService.getSkillGap(userId, jobId));
}

@Operation(summary = "부족 역량 보완 추천 — 새로 생성 + 저장")
@PostMapping("/" + JOB_ID + "/skill-gap")
public ApiResponse<SkillGapResponse> skillGap(
@AuthenticationPrincipal UUID userId,
Expand Down
44 changes: 44 additions & 0 deletions src/main/java/com/devpick/domain/job/entity/JobSkillGap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.devpick.domain.job.entity;

import com.devpick.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.UUID;

@Entity
@Table(name = "job_skill_gaps",
uniqueConstraints = @UniqueConstraint(name = "uk_job_skill_gap_user_posting", columnNames = {"user_id", "job_posting_id"}),
indexes = {
@Index(name = "idx_job_skill_gap_user", columnList = "user_id")
})
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class JobSkillGap extends BaseTimeEntity {

@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "job_posting_id", nullable = false)
private JobPosting jobPosting;

/** JSON: { "roadmap": [...], "contents": [...] } */
@Column(name = "result_json", nullable = false, columnDefinition = "text")
private String resultJson;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devpick.domain.job.repository;

import com.devpick.domain.job.entity.JobSkillGap;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
import java.util.UUID;

public interface JobSkillGapRepository extends JpaRepository<JobSkillGap, UUID> {

Optional<JobSkillGap> findByUserIdAndJobPosting_Id(UUID userId, UUID jobPostingId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.devpick.domain.job.repository.JobPostingSpecifications;
import com.devpick.domain.resume.repository.MasterResumeRepository;
import com.devpick.domain.resume.service.ResumeCryptoService;
import com.devpick.domain.subscription.service.PlanLimitService;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.domain.job.dto.JobApiModels.InterviewQaListItemResponse;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
Expand All @@ -30,6 +32,8 @@ public class JobInterviewService {
private final ResumeCryptoService resumeCryptoService;
private final JobAiClient jobAiClient;
private final JobService jobService;
private final UserRepository userRepository;
private final PlanLimitService planLimitService;

@Transactional(readOnly = true)
public List<InterviewQaListItemResponse> listForUser(UUID userId) {
Expand Down Expand Up @@ -60,6 +64,9 @@ public String getPayload(UUID userId, UUID jobId) {

@Transactional
public String generateAndSave(UUID userId, UUID jobId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
planLimitService.checkAndIncrementWeekly(userId, user.getPlanType(), "interview_qa_gen");
JobPosting job = jobPostingRepository.findById(jobId)
.orElseThrow(() -> new DevpickException(ErrorCode.JOB_NOT_FOUND));
if (!JobPostingSpecifications.passesListableQuality(job.getTitle(), job.getCompanyName())) {
Expand Down
35 changes: 33 additions & 2 deletions src/main/java/com/devpick/domain/job/service/JobService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@
import com.devpick.domain.job.entity.JobPosting;
import com.devpick.domain.job.entity.JobPostingCategory;
import com.devpick.domain.job.entity.PostingExperienceLevel;
import com.devpick.domain.job.entity.JobSkillGap;
import com.devpick.domain.job.repository.JobBookmarkRepository;
import com.devpick.domain.job.repository.JobPostingRepository;
import com.devpick.domain.job.repository.JobPostingSpecifications;
import com.devpick.domain.job.repository.JobSkillGapRepository;
import com.devpick.domain.point.entity.PointAction;
import com.devpick.domain.point.service.PointService;
import com.devpick.domain.report.entity.History;
import com.devpick.domain.report.repository.HistoryRepository;
import com.devpick.domain.resume.entity.MasterResume;
import com.devpick.domain.subscription.service.PlanLimitService;
import com.devpick.domain.resume.repository.MasterResumeRepository;
import com.devpick.domain.resume.service.ResumeCryptoService;
import com.devpick.domain.user.entity.Tag;
Expand Down Expand Up @@ -82,6 +85,8 @@ public class JobService {
private final ObjectMapper objectMapper;
private final HistoryRepository historyRepository;
private final PointService pointService;
private final PlanLimitService planLimitService;
private final JobSkillGapRepository jobSkillGapRepository;

@Transactional(readOnly = true)
public List<TechTagFacetResponse> listTechTagFacets(Integer limit) {
Expand Down Expand Up @@ -386,8 +391,11 @@ private JobBookmarkItemResponse toBookmarkItem(JobBookmark b, Map<String, Intege
);
}

@Transactional(readOnly = true)
@Transactional
public SkillGapResponse skillGap(UUID userId, UUID jobId) {
var user = userRepository.findByIdAndIsActiveTrue(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
planLimitService.checkAndIncrementWeekly(userId, user.getPlanType(), "skill_boost");
JobPosting p = jobPostingRepository.findById(jobId)
.orElseThrow(() -> new DevpickException(ErrorCode.JOB_NOT_FOUND));
ensureListableJob(p);
Expand All @@ -411,7 +419,30 @@ public SkillGapResponse skillGap(UUID userId, UUID jobId) {
// missing이 비어있으면(스킬 모두 보유) techStack 기준으로 콘텐츠 추천
List<String> contentSkills = missing.isEmpty() ? p.getTechStack() : missing;
List<ContentPickResponse> picks = recommendContents(contentSkills);
return new SkillGapResponse(roadmap, picks);
SkillGapResponse result = new SkillGapResponse(roadmap, picks);

try {
String resultJson = objectMapper.writeValueAsString(result);
JobSkillGap entity = jobSkillGapRepository.findByUserIdAndJobPosting_Id(userId, jobId)
.orElseGet(() -> JobSkillGap.builder().userId(userId).jobPosting(p).resultJson("").build());
entity.setResultJson(resultJson);
jobSkillGapRepository.save(entity);
} catch (Exception e) {
// 저장 실패해도 응답은 정상 반환
}

return result;
}

@Transactional(readOnly = true)
public SkillGapResponse getSkillGap(UUID userId, UUID jobId) {
JobSkillGap entity = jobSkillGapRepository.findByUserIdAndJobPosting_Id(userId, jobId)
.orElseThrow(() -> new DevpickException(ErrorCode.JOB_SKILL_GAP_NOT_FOUND));
try {
return objectMapper.readValue(entity.getResultJson(), SkillGapResponse.class);
} catch (Exception e) {
throw new DevpickException(ErrorCode.JOB_SKILL_GAP_NOT_FOUND);
}
}

private List<ContentPickResponse> recommendContents(List<String> skills) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import com.devpick.domain.job.repository.MockInterviewSessionRepository;
import com.devpick.domain.resume.repository.MasterResumeRepository;
import com.devpick.domain.resume.service.ResumeCryptoService;
import com.devpick.domain.subscription.service.PlanLimitService;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import com.fasterxml.jackson.core.type.TypeReference;
Expand Down Expand Up @@ -60,6 +62,8 @@ public class MockInterviewService {
private final JobAiClient jobAiClient;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
private final UserRepository userRepository;
private final PlanLimitService planLimitService;

@Transactional(readOnly = true)
public HistoryListResponse listForUser(UUID userId) {
Expand All @@ -78,6 +82,9 @@ public SessionDetailResponse get(UUID userId, UUID sessionId) {

@Transactional
public SessionDetailResponse startFromJob(UUID userId, UUID jobId, StartFromJobRequest request) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
planLimitService.checkAndIncrementWeekly(userId, user.getPlanType(), "mock_interview");
JobPosting job = jobPostingRepository.findById(jobId)
.orElseThrow(() -> new DevpickException(ErrorCode.JOB_NOT_FOUND));
String resumeJson = loadResumeJson(userId);
Expand Down Expand Up @@ -111,6 +118,9 @@ public SessionDetailResponse startFromJob(UUID userId, UUID jobId, StartFromJobR

@Transactional
public SessionDetailResponse startFromJd(UUID userId, StartFromJdRequest request) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
planLimitService.checkAndIncrementWeekly(userId, user.getPlanType(), "mock_interview");
if (request == null || request.jobTitle() == null || request.jobTitle().isBlank()) {
throw new DevpickException(ErrorCode.INVALID_INPUT);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
import com.devpick.domain.report.entity.WeeklyReport;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.UUID;

public record ReportSummaryResponse(
UUID reportId,
Instant weekStart,
Instant weekEnd,
String status
String status,
boolean locked
) {
public static ReportSummaryResponse of(WeeklyReport report) {
public static ReportSummaryResponse of(WeeklyReport report, boolean locked) {
return new ReportSummaryResponse(
report.getId(),
report.getWeekStart() != null ? report.getWeekStart().atStartOfDay().toInstant(ZoneOffset.UTC) : null,
report.getWeekEnd() != null ? report.getWeekEnd().atStartOfDay().toInstant(ZoneOffset.UTC) : null,
report.getStatus()
report.getStatus(),
locked
);
}
}
Loading
Loading