diff --git a/src/main/java/com/devpick/DevpickApplication.java b/src/main/java/com/devpick/DevpickApplication.java index 861c5956..c05c4649 100644 --- a/src/main/java/com/devpick/DevpickApplication.java +++ b/src/main/java/com/devpick/DevpickApplication.java @@ -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) { diff --git a/src/main/java/com/devpick/domain/community/controller/AiAnswerController.java b/src/main/java/com/devpick/domain/community/controller/AiAnswerController.java index aaf00044..eda77217 100644 --- a/src/main/java/com/devpick/domain/community/controller/AiAnswerController.java +++ b/src/main/java/com/devpick/domain/community/controller/AiAnswerController.java @@ -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; @@ -31,7 +32,8 @@ public class AiAnswerController { }) @PostMapping public ApiResponse 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)); } } \ No newline at end of file diff --git a/src/main/java/com/devpick/domain/community/controller/AiQuestionController.java b/src/main/java/com/devpick/domain/community/controller/AiQuestionController.java index d7bb6845..36484603 100644 --- a/src/main/java/com/devpick/domain/community/controller/AiQuestionController.java +++ b/src/main/java/com/devpick/domain/community/controller/AiQuestionController.java @@ -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") @@ -29,7 +32,9 @@ public class AiQuestionController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "AI 서버 오류") }) @PostMapping("/refine") - public ApiResponse refine(@Valid @RequestBody QuestionRefineRequest request) { - return ApiResponse.ok(aiQuestionService.refine(request)); + public ApiResponse refine( + @AuthenticationPrincipal UUID userId, + @Valid @RequestBody QuestionRefineRequest request) { + return ApiResponse.ok(aiQuestionService.refine(userId, request)); } } diff --git a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java index 66385d92..7eeeadc2 100644 --- a/src/main/java/com/devpick/domain/community/service/AiAnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AiAnswerService.java @@ -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; @@ -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)); diff --git a/src/main/java/com/devpick/domain/community/service/AiQuestionService.java b/src/main/java/com/devpick/domain/community/service/AiQuestionService.java index bd4bcca9..577b979d 100644 --- a/src/main/java/com/devpick/domain/community/service/AiQuestionService.java +++ b/src/main/java/com/devpick/domain/community/service/AiQuestionService.java @@ -7,6 +7,8 @@ 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; @@ -14,6 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; + @Slf4j @Service @RequiredArgsConstructor @@ -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 데이터 활용) diff --git a/src/main/java/com/devpick/domain/content/controller/AiQuizController.java b/src/main/java/com/devpick/domain/content/controller/AiQuizController.java index 43643658..02c31041 100644 --- a/src/main/java/com/devpick/domain/content/controller/AiQuizController.java +++ b/src/main/java/com/devpick/domain/content/controller/AiQuizController.java @@ -44,6 +44,7 @@ public ApiResponse 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)); } diff --git a/src/main/java/com/devpick/domain/content/controller/AiSummaryController.java b/src/main/java/com/devpick/domain/content/controller/AiSummaryController.java index f119d3a5..c3a5047f 100644 --- a/src/main/java/com/devpick/domain/content/controller/AiSummaryController.java +++ b/src/main/java/com/devpick/domain/content/controller/AiSummaryController.java @@ -40,6 +40,7 @@ public ApiResponse 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)); } } diff --git a/src/main/java/com/devpick/domain/job/controller/JobController.java b/src/main/java/com/devpick/domain/job/controller/JobController.java index 7244ef52..a6e1b5e3 100644 --- a/src/main/java/com/devpick/domain/job/controller/JobController.java +++ b/src/main/java/com/devpick/domain/job/controller/JobController.java @@ -106,7 +106,15 @@ public ApiResponse unbookmark( return ApiResponse.ok(); } - @Operation(summary = "부족 역량 보완 추천") + @Operation(summary = "부족 역량 보완 — 마지막 저장 결과 조회") + @GetMapping("/" + JOB_ID + "/skill-gap") + public ApiResponse getSkillGap( + @AuthenticationPrincipal UUID userId, + @PathVariable UUID jobId) { + return ApiResponse.ok(jobService.getSkillGap(userId, jobId)); + } + + @Operation(summary = "부족 역량 보완 추천 — 새로 생성 + 저장") @PostMapping("/" + JOB_ID + "/skill-gap") public ApiResponse skillGap( @AuthenticationPrincipal UUID userId, diff --git a/src/main/java/com/devpick/domain/job/entity/JobSkillGap.java b/src/main/java/com/devpick/domain/job/entity/JobSkillGap.java new file mode 100644 index 00000000..5561819d --- /dev/null +++ b/src/main/java/com/devpick/domain/job/entity/JobSkillGap.java @@ -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; +} diff --git a/src/main/java/com/devpick/domain/job/repository/JobSkillGapRepository.java b/src/main/java/com/devpick/domain/job/repository/JobSkillGapRepository.java new file mode 100644 index 00000000..928e08ac --- /dev/null +++ b/src/main/java/com/devpick/domain/job/repository/JobSkillGapRepository.java @@ -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 { + + Optional findByUserIdAndJobPosting_Id(UUID userId, UUID jobPostingId); +} diff --git a/src/main/java/com/devpick/domain/job/service/JobInterviewService.java b/src/main/java/com/devpick/domain/job/service/JobInterviewService.java index bca39ac2..99934388 100644 --- a/src/main/java/com/devpick/domain/job/service/JobInterviewService.java +++ b/src/main/java/com/devpick/domain/job/service/JobInterviewService.java @@ -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; @@ -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 listForUser(UUID userId) { @@ -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())) { diff --git a/src/main/java/com/devpick/domain/job/service/JobService.java b/src/main/java/com/devpick/domain/job/service/JobService.java index 6f126951..37eb2cdf 100644 --- a/src/main/java/com/devpick/domain/job/service/JobService.java +++ b/src/main/java/com/devpick/domain/job/service/JobService.java @@ -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; @@ -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 listTechTagFacets(Integer limit) { @@ -386,8 +391,11 @@ private JobBookmarkItemResponse toBookmarkItem(JobBookmark b, Map 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); @@ -411,7 +419,30 @@ public SkillGapResponse skillGap(UUID userId, UUID jobId) { // missing이 비어있으면(스킬 모두 보유) techStack 기준으로 콘텐츠 추천 List contentSkills = missing.isEmpty() ? p.getTechStack() : missing; List 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 recommendContents(List skills) { diff --git a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java index d4274721..dc6a01b2 100644 --- a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java @@ -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; @@ -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) { @@ -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); @@ -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); } diff --git a/src/main/java/com/devpick/domain/report/dto/ReportSummaryResponse.java b/src/main/java/com/devpick/domain/report/dto/ReportSummaryResponse.java index fa36cd87..e34f39d5 100644 --- a/src/main/java/com/devpick/domain/report/dto/ReportSummaryResponse.java +++ b/src/main/java/com/devpick/domain/report/dto/ReportSummaryResponse.java @@ -3,6 +3,7 @@ import com.devpick.domain.report.entity.WeeklyReport; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneOffset; import java.util.UUID; @@ -10,14 +11,16 @@ 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 ); } } diff --git a/src/main/java/com/devpick/domain/report/service/WeeklyReportService.java b/src/main/java/com/devpick/domain/report/service/WeeklyReportService.java index 16db44ea..44ab8a02 100644 --- a/src/main/java/com/devpick/domain/report/service/WeeklyReportService.java +++ b/src/main/java/com/devpick/domain/report/service/WeeklyReportService.java @@ -17,6 +17,7 @@ import com.devpick.domain.report.entity.WeeklyReport; import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.report.repository.WeeklyReportRepository; +import com.devpick.domain.subscription.entity.PlanType; import com.devpick.domain.user.entity.User; import com.devpick.domain.user.entity.UserTag; import com.devpick.domain.user.repository.UserRepository; @@ -70,8 +71,17 @@ public class WeeklyReportService { // DP-256: 리포트 목록 조회 (드롭다운용 최소 필드) @Transactional(readOnly = true) public List getReportList(UUID userId) { + User user = userRepository.findByIdAndIsActiveTrue(userId) + .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); + boolean isFree = user.getPlanType() == PlanType.FREE; + LocalDate cutoff = LocalDate.now(ZONE_SEOUL).minusDays(7); + return weeklyReportRepository.findByUserIdOrderByWeekStartDesc(userId).stream() - .map(ReportSummaryResponse::of) + .map(report -> { + boolean locked = isFree && report.getWeekStart() != null + && report.getWeekStart().isBefore(cutoff); + return ReportSummaryResponse.of(report, locked); + }) .toList(); } diff --git a/src/main/java/com/devpick/domain/subscription/client/TossPaymentsClient.java b/src/main/java/com/devpick/domain/subscription/client/TossPaymentsClient.java new file mode 100644 index 00000000..6b2536c5 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/client/TossPaymentsClient.java @@ -0,0 +1,113 @@ +package com.devpick.domain.subscription.client; + +import com.devpick.domain.subscription.config.TossProperties; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TossPaymentsClient { + + private final WebClient webClient; + private final TossProperties tossProperties; + + /** + * 빌링키 발급. + * POST https://api.tosspayments.com/v1/billing/authorizations/issue + */ + public String issueBillingKey(String authKey, String customerKey) { + Map body = Map.of( + "authKey", authKey, + "customerKey", customerKey + ); + + Map response = webClient.post() + .uri(tossProperties.billingUrl() + "/authorizations/issue") + .header(HttpHeaders.AUTHORIZATION, basicAuth(tossProperties.secretKey())) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .onStatus(status -> status.isError(), resp -> resp.bodyToMono(String.class) + .map(err -> { + log.error("토스 빌링키 발급 실패: {}", err); + return new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED); + })) + .bodyToMono(Map.class) + .block(); + + if (response == null || response.get("billingKey") == null) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED); + } + return (String) response.get("billingKey"); + } + + /** + * 빌링키로 결제 실행. + * POST https://api.tosspayments.com/v1/billing/{billingKey} + */ + public String charge(String billingKey, String customerKey, int amount, String orderName, String orderId) { + Map body = Map.of( + "customerKey", customerKey, + "amount", amount, + "orderId", orderId, + "orderName", orderName + ); + + Map response = webClient.post() + .uri(tossProperties.billingUrl() + "/" + billingKey) + .header(HttpHeaders.AUTHORIZATION, basicAuth(tossProperties.secretKey())) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .onStatus(status -> status.isError(), resp -> resp.bodyToMono(String.class) + .map(err -> { + log.error("토스 결제 실패: {}", err); + return new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED); + })) + .bodyToMono(Map.class) + .block(); + + if (response == null || response.get("paymentKey") == null) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED); + } + return (String) response.get("paymentKey"); + } + + /** + * 결제 취소(환불). + * POST https://api.tosspayments.com/v1/payments/{paymentKey}/cancel + */ + public void cancel(String paymentKey, String cancelReason) { + Map body = Map.of("cancelReason", cancelReason); + + webClient.post() + .uri(tossProperties.paymentsUrl() + "/" + paymentKey + "/cancel") + .header(HttpHeaders.AUTHORIZATION, basicAuth(tossProperties.secretKey())) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .onStatus(status -> status.isError(), resp -> resp.bodyToMono(String.class) + .map(err -> { + log.error("토스 결제 취소 실패: {}", err); + return new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED); + })) + .bodyToMono(Void.class) + .block(); + } + + private String basicAuth(String secretKey) { + String credentials = secretKey + ":"; + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/devpick/domain/subscription/config/TossProperties.java b/src/main/java/com/devpick/domain/subscription/config/TossProperties.java new file mode 100644 index 00000000..cc92e5bd --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/config/TossProperties.java @@ -0,0 +1,15 @@ +package com.devpick.domain.subscription.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "toss") +public record TossProperties( + String secretKey, + String clientKey, + String billingUrl, + String paymentsUrl, + int amountPro, + int amountMax, + String orderNamePro, + String orderNameMax +) {} diff --git a/src/main/java/com/devpick/domain/subscription/controller/SubscriptionController.java b/src/main/java/com/devpick/domain/subscription/controller/SubscriptionController.java new file mode 100644 index 00000000..13e6a0c7 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/controller/SubscriptionController.java @@ -0,0 +1,57 @@ +package com.devpick.domain.subscription.controller; + +import com.devpick.domain.subscription.dto.BillingAuthRequest; +import com.devpick.domain.subscription.dto.BillingAuthResponse; +import com.devpick.domain.subscription.dto.PlanChangeRequest; +import com.devpick.domain.subscription.dto.PlanChangeResponse; +import com.devpick.domain.subscription.service.SubscriptionService; +import com.devpick.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@Tag(name = "Subscription", description = "구독 플랜 결제/해지/환불 (DP-495)") +@RestController +@RequestMapping("/subscriptions") +@RequiredArgsConstructor +public class SubscriptionController { + + private final SubscriptionService subscriptionService; + + @Operation(summary = "결제 + 플랜 업그레이드") + @PostMapping("/billing-auth") + @ResponseStatus(HttpStatus.OK) + public ApiResponse billingAuth( + @AuthenticationPrincipal UUID userId, + @RequestBody @Valid BillingAuthRequest request) { + return ApiResponse.ok(subscriptionService.register(userId, request)); + } + + @Operation(summary = "구독 해지 (환불 없음, planExpiredAt까지 플랜 유지)") + @DeleteMapping + public ApiResponse cancelSubscription( + @AuthenticationPrincipal UUID userId) { + return ApiResponse.ok(subscriptionService.cancel(userId)); + } + + @Operation(summary = "결제 취소 + 즉시 환불 (7일 이내, Free 기준치 이내 사용 시)") + @PostMapping("/cancel") + public ApiResponse refund( + @AuthenticationPrincipal UUID userId) { + return ApiResponse.ok(subscriptionService.refund(userId)); + } + + @Operation(summary = "다음 결제 구간부터 플랜 변경 예약 (PRO ↔ MAX)") + @PostMapping("/change") + public ApiResponse changePlan( + @AuthenticationPrincipal UUID userId, + @RequestBody @Valid PlanChangeRequest request) { + return ApiResponse.ok(subscriptionService.changePlan(userId, request)); + } +} diff --git a/src/main/java/com/devpick/domain/subscription/dto/BillingAuthRequest.java b/src/main/java/com/devpick/domain/subscription/dto/BillingAuthRequest.java new file mode 100644 index 00000000..e0a5d63f --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/dto/BillingAuthRequest.java @@ -0,0 +1,11 @@ +package com.devpick.domain.subscription.dto; + +import com.devpick.domain.subscription.entity.PlanType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BillingAuthRequest( + @NotBlank String authKey, + @NotBlank String customerKey, + @NotNull PlanType planType +) {} diff --git a/src/main/java/com/devpick/domain/subscription/dto/BillingAuthResponse.java b/src/main/java/com/devpick/domain/subscription/dto/BillingAuthResponse.java new file mode 100644 index 00000000..60ca6a3e --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/dto/BillingAuthResponse.java @@ -0,0 +1,10 @@ +package com.devpick.domain.subscription.dto; + +import com.devpick.domain.subscription.entity.PlanType; + +import java.time.Instant; + +public record BillingAuthResponse( + PlanType planType, + Instant planExpiredAt +) {} diff --git a/src/main/java/com/devpick/domain/subscription/dto/PlanChangeRequest.java b/src/main/java/com/devpick/domain/subscription/dto/PlanChangeRequest.java new file mode 100644 index 00000000..de1201c3 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/dto/PlanChangeRequest.java @@ -0,0 +1,8 @@ +package com.devpick.domain.subscription.dto; + +import com.devpick.domain.subscription.entity.PlanType; +import jakarta.validation.constraints.NotNull; + +public record PlanChangeRequest( + @NotNull PlanType planType +) {} diff --git a/src/main/java/com/devpick/domain/subscription/dto/PlanChangeResponse.java b/src/main/java/com/devpick/domain/subscription/dto/PlanChangeResponse.java new file mode 100644 index 00000000..250417d1 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/dto/PlanChangeResponse.java @@ -0,0 +1,11 @@ +package com.devpick.domain.subscription.dto; + +import com.devpick.domain.subscription.entity.PlanType; + +import java.time.Instant; + +public record PlanChangeResponse( + PlanType currentPlanType, + PlanType pendingPlanType, + Instant changeEffectiveAt +) {} diff --git a/src/main/java/com/devpick/domain/subscription/dto/PlanLimitInfo.java b/src/main/java/com/devpick/domain/subscription/dto/PlanLimitInfo.java new file mode 100644 index 00000000..ac0a1307 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/dto/PlanLimitInfo.java @@ -0,0 +1,14 @@ +package com.devpick.domain.subscription.dto; + +import java.time.Instant; + +public record PlanLimitInfo( + int used, + int max, + int remaining, + Instant resetsAt +) { + public static PlanLimitInfo unlimited() { + return new PlanLimitInfo(0, -1, -1, null); + } +} diff --git a/src/main/java/com/devpick/domain/subscription/entity/PlanType.java b/src/main/java/com/devpick/domain/subscription/entity/PlanType.java new file mode 100644 index 00000000..83695e6e --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/entity/PlanType.java @@ -0,0 +1,5 @@ +package com.devpick.domain.subscription.entity; + +public enum PlanType { + FREE, PRO, MAX +} diff --git a/src/main/java/com/devpick/domain/subscription/entity/Subscription.java b/src/main/java/com/devpick/domain/subscription/entity/Subscription.java new file mode 100644 index 00000000..f601487c --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/entity/Subscription.java @@ -0,0 +1,88 @@ +package com.devpick.domain.subscription.entity; + +import com.devpick.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "subscriptions", indexes = { + @Index(name = "idx_subscriptions_user_id", columnList = "user_id"), + @Index(name = "idx_subscriptions_status", columnList = "status") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor +public class Subscription extends BaseTimeEntity { + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "toss_billing_key", length = 200, nullable = false) + private String tossBillingKey; + + @Column(name = "toss_customer_key", length = 100, nullable = false) + private String tossCustomerKey; + + @Column(name = "payment_key", length = 200) + private String paymentKey; + + @Enumerated(EnumType.STRING) + @Column(name = "plan_type", length = 20, nullable = false) + private PlanType planType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + @Builder.Default + private SubscriptionStatus status = SubscriptionStatus.ACTIVE; + + @Enumerated(EnumType.STRING) + @Column(name = "pending_plan_type", length = 20) + private PlanType pendingPlanType; + + @Column(name = "amount", nullable = false) + private int amount; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + public void cancel() { + this.status = SubscriptionStatus.CANCELED; + this.pendingPlanType = null; + } + + public void setPendingPlanType(PlanType planType) { + this.pendingPlanType = planType; + } + + public void refund() { + this.status = SubscriptionStatus.REFUNDED; + } + + public void markPaymentFailed() { + this.status = SubscriptionStatus.PAYMENT_FAILED; + } + + public void renew(String newPaymentKey, LocalDateTime newExpiredAt) { + this.paymentKey = newPaymentKey; + this.startedAt = LocalDateTime.now(); + this.expiredAt = newExpiredAt; + this.status = SubscriptionStatus.ACTIVE; + } + + public void renewWithPlan(String newPaymentKey, LocalDateTime newExpiredAt, int newAmount, PlanType newPlanType) { + this.paymentKey = newPaymentKey; + this.startedAt = LocalDateTime.now(); + this.expiredAt = newExpiredAt; + this.amount = newAmount; + this.planType = newPlanType; + this.pendingPlanType = null; + this.status = SubscriptionStatus.ACTIVE; + } +} diff --git a/src/main/java/com/devpick/domain/subscription/entity/SubscriptionStatus.java b/src/main/java/com/devpick/domain/subscription/entity/SubscriptionStatus.java new file mode 100644 index 00000000..289f0977 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/entity/SubscriptionStatus.java @@ -0,0 +1,5 @@ +package com.devpick.domain.subscription.entity; + +public enum SubscriptionStatus { + ACTIVE, CANCELED, REFUNDED, PAYMENT_FAILED +} diff --git a/src/main/java/com/devpick/domain/subscription/repository/SubscriptionRepository.java b/src/main/java/com/devpick/domain/subscription/repository/SubscriptionRepository.java new file mode 100644 index 00000000..2659767b --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/repository/SubscriptionRepository.java @@ -0,0 +1,19 @@ +package com.devpick.domain.subscription.repository; + +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.domain.subscription.entity.Subscription; +import com.devpick.domain.subscription.entity.SubscriptionStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface SubscriptionRepository extends JpaRepository { + + Optional findTopByUserIdAndStatusOrderByStartedAtDesc(UUID userId, SubscriptionStatus status); + + List findByStatusAndPlanTypeInAndExpiredAtBefore( + SubscriptionStatus status, List planTypes, LocalDateTime now); +} diff --git a/src/main/java/com/devpick/domain/subscription/service/PlanLimitService.java b/src/main/java/com/devpick/domain/subscription/service/PlanLimitService.java new file mode 100644 index 00000000..b37b6c6f --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/service/PlanLimitService.java @@ -0,0 +1,162 @@ +package com.devpick.domain.subscription.service; + +import com.devpick.domain.subscription.dto.PlanLimitInfo; +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.IsoFields; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PlanLimitService { + + private final StringRedisTemplate redisTemplate; + + private static final int UNLIMITED = -1; + + // 내부 Redis 키 → 프론트 feature 키 매핑 + private static final Map FEATURE_KEY_MAP = Map.of( + "skill_boost", "skillBoostWeekly", + "interview_qa_gen", "interviewQaGenerateWeekly", + "mock_interview", "mockInterviewWeekly" + ); + + // 플랜별 일 한도 (AI 질문 개선 + AI 답변 합산) + private static final Map AI_DAILY_MAX = Map.of( + PlanType.FREE, 5, + PlanType.PRO, 10, + PlanType.MAX, UNLIMITED + ); + + // 플랜별 주 한도 (채용 AI 기능) + private static final Map> WEEKLY_MAX = Map.of( + "skill_boost", Map.of(PlanType.FREE, 2, PlanType.PRO, 7, PlanType.MAX, UNLIMITED), + "interview_qa_gen", Map.of(PlanType.FREE, 2, PlanType.PRO, 7, PlanType.MAX, UNLIMITED), + "mock_interview", Map.of(PlanType.FREE, 2, PlanType.PRO, 7, PlanType.MAX, UNLIMITED) + ); + + /** + * AI 일 사용량 체크 + 증가. + * Max 유저는 체크 없이 통과. 초과 시 SUBSCRIPTION_LIMIT_EXCEEDED 예외. + */ + public void checkAndIncrementAiDaily(UUID userId, PlanType planType) { + if (planType == PlanType.MAX) return; + + int max = AI_DAILY_MAX.get(planType); + String key = dailyKey("ai", userId); + long used = increment(key, dailyTtlSeconds()); + + if (used > max) { + redisTemplate.opsForValue().decrement(key); + throw new DevpickException(ErrorCode.SUBSCRIPTION_LIMIT_EXCEEDED, + Map.of("feature", "aiDaily", + "resetsAt", nextMidnightUtc().toString(), + "requiredPlan", planType == PlanType.FREE ? "PRO" : "MAX")); + } + } + + /** + * 주간 사용량 체크 + 증가. + * Max 유저는 체크 없이 통과. 초과 시 SUBSCRIPTION_LIMIT_EXCEEDED 예외. + */ + public void checkAndIncrementWeekly(UUID userId, PlanType planType, String feature) { + if (planType == PlanType.MAX) return; + + int max = WEEKLY_MAX.get(feature).get(planType); + String key = weeklyKey(feature, userId); + long used = increment(key, weeklyTtlSeconds()); + + if (used > max) { + redisTemplate.opsForValue().decrement(key); + throw new DevpickException(ErrorCode.SUBSCRIPTION_LIMIT_EXCEEDED, + Map.of("feature", FEATURE_KEY_MAP.getOrDefault(feature, feature), + "resetsAt", nextMondayUtc().toString(), + "requiredPlan", planType == PlanType.FREE ? "PRO" : "MAX")); + } + } + + /** + * 현재 기간 사용량이 Free 기준치를 하나라도 초과했으면 true. + * 환불 자격 검증에 사용. + */ + public boolean exceedsFreeLimit(UUID userId) { + if (getCount(dailyKey("ai", userId)) > AI_DAILY_MAX.get(PlanType.FREE)) return true; + for (String feature : WEEKLY_MAX.keySet()) { + if (getCount(weeklyKey(feature, userId)) > WEEKLY_MAX.get(feature).get(PlanType.FREE)) return true; + } + return false; + } + + /** /users/me 응답용 — AI 일 사용량 조회 (카운터 증가 없음). */ + public PlanLimitInfo getAiDailyInfo(UUID userId, PlanType planType) { + if (planType == PlanType.MAX) return PlanLimitInfo.unlimited(); + int max = AI_DAILY_MAX.get(planType); + int used = getCount(dailyKey("ai", userId)); + return new PlanLimitInfo(used, max, Math.max(0, max - used), nextMidnightUtc()); + } + + /** /users/me 응답용 — 주간 사용량 조회 (카운터 증가 없음). */ + public PlanLimitInfo getWeeklyInfo(UUID userId, PlanType planType, String feature) { + if (planType == PlanType.MAX) return PlanLimitInfo.unlimited(); + int max = WEEKLY_MAX.get(feature).get(planType); + int used = getCount(weeklyKey(feature, userId)); + return new PlanLimitInfo(used, max, Math.max(0, max - used), nextMondayUtc()); + } + + // ── 내부 헬퍼 ────────────────────────────────────────────────── + + private long increment(String key, long ttlSeconds) { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1) { + redisTemplate.expire(key, java.time.Duration.ofSeconds(ttlSeconds)); + } + return count != null ? count : 1; + } + + private int getCount(String key) { + String val = redisTemplate.opsForValue().get(key); + return val == null ? 0 : Integer.parseInt(val); + } + + private String dailyKey(String feature, UUID userId) { + String date = LocalDate.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE); + return "plan:" + feature + ":" + userId + ":" + date; + } + + private String weeklyKey(String feature, UUID userId) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + int week = today.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + int year = today.get(IsoFields.WEEK_BASED_YEAR); + return "plan:" + feature + ":" + userId + ":" + year + "-W" + String.format("%02d", week); + } + + private long dailyTtlSeconds() { + Instant midnight = nextMidnightUtc(); + return midnight.getEpochSecond() - Instant.now().getEpochSecond(); + } + + private long weeklyTtlSeconds() { + Instant monday = nextMondayUtc(); + return monday.getEpochSecond() - Instant.now().getEpochSecond(); + } + + private Instant nextMidnightUtc() { + return LocalDate.now(ZoneOffset.UTC).plusDays(1) + .atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private Instant nextMondayUtc() { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + int daysUntilMonday = (8 - today.getDayOfWeek().getValue()) % 7; + if (daysUntilMonday == 0) daysUntilMonday = 7; + return today.plusDays(daysUntilMonday).atStartOfDay(ZoneOffset.UTC).toInstant(); + } +} diff --git a/src/main/java/com/devpick/domain/subscription/service/SubscriptionService.java b/src/main/java/com/devpick/domain/subscription/service/SubscriptionService.java new file mode 100644 index 00000000..fd3f56a1 --- /dev/null +++ b/src/main/java/com/devpick/domain/subscription/service/SubscriptionService.java @@ -0,0 +1,210 @@ +package com.devpick.domain.subscription.service; + +import com.devpick.domain.subscription.client.TossPaymentsClient; +import com.devpick.domain.subscription.config.TossProperties; +import com.devpick.domain.subscription.dto.BillingAuthRequest; +import com.devpick.domain.subscription.dto.BillingAuthResponse; +import com.devpick.domain.subscription.dto.PlanChangeRequest; +import com.devpick.domain.subscription.dto.PlanChangeResponse; +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.domain.subscription.entity.Subscription; +import com.devpick.domain.subscription.entity.SubscriptionStatus; +import com.devpick.domain.subscription.repository.SubscriptionRepository; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import com.devpick.domain.subscription.service.PlanLimitService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscriptionService { + + private final SubscriptionRepository subscriptionRepository; + private final UserRepository userRepository; + private final TossPaymentsClient tossPaymentsClient; + private final TossProperties tossProperties; + private final PlanLimitService planLimitService; + + /** + * 빌링키 등록 + 즉시 첫 결제 + 플랜 업그레이드. + */ + @Transactional + public BillingAuthResponse register(UUID userId, BillingAuthRequest request) { + User user = findUser(userId); + + if (!user.isFree()) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE); + } + + String billingKey = tossPaymentsClient.issueBillingKey(request.authKey(), request.customerKey()); + + int amount = request.planType() == PlanType.PRO ? tossProperties.amountPro() : tossProperties.amountMax(); + String orderName = request.planType() == PlanType.PRO ? tossProperties.orderNamePro() : tossProperties.orderNameMax(); + String orderId = UUID.randomUUID().toString(); + + String paymentKey = tossPaymentsClient.charge(billingKey, request.customerKey(), amount, orderName, orderId); + + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime expiredAt = now.plusMonths(1); + + Subscription subscription = Subscription.builder() + .userId(userId) + .tossBillingKey(billingKey) + .tossCustomerKey(request.customerKey()) + .paymentKey(paymentKey) + .planType(request.planType()) + .status(SubscriptionStatus.ACTIVE) + .amount(amount) + .startedAt(now) + .expiredAt(expiredAt) + .build(); + subscriptionRepository.save(subscription); + + user.upgradePlan(request.planType(), billingKey, request.customerKey(), null); + + return new BillingAuthResponse( + user.getPlanType(), + expiredAt.toInstant(ZoneOffset.UTC) + ); + } + + /** + * 구독 해지 — 환불 없음. planExpiredAt까지 플랜 유지. + */ + @Transactional + public BillingAuthResponse cancel(UUID userId) { + Subscription subscription = findActiveSubscription(userId); + subscription.cancel(); + + User user = findUser(userId); + user.extendPlan(subscription.getExpiredAt()); + + return new BillingAuthResponse( + user.getPlanType(), + subscription.getExpiredAt().toInstant(ZoneOffset.UTC) + ); + } + + /** + * 결제 취소 + 즉시 환불 + FREE 전환. + * 조건: 결제일로부터 7일 이내 + Free 기준치 초과 사용 없음. + */ + @Transactional + public BillingAuthResponse refund(UUID userId) { + Subscription subscription = findActiveSubscription(userId); + + if (subscription.getStartedAt().isBefore(LocalDateTime.now(ZoneOffset.UTC).minusDays(7))) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_REFUND_PERIOD_EXPIRED); + } + if (planLimitService.exceedsFreeLimit(userId)) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_REFUND_USAGE_EXCEEDED); + } + + User user = findUser(userId); + tossPaymentsClient.cancel(subscription.getPaymentKey(), "구독 취소 환불"); + + subscription.refund(); + user.downgradeToFree(); + + return new BillingAuthResponse(PlanType.FREE, null); + } + + /** + * 다음 결제 구간부터 플랜 변경 예약. + * PRO ↔ MAX 간 변경만 허용. 현재 플랜과 동일하거나 FREE 요청 시 예외. + */ + @Transactional + public PlanChangeResponse changePlan(UUID userId, PlanChangeRequest request) { + if (request.planType() == PlanType.FREE) { + throw new DevpickException(ErrorCode.INVALID_INPUT); + } + + Subscription subscription = findActiveSubscription(userId); + User user = findUser(userId); + + if (user.getPlanType() == request.planType()) { + // 현재 플랜과 동일 = 변경 예약 취소 + if (subscription.getPendingPlanType() == null) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE); + } + subscription.setPendingPlanType(null); + return new PlanChangeResponse( + user.getPlanType(), + null, + subscription.getExpiredAt().toInstant(ZoneOffset.UTC) + ); + } + + subscription.setPendingPlanType(request.planType()); + + return new PlanChangeResponse( + user.getPlanType(), + request.planType(), + subscription.getExpiredAt().toInstant(ZoneOffset.UTC) + ); + } + + /** + * 매주 월요일 09:00 — planExpiredAt 도래한 유료 구독 자동 결제. + */ + @Scheduled(cron = "0 0 9 * * MON") + @Transactional + public void renewSubscriptions() { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + List due = subscriptionRepository.findByStatusAndPlanTypeInAndExpiredAtBefore( + SubscriptionStatus.ACTIVE, List.of(PlanType.PRO, PlanType.MAX), now); + + for (Subscription sub : due) { + try { + User user = userRepository.findById(sub.getUserId()).orElse(null); + if (user == null) continue; + + PlanType nextPlan = sub.getPendingPlanType() != null + ? sub.getPendingPlanType() : sub.getPlanType(); + int nextAmount = nextPlan == PlanType.PRO + ? tossProperties.amountPro() : tossProperties.amountMax(); + String orderName = nextPlan == PlanType.PRO + ? tossProperties.orderNamePro() : tossProperties.orderNameMax(); + String orderId = UUID.randomUUID().toString(); + + String newPaymentKey = tossPaymentsClient.charge( + sub.getTossBillingKey(), sub.getTossCustomerKey(), + nextAmount, orderName, orderId); + + LocalDateTime newExpiredAt = sub.getExpiredAt().plusMonths(1); + sub.renewWithPlan(newPaymentKey, newExpiredAt, nextAmount, nextPlan); + user.upgradePlan(nextPlan, sub.getTossBillingKey(), sub.getTossCustomerKey(), null); + + } catch (Exception e) { + log.error("자동 결제 실패 userId={}: {}", sub.getUserId(), e.getMessage()); + sub.markPaymentFailed(); + userRepository.findById(sub.getUserId()).ifPresent(User::downgradeToFree); + } + } + } + + // ── 헬퍼 ────────────────────────────────────────────────────── + + private User findUser(UUID userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); + } + + private Subscription findActiveSubscription(UUID userId) { + return subscriptionRepository + .findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE) + .orElseThrow(() -> new DevpickException(ErrorCode.SUBSCRIPTION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/devpick/domain/user/dto/UserProfileResponse.java b/src/main/java/com/devpick/domain/user/dto/UserProfileResponse.java index 098492fd..2414d329 100644 --- a/src/main/java/com/devpick/domain/user/dto/UserProfileResponse.java +++ b/src/main/java/com/devpick/domain/user/dto/UserProfileResponse.java @@ -1,6 +1,8 @@ package com.devpick.domain.user.dto; import com.devpick.domain.point.dto.RepresentativeBadgeDto; +import com.devpick.domain.subscription.dto.PlanLimitInfo; +import com.devpick.domain.subscription.entity.PlanType; import com.devpick.domain.user.entity.Job; import com.devpick.domain.user.entity.Level; import com.devpick.domain.user.entity.User; @@ -8,6 +10,7 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; import java.util.UUID; public record UserProfileResponse( @@ -20,9 +23,19 @@ public record UserProfileResponse( List tags, Instant createdAt, int totalPoints, - RepresentativeBadgeDto representativeBadge + RepresentativeBadgeDto representativeBadge, + PlanType planType, + Instant planExpiredAt, + PlanType pendingPlanType, + Instant lastBilledAt, + Map limits ) { - public static UserProfileResponse of(User user, RepresentativeBadgeDto representativeBadge) { + public static UserProfileResponse of( + User user, + RepresentativeBadgeDto representativeBadge, + PlanType pendingPlanType, + Instant lastBilledAt, + Map limits) { List tagNames = user.getUserTags().stream() .map(ut -> ut.getTag().getName()) .toList(); @@ -36,7 +49,12 @@ public static UserProfileResponse of(User user, RepresentativeBadgeDto represent tagNames, user.getCreatedAt() != null ? user.getCreatedAt().toInstant(ZoneOffset.UTC) : null, user.getTotalPoints(), - representativeBadge + representativeBadge, + user.getPlanType(), + user.getPlanExpiredAt() != null ? user.getPlanExpiredAt().toInstant(ZoneOffset.UTC) : null, + pendingPlanType, + lastBilledAt, + limits ); } } diff --git a/src/main/java/com/devpick/domain/user/entity/User.java b/src/main/java/com/devpick/domain/user/entity/User.java index 511a3319..9d499e27 100644 --- a/src/main/java/com/devpick/domain/user/entity/User.java +++ b/src/main/java/com/devpick/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.devpick.domain.user.entity; +import com.devpick.domain.subscription.entity.PlanType; import com.devpick.global.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -54,6 +55,20 @@ public class User extends BaseTimeEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + @Enumerated(EnumType.STRING) + @Column(name = "plan_type", length = 20, nullable = false) + @Builder.Default + private PlanType planType = PlanType.FREE; + + @Column(name = "toss_billing_key", length = 200) + private String tossBillingKey; + + @Column(name = "toss_customer_key", length = 100) + private String tossCustomerKey; + + @Column(name = "plan_expired_at") + private LocalDateTime planExpiredAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List userTags = new ArrayList<>(); @@ -154,4 +169,24 @@ public boolean isRecoverable() { && this.deletedAt != null && this.deletedAt.plusDays(7).isAfter(LocalDateTime.now()); } + + public boolean isFree() { return planType == PlanType.FREE; } + public boolean isProOrAbove() { return planType == PlanType.PRO || planType == PlanType.MAX; } + public boolean isMax() { return planType == PlanType.MAX; } + + public void upgradePlan(PlanType type, String billingKey, String customerKey, LocalDateTime expiredAt) { + this.planType = type; + this.tossBillingKey = billingKey; + this.tossCustomerKey = customerKey; + this.planExpiredAt = expiredAt; + } + + public void downgradeToFree() { + this.planType = PlanType.FREE; + this.planExpiredAt = null; + } + + public void extendPlan(LocalDateTime newExpiredAt) { + this.planExpiredAt = newExpiredAt; + } } diff --git a/src/main/java/com/devpick/domain/user/service/UserService.java b/src/main/java/com/devpick/domain/user/service/UserService.java index adf2f643..c2d8b536 100644 --- a/src/main/java/com/devpick/domain/user/service/UserService.java +++ b/src/main/java/com/devpick/domain/user/service/UserService.java @@ -4,6 +4,11 @@ import com.devpick.domain.community.repository.PostRepository; import com.devpick.domain.point.repository.UserBadgeRepository; import com.devpick.domain.point.service.BadgeService; +import com.devpick.domain.subscription.dto.PlanLimitInfo; +import com.devpick.domain.subscription.entity.Subscription; +import com.devpick.domain.subscription.entity.SubscriptionStatus; +import com.devpick.domain.subscription.repository.SubscriptionRepository; +import com.devpick.domain.subscription.service.PlanLimitService; import com.devpick.domain.user.dto.PublicUserProfileResponse; import com.devpick.domain.user.dto.UserProfileResponse; import com.devpick.domain.user.dto.UserProfileUpdateRequest; @@ -23,7 +28,10 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; @Service @@ -39,6 +47,8 @@ public class UserService { private final PostRepository postRepository; private final AnswerRepository answerRepository; private final FileStorageService fileStorageService; + private final PlanLimitService planLimitService; + private final SubscriptionRepository subscriptionRepository; @Transactional(readOnly = true) public PublicUserProfileResponse getPublicProfile(UUID targetUserId) { @@ -55,7 +65,31 @@ public PublicUserProfileResponse getPublicProfile(UUID targetUserId) { @Transactional(readOnly = true) public UserProfileResponse getProfile(UUID userId) { User user = findActiveUser(userId); - return UserProfileResponse.of(user, badgeService.getRepresentativeBadge(user.getId()).orElse(null)); + + var activeSubscription = subscriptionRepository + .findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE); + + java.time.Instant lastBilledAt = activeSubscription + .map(Subscription::getStartedAt) + .map(ldt -> ldt.toInstant(ZoneOffset.UTC)) + .orElse(null); + + com.devpick.domain.subscription.entity.PlanType pendingPlanType = activeSubscription + .map(Subscription::getPendingPlanType) + .orElse(null); + + Map limits = new LinkedHashMap<>(); + limits.put("aiDaily", planLimitService.getAiDailyInfo(userId, user.getPlanType())); + limits.put("skillBoostWeekly", planLimitService.getWeeklyInfo(userId, user.getPlanType(), "skill_boost")); + limits.put("interviewQaGenerateWeekly", planLimitService.getWeeklyInfo(userId, user.getPlanType(), "interview_qa_gen")); + limits.put("mockInterviewWeekly", planLimitService.getWeeklyInfo(userId, user.getPlanType(), "mock_interview")); + + return UserProfileResponse.of( + user, + badgeService.getRepresentativeBadge(user.getId()).orElse(null), + pendingPlanType, + lastBilledAt, + limits); } @Transactional @@ -96,6 +130,20 @@ public void deleteAccount(UUID userId) { refreshTokenRepository.deleteByUser(user); } + /** + * Free 유저가 본인 레벨 외 다른 레벨을 요청하면 SUBSCRIPTION_PLAN_REQUIRED 예외를 던진다. + */ + @Transactional(readOnly = true) + public void checkAiLevelAccess(UUID userId, String requestedLevel) { + if (userId == null || requestedLevel == null || requestedLevel.isBlank()) return; + User user = userRepository.findByIdAndIsActiveTrue(userId).orElse(null); + if (user == null) return; + if (user.isFree() && !user.getLevel().name().equalsIgnoreCase(requestedLevel.trim())) { + throw new DevpickException(ErrorCode.SUBSCRIPTION_PLAN_REQUIRED, + Map.of("requiredPlan", "PRO")); + } + } + /** * AI 요약·퀴즈 API의 {@code level} 쿼리가 비어 있을 때 사용한다. * 값이 있으면 trim 한 문자열을 그대로 쓰고, 없으면 로그인 사용자는 프로필 {@link com.devpick.domain.user.entity.Level}, diff --git a/src/main/java/com/devpick/global/common/exception/ErrorCode.java b/src/main/java/com/devpick/global/common/exception/ErrorCode.java index 46b3bcf2..397d3d41 100644 --- a/src/main/java/com/devpick/global/common/exception/ErrorCode.java +++ b/src/main/java/com/devpick/global/common/exception/ErrorCode.java @@ -114,7 +114,17 @@ public enum ErrorCode { JOB_BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_002", "북마크한 공고가 아닙니다."), RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "RESUME_001", "마스터 이력서가 없습니다. 먼저 작성해 주세요."), RESUME_DOCUMENT_TEXT_EMPTY(HttpStatus.BAD_REQUEST, "RESUME_003", "이력서 파일에서 텍스트를 추출하지 못했습니다. 다른 형식으로 저장했는지 확인해 주세요."), - INTERVIEW_QA_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_003", "면접 Q&A가 없습니다."); + INTERVIEW_QA_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_003", "면접 Q&A가 없습니다."), + JOB_SKILL_GAP_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_004", "저장된 부족 역량 분석 결과가 없습니다."), + + // Subscription / Payment (DP-495) + SUBSCRIPTION_ALREADY_ACTIVE(HttpStatus.CONFLICT, "PAYMENT_001", "이미 구독 중입니다."), + SUBSCRIPTION_PAYMENT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PAYMENT_002", "결제 처리 중 오류가 발생했습니다."), + SUBSCRIPTION_PLAN_REQUIRED(HttpStatus.FORBIDDEN, "PAYMENT_003", "상위 플랜이 필요한 기능입니다."), + SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_004", "구독 정보를 찾을 수 없습니다."), + SUBSCRIPTION_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "PAYMENT_005", "플랜 사용 한도를 초과했습니다."), + SUBSCRIPTION_REFUND_PERIOD_EXPIRED(HttpStatus.CONFLICT, "PAYMENT_006", "결제 취소 가능 기간(7일)이 지났습니다."), + SUBSCRIPTION_REFUND_USAGE_EXCEEDED(HttpStatus.CONFLICT, "PAYMENT_007", "프리 플랜 기준을 초과하여 사용한 이력이 있어 결제 취소가 불가합니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/devpick/global/config/SecurityConfig.java b/src/main/java/com/devpick/global/config/SecurityConfig.java index b4f352a9..216da4ba 100644 --- a/src/main/java/com/devpick/global/config/SecurityConfig.java +++ b/src/main/java/com/devpick/global/config/SecurityConfig.java @@ -48,7 +48,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.GET, "/health", "/api/health", "/actuator/health") + .requestMatchers(HttpMethod.GET, "/health", "/api/health", "/actuator/health", + "/actuator/prometheus") .permitAll() .requestMatchers("/auth/**").permitAll() .requestMatchers("/reports/weekly/share/**").permitAll() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ab5c4b1..e01cc51e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -34,12 +34,33 @@ app: aladin: ttb-key: ${ALADIN_TTB_KEY:} +toss: + secret-key: ${TOSS_SECRET_KEY:test_sk_xxx} + client-key: ${TOSS_CLIENT_KEY:test_ck_xxx} + billing-url: https://api.tosspayments.com/v1/billing + payments-url: https://api.tosspayments.com/v1/payments + amount-pro: 9900 + amount-max: 19900 + order-name-pro: "Trace Pro 월정액" + order-name-max: "Trace Max 월정액" + # Actuator: EC2/로드밸런서에서 /actuator/health 사용 시 노출 필요 +# Prometheus: /actuator/prometheus — 운영에서는 방화벽/VPC 내 스크레이프만 허용할 것 management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus endpoint: health: show-details: never + prometheus: + metrics: + export: + enabled: true + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.95, 0.99 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 2fff0c61..84383cd0 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,3 +1,6 @@ +-- DP-495: plan_type NULL 백필 — 컬럼 추가 전 생성된 사용자 FREE 처리 +UPDATE users SET plan_type = 'FREE' WHERE plan_type IS NULL; + -- DP-467: post_type NULL 백필 — 컬럼 추가 전 생성된 게시글 보정 및 NOT NULL 제약 적용 UPDATE posts SET post_type = 'TECH' WHERE post_type IS NULL; ALTER TABLE posts ALTER COLUMN post_type SET NOT NULL; diff --git a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java index 43de5060..eb6ebe5b 100644 --- a/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AiAnswerControllerTest.java @@ -23,6 +23,8 @@ import java.util.List; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -71,7 +73,7 @@ void setUp() { @Test @DisplayName("POST /posts/{postId}/ai-answer - AI 답변 생성 성공 시 200 반환") void generateAiAnswer_success_returns200() throws Exception { - given(aiAnswerService.generateOrGetAnswer(postId)).willReturn(aiAnswerResponse); + given(aiAnswerService.generateOrGetAnswer(any(), eq(postId))).willReturn(aiAnswerResponse); mockMvc.perform(post("/posts/" + postId + "/ai-answer")) .andExpect(status().isOk()) @@ -86,7 +88,7 @@ void generateAiAnswer_success_returns200() throws Exception { @Test @DisplayName("POST /posts/{postId}/ai-answer - 게시글 없으면 404 반환") void generateAiAnswer_postNotFound_returns404() throws Exception { - given(aiAnswerService.generateOrGetAnswer(postId)) + given(aiAnswerService.generateOrGetAnswer(any(), eq(postId))) .willThrow(new DevpickException(ErrorCode.COMMUNITY_POST_NOT_FOUND)); mockMvc.perform(post("/posts/" + postId + "/ai-answer")) @@ -97,7 +99,7 @@ void generateAiAnswer_postNotFound_returns404() throws Exception { @Test @DisplayName("POST /posts/{postId}/ai-answer - AI 서버 오류 시 500 반환") void generateAiAnswer_aiServerError_returns500() throws Exception { - given(aiAnswerService.generateOrGetAnswer(postId)) + given(aiAnswerService.generateOrGetAnswer(any(), eq(postId))) .willThrow(new DevpickException(ErrorCode.AI_SERVER_ERROR)); mockMvc.perform(post("/posts/" + postId + "/ai-answer")) diff --git a/src/test/java/com/devpick/domain/community/controller/AiQuestionControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AiQuestionControllerTest.java index 084197f3..f9315a71 100644 --- a/src/test/java/com/devpick/domain/community/controller/AiQuestionControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AiQuestionControllerTest.java @@ -16,6 +16,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -45,6 +46,7 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(aiQuestionController) .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) .build(); } @@ -57,7 +59,7 @@ void refine_success_returns200() throws Exception { "Spring Framework 핵심 개념이란?", "Spring Framework의 IoC, DI, AOP에 대해 설명해주세요.", List.of("IoC/DI 개념을 명시하세요")); - given(aiQuestionService.refine(any())).willReturn(response); + given(aiQuestionService.refine(any(), any())).willReturn(response); mockMvc.perform(post("/posts/refine") .contentType(MediaType.APPLICATION_JSON) @@ -85,7 +87,7 @@ void refine_validationFails_returns400() throws Exception { void refine_aiServerError_returns500() throws Exception { QuestionRefineRequest request = new QuestionRefineRequest( "title", "content", Level.JUNIOR, null); - given(aiQuestionService.refine(any())) + given(aiQuestionService.refine(any(), any())) .willThrow(new DevpickException(ErrorCode.AI_SERVER_ERROR)); mockMvc.perform(post("/posts/refine") diff --git a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java index b3a1ed43..ba21a844 100644 --- a/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AiAnswerServiceTest.java @@ -9,7 +9,9 @@ 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.entity.Level; +import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -46,6 +48,10 @@ class AiAnswerServiceTest { private PostRepository postRepository; @Mock private AiAnswerClient aiAnswerClient; + @Mock + private UserRepository userRepository; + @Mock + private PlanLimitService planLimitService; private UUID postId; private Post post; @@ -75,7 +81,7 @@ void setUp() { void generateOrGetAnswer_postNotFound_throwsException() { given(postRepository.findById(postId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> aiAnswerService.generateOrGetAnswer(postId)) + assertThatThrownBy(() -> aiAnswerService.generateOrGetAnswer(null, postId)) .isInstanceOf(DevpickException.class) .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) .isEqualTo(ErrorCode.COMMUNITY_POST_NOT_FOUND)); @@ -100,7 +106,7 @@ void generateOrGetAnswer_existingAnswer_returnsExisting() { given(postRepository.findById(postId)).willReturn(Optional.of(post)); given(aiAnswerRepository.findByPost_Id(postId)).willReturn(Optional.of(existing)); - AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(postId); + AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(null, postId); assertThat(result.id()).isEqualTo(answerId); assertThat(result.content()).isEqualTo("기존 AI 답변"); @@ -130,7 +136,7 @@ void generateOrGetAnswer_noExisting_callsFastApiAndSavesAllFields() { given(aiAnswerClient.generateAnswer(post, null)).willReturn(fakeAiResponse); given(aiAnswerRepository.save(any(AiAnswer.class))).willReturn(saved); - AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(postId); + AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(null, postId); assertThat(result.id()).isEqualTo(answerId); assertThat(result.content()).isEqualTo("AI가 생성한 답변"); @@ -155,7 +161,7 @@ void generateOrGetAnswer_careerPost_throwsException() { given(postRepository.findById(postId)).willReturn(Optional.of(careerPost)); - assertThatThrownBy(() -> aiAnswerService.generateOrGetAnswer(postId)) + assertThatThrownBy(() -> aiAnswerService.generateOrGetAnswer(null, postId)) .isInstanceOf(DevpickException.class) .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) .isEqualTo(ErrorCode.COMMUNITY_AI_NOT_SUPPORTED)); @@ -189,9 +195,34 @@ void generateOrGetAnswer_withRefinedQuestion_passesRefinedData() { given(aiAnswerClient.generateAnswer(post, aiQuestion)).willReturn(fakeAiResponse); given(aiAnswerRepository.save(any(AiAnswer.class))).willReturn(saved); - AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(postId); + AiAnswerResponse result = aiAnswerService.generateOrGetAnswer(null, postId); assertThat(result.content()).isEqualTo("refined 기반 답변"); verify(aiAnswerClient).generateAnswer(post, aiQuestion); } + + @Test + @DisplayName("userId가 null이 아니면 플랜 제한 체크 후 AI 답변을 생성한다") + void generateOrGetAnswer_withUserId_callsPlanLimitCheck() { + UUID userId = UUID.randomUUID(); + com.devpick.domain.user.entity.User user = com.devpick.domain.user.entity.User.builder() + .email("u@t.kr").nickname("u") + .job(com.devpick.domain.user.entity.Job.BACKEND) + .level(Level.JUNIOR).build(); + AiAnswer saved = AiAnswer.builder() + .post(post).content("답변").keyPoints(java.util.List.of()) + .suggestedTags(java.util.List.of()).confidence(0.9).build(); + org.springframework.test.util.ReflectionTestUtils.setField(saved, "id", UUID.randomUUID()); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(postRepository.findById(postId)).willReturn(Optional.of(post)); + given(aiAnswerRepository.findByPost_Id(postId)).willReturn(Optional.empty()); + given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.empty()); + given(aiAnswerClient.generateAnswer(any(), any())).willReturn(fakeAiResponse); + given(aiAnswerRepository.save(any())).willReturn(saved); + + aiAnswerService.generateOrGetAnswer(userId, postId); + + verify(planLimitService).checkAndIncrementAiDaily(userId, user.getPlanType()); + } } diff --git a/src/test/java/com/devpick/domain/community/service/AiQuestionServiceTest.java b/src/test/java/com/devpick/domain/community/service/AiQuestionServiceTest.java index 223db462..58dadf64 100644 --- a/src/test/java/com/devpick/domain/community/service/AiQuestionServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AiQuestionServiceTest.java @@ -7,7 +7,9 @@ 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.entity.Level; +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.JsonProcessingException; @@ -47,6 +49,10 @@ class AiQuestionServiceTest { private PostRepository postRepository; @Mock private ObjectMapper objectMapper; + @Mock + private UserRepository userRepository; + @Mock + private PlanLimitService planLimitService; private UUID postId; private Post post; @@ -73,7 +79,7 @@ void refine_withoutPostId_returnsResponseWithoutSaving() { List.of("IoC/DI 개념을 명시하면 더 좋은 답변을 받을 수 있어요")); given(aiQuestionClient.refine(request)).willReturn(expected); - QuestionRefineResponse response = aiQuestionService.refine(request); + QuestionRefineResponse response = aiQuestionService.refine(null, request); assertThat(response.refinedTitle()).isEqualTo("Spring Framework 핵심 개념이란?"); assertThat(response.suggestions()).hasSize(1); @@ -95,7 +101,7 @@ void refine_withPostId_savesAiQuestion() throws Exception { given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.empty()); given(objectMapper.writeValueAsString(any())).willReturn("[\"IoC 태그 추가 권장\"]"); - QuestionRefineResponse response = aiQuestionService.refine(request); + QuestionRefineResponse response = aiQuestionService.refine(null, request); assertThat(response.refinedTitle()).isEqualTo("Spring Framework 핵심 개념이란?"); verify(aiQuestionRepository).save(any(AiQuestion.class)); @@ -111,7 +117,7 @@ void refine_withPostIdButNoPost_returnsResponseWithoutSaving() { given(aiQuestionClient.refine(request)).willReturn(expected); given(postRepository.findById(postId)).willReturn(Optional.empty()); - QuestionRefineResponse response = aiQuestionService.refine(request); + QuestionRefineResponse response = aiQuestionService.refine(null, request); assertThat(response.refinedTitle()).isEqualTo("refined"); verify(aiQuestionRepository, never()).save(any()); @@ -135,7 +141,7 @@ void refine_whenAiQuestionAlreadyExists_skipsSave() throws Exception { given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.of(existing)); given(objectMapper.writeValueAsString(any())).willReturn("[]"); - aiQuestionService.refine(request); + aiQuestionService.refine(null, request); verify(aiQuestionRepository, times(1)).findByPost_Id(postId); verify(aiQuestionRepository, never()).save(any()); @@ -152,7 +158,7 @@ void refine_suggestionsSerializeFails_stillSaves() throws Exception { given(aiQuestionRepository.findByPost_Id(postId)).willReturn(Optional.empty()); given(objectMapper.writeValueAsString(any())).willThrow(new JsonProcessingException("fail") {}); - aiQuestionService.refine(request); + aiQuestionService.refine(null, request); verify(aiQuestionRepository).save(any(AiQuestion.class)); } @@ -164,9 +170,28 @@ void refine_aiServerError_throwsException() { given(aiQuestionClient.refine(request)) .willThrow(new DevpickException(ErrorCode.AI_SERVER_ERROR)); - assertThatThrownBy(() -> aiQuestionService.refine(request)) + assertThatThrownBy(() -> aiQuestionService.refine(null, request)) .isInstanceOf(DevpickException.class) .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) .isEqualTo(ErrorCode.AI_SERVER_ERROR)); } + + @Test + @DisplayName("userId가 null이 아니면 플랜 제한 체크 후 질문을 개선한다") + void refine_withUserId_callsPlanLimitCheck() { + UUID userId = UUID.randomUUID(); + com.devpick.domain.user.entity.User user = com.devpick.domain.user.entity.User.builder() + .email("u@t.kr").nickname("u") + .job(com.devpick.domain.user.entity.Job.BACKEND) + .level(Level.JUNIOR).build(); + QuestionRefineRequest request = new QuestionRefineRequest("title", "content", Level.JUNIOR, null); + QuestionRefineResponse response = new QuestionRefineResponse("refined", "content", List.of()); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(aiQuestionClient.refine(request)).willReturn(response); + + aiQuestionService.refine(userId, request); + + verify(planLimitService).checkAndIncrementAiDaily(userId, user.getPlanType()); + } } diff --git a/src/test/java/com/devpick/domain/job/service/JobInterviewServiceTest.java b/src/test/java/com/devpick/domain/job/service/JobInterviewServiceTest.java new file mode 100644 index 00000000..a12bb9db --- /dev/null +++ b/src/test/java/com/devpick/domain/job/service/JobInterviewServiceTest.java @@ -0,0 +1,71 @@ +package com.devpick.domain.job.service; + +import com.devpick.domain.job.client.JobAiClient; +import com.devpick.domain.job.repository.JobInterviewQaRepository; +import com.devpick.domain.job.repository.JobPostingRepository; +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.entity.Job; +import com.devpick.domain.user.entity.Level; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class JobInterviewServiceTest { + + @InjectMocks private JobInterviewService jobInterviewService; + @Mock private JobPostingRepository jobPostingRepository; + @Mock private JobInterviewQaRepository jobInterviewQaRepository; + @Mock private MasterResumeRepository masterResumeRepository; + @Mock private ResumeCryptoService resumeCryptoService; + @Mock private JobAiClient jobAiClient; + @Mock private JobService jobService; + @Mock private UserRepository userRepository; + @Mock private PlanLimitService planLimitService; + + @Test + @DisplayName("generateAndSave — 유저 없으면 USER_NOT_FOUND 예외") + void generateAndSave_userNotFound_throwsException() { + given(userRepository.findById(any())).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jobInterviewService.generateAndSave(UUID.randomUUID(), UUID.randomUUID())) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("generateAndSave — 유저 있으면 플랜 제한 체크 후 채용공고 조회") + void generateAndSave_withUser_callsPlanLimitThenFetchesJob() { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + User user = User.builder().email("u@t.kr").nickname("u") + .job(Job.BACKEND).level(Level.JUNIOR).build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(jobPostingRepository.findById(jobId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jobInterviewService.generateAndSave(userId, jobId)) + .isInstanceOf(DevpickException.class); + + verify(planLimitService).checkAndIncrementWeekly(userId, user.getPlanType(), "interview_qa_gen"); + } +} diff --git a/src/test/java/com/devpick/domain/job/service/JobSkillGapServiceTest.java b/src/test/java/com/devpick/domain/job/service/JobSkillGapServiceTest.java index 2cf52dc4..63ef35ba 100644 --- a/src/test/java/com/devpick/domain/job/service/JobSkillGapServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/JobSkillGapServiceTest.java @@ -6,16 +6,25 @@ import com.devpick.domain.job.entity.EmploymentType; import com.devpick.domain.job.entity.JobPosting; import com.devpick.domain.job.entity.JobPostingCategory; +import com.devpick.domain.job.entity.JobSkillGap; import com.devpick.domain.job.entity.PostingExperienceLevel; import com.devpick.domain.job.repository.JobBookmarkRepository; import com.devpick.domain.job.repository.JobPostingRepository; +import com.devpick.domain.job.repository.JobSkillGapRepository; import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.resume.entity.MasterResume; 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.entity.Job; +import com.devpick.domain.user.entity.Level; +import com.devpick.domain.user.entity.User; import com.devpick.domain.user.repository.TagRepository; 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.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,8 +41,10 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -50,8 +61,16 @@ class JobSkillGapServiceTest { @Mock private ContentRepository contentRepository; @Mock private JobAiClient jobAiClient; @Mock private HistoryRepository historyRepository; + @Mock private PlanLimitService planLimitService; + @Mock private JobSkillGapRepository jobSkillGapRepository; @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @BeforeEach + void setUp() { + User freeUser = User.builder().email("u@t.kr").nickname("u").job(Job.BACKEND).level(Level.JUNIOR).build(); + lenient().when(userRepository.findByIdAndIsActiveTrue(any())).thenReturn(Optional.of(freeUser)); + } + private static JobPosting postingWith(List requiredSkills, List techStack) { JobPosting p = JobPosting.builder() .companyName("테스트컴퍼니") @@ -165,4 +184,39 @@ void skillGap_emptyRequiredAndEmptyTechStack_returnsEmptyContent() { assertThat(result.contents()).isEmpty(); assertThat(result.roadmap()).isEmpty(); } + + @Test + @DisplayName("getSkillGap — 저장된 결과 반환") + void getSkillGap_existingResult_returnsResponse() throws Exception { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + SkillGapResponse expected = new SkillGapResponse(List.of("Java 학습"), List.of()); + String json = new ObjectMapper().writeValueAsString(expected); + + JobSkillGap entity = JobSkillGap.builder() + .userId(userId) + .jobPosting(postingWith(List.of(), List.of())) + .resultJson(json) + .build(); + given(jobSkillGapRepository.findByUserIdAndJobPosting_Id(userId, jobId)) + .willReturn(Optional.of(entity)); + + SkillGapResponse result = jobService.getSkillGap(userId, jobId); + + assertThat(result.roadmap()).containsExactly("Java 학습"); + } + + @Test + @DisplayName("getSkillGap — 저장된 결과 없으면 JOB_SKILL_GAP_NOT_FOUND 예외") + void getSkillGap_notFound_throwsException() { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + given(jobSkillGapRepository.findByUserIdAndJobPosting_Id(userId, jobId)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> jobService.getSkillGap(userId, jobId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.JOB_SKILL_GAP_NOT_FOUND)); + } } diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java index 02ddf539..92d0f774 100644 --- a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java @@ -15,6 +15,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.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -54,6 +56,8 @@ class MockInterviewServiceTest { @Mock private JobAiClient jobAiClient; @Spy private ObjectMapper objectMapper = new ObjectMapper(); @Mock private ApplicationEventPublisher eventPublisher; + @Mock private UserRepository userRepository; + @Mock private PlanLimitService planLimitService; private static final String MINIMAL_PLAN_JSON = """ { @@ -232,23 +236,53 @@ void listForUser_noSessions_returnsEmptyList() { @Test @DisplayName("startFromJd — null request 시 DevpickException을 던진다") void startFromJd_nullRequest_throwsException() { - assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), null)) + UUID userId = UUID.randomUUID(); + com.devpick.domain.user.entity.User user = com.devpick.domain.user.entity.User.builder() + .email("u@t.kr").nickname("u") + .job(com.devpick.domain.user.entity.Job.BACKEND) + .level(com.devpick.domain.user.entity.Level.JUNIOR).build(); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> mockInterviewService.startFromJd(userId, null)) .isInstanceOf(DevpickException.class); } @Test @DisplayName("startFromJd — jobTitle null 시 DevpickException을 던진다") void startFromJd_nullTitle_throwsException() { + UUID userId = UUID.randomUUID(); + com.devpick.domain.user.entity.User user = com.devpick.domain.user.entity.User.builder() + .email("u@t.kr").nickname("u") + .job(com.devpick.domain.user.entity.Job.BACKEND) + .level(com.devpick.domain.user.entity.Level.JUNIOR).build(); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + StartFromJdRequest req = new StartFromJdRequest("카카오", null, "BACKEND", "", null, null, null); - assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), req)) + assertThatThrownBy(() -> mockInterviewService.startFromJd(userId, req)) .isInstanceOf(DevpickException.class); } @Test @DisplayName("startFromJd — jobTitle 공백 시 DevpickException을 던진다") void startFromJd_blankTitle_throwsException() { + UUID userId = UUID.randomUUID(); + com.devpick.domain.user.entity.User user = com.devpick.domain.user.entity.User.builder() + .email("u@t.kr").nickname("u") + .job(com.devpick.domain.user.entity.Job.BACKEND) + .level(com.devpick.domain.user.entity.Level.JUNIOR).build(); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + StartFromJdRequest req = new StartFromJdRequest("카카오", " ", "BACKEND", "", null, null, null); - assertThatThrownBy(() -> mockInterviewService.startFromJd(UUID.randomUUID(), req)) + assertThatThrownBy(() -> mockInterviewService.startFromJd(userId, req)) + .isInstanceOf(DevpickException.class); + } + + @Test + @DisplayName("startFromJob — 유저 없으면 USER_NOT_FOUND 예외") + void startFromJob_userNotFound_throwsException() { + given(userRepository.findById(any())).willReturn(Optional.empty()); + + assertThatThrownBy(() -> mockInterviewService.startFromJob(UUID.randomUUID(), UUID.randomUUID(), null)) .isInstanceOf(DevpickException.class); } diff --git a/src/test/java/com/devpick/domain/report/controller/ReportControllerTest.java b/src/test/java/com/devpick/domain/report/controller/ReportControllerTest.java index d1a5f315..7e99521a 100644 --- a/src/test/java/com/devpick/domain/report/controller/ReportControllerTest.java +++ b/src/test/java/com/devpick/domain/report/controller/ReportControllerTest.java @@ -113,7 +113,8 @@ void getReportList_success_returns200() throws Exception { reportId, LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay().toInstant(ZoneOffset.UTC), LocalDate.now().with(DayOfWeek.MONDAY).plusDays(6).atStartOfDay().toInstant(ZoneOffset.UTC), - "generated" + "generated", + false ); given(weeklyReportService.getReportList(userId)).willReturn(List.of(summary)); @@ -207,7 +208,8 @@ void getReportList_weekStartIsIso8601Format() throws Exception { reportId, weekStartInstant, weekStartInstant.plusSeconds(6 * 24 * 60 * 60), - "generated" + "generated", + false ); given(weeklyReportService.getReportList(userId)).willReturn(List.of(summary)); diff --git a/src/test/java/com/devpick/domain/report/service/WeeklyReportServiceTest.java b/src/test/java/com/devpick/domain/report/service/WeeklyReportServiceTest.java index 7d285194..604d0af7 100644 --- a/src/test/java/com/devpick/domain/report/service/WeeklyReportServiceTest.java +++ b/src/test/java/com/devpick/domain/report/service/WeeklyReportServiceTest.java @@ -110,6 +110,7 @@ void setUp() throws JsonProcessingException { .build(); ReflectionTestUtils.setField(user, "id", userId); lenient().when(highlightEngine.generate(any())).thenReturn("[]"); + lenient().when(userRepository.findByIdAndIsActiveTrue(userId)).thenReturn(Optional.of(user)); ReportActivity activity = ReportActivity.builder() .contentsRead(5) @@ -415,6 +416,25 @@ void getReportList_empty_returnsEmptyList() { assertThat(result).isEmpty(); } + @Test + @DisplayName("getReportList — FREE 유저 7일 이상 지난 리포트는 locked=true") + void getReportList_freeUser_oldReport_isLocked() { + LocalDate oldWeekStart = LocalDate.now(ZONE_SEOUL).minusDays(14); + WeeklyReport oldReport = WeeklyReport.builder() + .user(user) + .weekStart(oldWeekStart) + .weekEnd(oldWeekStart.plusDays(6)) + .status("generated") + .build(); + ReflectionTestUtils.setField(oldReport, "id", UUID.randomUUID()); + given(weeklyReportRepository.findByUserIdOrderByWeekStartDesc(userId)) + .willReturn(List.of(oldReport)); + + List result = weeklyReportService.getReportList(userId); + + assertThat(result.get(0).locked()).isTrue(); + } + @Test @DisplayName("generateOrGetReport — 이미 존재하는 리포트면 기존 리포트 반환") void generateOrGetReport_alreadyExists_returnsExisting() { diff --git a/src/test/java/com/devpick/domain/subscription/client/TossPaymentsClientTest.java b/src/test/java/com/devpick/domain/subscription/client/TossPaymentsClientTest.java new file mode 100644 index 00000000..e4fec39f --- /dev/null +++ b/src/test/java/com/devpick/domain/subscription/client/TossPaymentsClientTest.java @@ -0,0 +1,123 @@ +package com.devpick.domain.subscription.client; + +import com.devpick.domain.subscription.config.TossProperties; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; + +@SuppressWarnings({"unchecked", "rawtypes"}) +@ExtendWith(MockitoExtension.class) +class TossPaymentsClientTest { + + @InjectMocks + private TossPaymentsClient tossPaymentsClient; + + @Mock private WebClient webClient; + @Mock private WebClient.RequestBodyUriSpec requestBodyUriSpec; + @Mock private WebClient.RequestBodySpec requestBodySpec; + @Mock private WebClient.RequestHeadersSpec requestHeadersSpec; + @Mock private WebClient.ResponseSpec responseSpec; + @Mock private TossProperties tossProperties; + + @BeforeEach + void setUp() { + lenient().when(tossProperties.secretKey()).thenReturn("test_sk"); + lenient().when(tossProperties.billingUrl()).thenReturn("https://api.tosspayments.com/v1/billing"); + lenient().when(tossProperties.paymentsUrl()).thenReturn("https://api.tosspayments.com/v1/payments"); + + lenient().when(webClient.post()).thenReturn(requestBodyUriSpec); + lenient().when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec); + lenient().when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec); + lenient().when(requestBodySpec.contentType(any(MediaType.class))).thenReturn(requestBodySpec); + lenient().when(requestBodySpec.bodyValue(any())).thenReturn(requestHeadersSpec); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + lenient().when(responseSpec.onStatus(any(), any())).thenReturn(responseSpec); + } + + // ── issueBillingKey ────────────────────────────────────────────────────── + + @Test + @DisplayName("issueBillingKey — 응답에 billingKey 있으면 반환") + void issueBillingKey_success_returnsBillingKey() { + Map response = Map.of("billingKey", "bk_test_123"); + given(responseSpec.bodyToMono(Map.class)).willReturn(Mono.just(response)); + + String result = tossPaymentsClient.issueBillingKey("authKey", "customerKey"); + + assertThat(result).isEqualTo("bk_test_123"); + } + + @Test + @DisplayName("issueBillingKey — 응답이 null이면 SUBSCRIPTION_PAYMENT_FAILED 예외") + void issueBillingKey_nullResponse_throwsPaymentFailed() { + given(responseSpec.bodyToMono(Map.class)).willReturn(Mono.empty()); + + assertThatThrownBy(() -> tossPaymentsClient.issueBillingKey("authKey", "customerKey")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED)); + } + + @Test + @DisplayName("issueBillingKey — billingKey 필드 없으면 SUBSCRIPTION_PAYMENT_FAILED 예외") + void issueBillingKey_missingBillingKey_throwsPaymentFailed() { + given(responseSpec.bodyToMono(Map.class)).willReturn(Mono.just(Map.of("status", "DONE"))); + + assertThatThrownBy(() -> tossPaymentsClient.issueBillingKey("authKey", "customerKey")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED)); + } + + // ── charge ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("charge — 응답에 paymentKey 있으면 반환") + void charge_success_returnsPaymentKey() { + Map response = Map.of("paymentKey", "pk_test_abc"); + given(responseSpec.bodyToMono(Map.class)).willReturn(Mono.just(response)); + + String result = tossPaymentsClient.charge("billingKey", "customerKey", 9900, "Pro", "order-1"); + + assertThat(result).isEqualTo("pk_test_abc"); + } + + @Test + @DisplayName("charge — 응답이 null이면 SUBSCRIPTION_PAYMENT_FAILED 예외") + void charge_nullResponse_throwsPaymentFailed() { + given(responseSpec.bodyToMono(Map.class)).willReturn(Mono.empty()); + + assertThatThrownBy(() -> tossPaymentsClient.charge("billingKey", "customerKey", 9900, "Pro", "order-1")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED)); + } + + // ── cancel ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("cancel — 정상 호출 시 예외 없이 완료") + void cancel_success_completesWithoutException() { + given(responseSpec.bodyToMono(Void.class)).willReturn(Mono.empty()); + + tossPaymentsClient.cancel("paymentKey", "환불 요청"); + } +} diff --git a/src/test/java/com/devpick/domain/subscription/controller/SubscriptionControllerTest.java b/src/test/java/com/devpick/domain/subscription/controller/SubscriptionControllerTest.java new file mode 100644 index 00000000..3c6b7ba8 --- /dev/null +++ b/src/test/java/com/devpick/domain/subscription/controller/SubscriptionControllerTest.java @@ -0,0 +1,201 @@ +package com.devpick.domain.subscription.controller; + +import com.devpick.domain.subscription.dto.BillingAuthRequest; +import com.devpick.domain.subscription.dto.BillingAuthResponse; +import com.devpick.domain.subscription.dto.PlanChangeRequest; +import com.devpick.domain.subscription.dto.PlanChangeResponse; +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.domain.subscription.service.SubscriptionService; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import com.devpick.global.common.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class SubscriptionControllerTest { + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock private SubscriptionService subscriptionService; + @InjectMocks private SubscriptionController subscriptionController; + + private UUID userId; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .standaloneSetup(subscriptionController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + userId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("POST /subscriptions/billing-auth - 결제 성공 시 200 반환") + void billingAuth_success_returns200() throws Exception { + BillingAuthRequest request = new BillingAuthRequest("authKey", "customerKey", PlanType.PRO); + BillingAuthResponse response = new BillingAuthResponse(PlanType.PRO, null); + given(subscriptionService.register(eq(userId), any())).willReturn(response); + + mockMvc.perform(post("/subscriptions/billing-auth") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.planType").value("PRO")); + } + + @Test + @DisplayName("POST /subscriptions/billing-auth - 이미 구독 중 409 반환") + void billingAuth_alreadySubscribed_returns409() throws Exception { + BillingAuthRequest request = new BillingAuthRequest("authKey", "customerKey", PlanType.MAX); + given(subscriptionService.register(eq(userId), any())) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE)); + + mockMvc.perform(post("/subscriptions/billing-auth") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("PAYMENT_001")); + } + + @Test + @DisplayName("DELETE /subscriptions - 구독 해지 성공 시 200 반환") + void cancelSubscription_success_returns200() throws Exception { + BillingAuthResponse response = new BillingAuthResponse(PlanType.PRO, Instant.now().plusSeconds(2592000)); + given(subscriptionService.cancel(userId)).willReturn(response); + + mockMvc.perform(delete("/subscriptions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("DELETE /subscriptions - 활성 구독 없음 404 반환") + void cancelSubscription_notFound_returns404() throws Exception { + given(subscriptionService.cancel(userId)) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_NOT_FOUND)); + + mockMvc.perform(delete("/subscriptions")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("PAYMENT_004")); + } + + @Test + @DisplayName("POST /subscriptions/cancel - 환불 성공 시 200 반환") + void refund_success_returns200() throws Exception { + BillingAuthResponse response = new BillingAuthResponse(PlanType.FREE, null); + given(subscriptionService.refund(userId)).willReturn(response); + + mockMvc.perform(post("/subscriptions/cancel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.planType").value("FREE")); + } + + @Test + @DisplayName("POST /subscriptions/cancel - 7일 초과 409 반환") + void refund_periodExpired_returns409() throws Exception { + given(subscriptionService.refund(userId)) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_REFUND_PERIOD_EXPIRED)); + + mockMvc.perform(post("/subscriptions/cancel")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("PAYMENT_006")); + } + + @Test + @DisplayName("POST /subscriptions/cancel - Free 기준 초과 사용 409 반환") + void refund_usageExceeded_returns409() throws Exception { + given(subscriptionService.refund(userId)) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_REFUND_USAGE_EXCEEDED)); + + mockMvc.perform(post("/subscriptions/cancel")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("PAYMENT_007")); + } + + @Test + @DisplayName("POST /subscriptions/change - 플랜 변경 예약 성공 시 200 반환") + void changePlan_success_returns200() throws Exception { + PlanChangeRequest request = new PlanChangeRequest(PlanType.MAX); + PlanChangeResponse response = new PlanChangeResponse(PlanType.PRO, PlanType.MAX, Instant.now().plusSeconds(2592000)); + given(subscriptionService.changePlan(eq(userId), any())).willReturn(response); + + mockMvc.perform(post("/subscriptions/change") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.currentPlanType").value("PRO")) + .andExpect(jsonPath("$.data.pendingPlanType").value("MAX")); + } + + @Test + @DisplayName("POST /subscriptions/change - 동일 플랜(변경 취소) 성공 시 200 반환") + void changePlan_cancelPending_returns200() throws Exception { + PlanChangeRequest request = new PlanChangeRequest(PlanType.PRO); + PlanChangeResponse response = new PlanChangeResponse(PlanType.PRO, null, Instant.now().plusSeconds(2592000)); + given(subscriptionService.changePlan(eq(userId), any())).willReturn(response); + + mockMvc.perform(post("/subscriptions/change") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.pendingPlanType").doesNotExist()); + } + + @Test + @DisplayName("POST /subscriptions/change - 이미 동일 플랜 구독 중 409 반환") + void changePlan_alreadyActive_returns409() throws Exception { + PlanChangeRequest request = new PlanChangeRequest(PlanType.PRO); + given(subscriptionService.changePlan(eq(userId), any())) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE)); + + mockMvc.perform(post("/subscriptions/change") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("PAYMENT_001")); + } +} diff --git a/src/test/java/com/devpick/domain/subscription/service/PlanLimitServiceTest.java b/src/test/java/com/devpick/domain/subscription/service/PlanLimitServiceTest.java new file mode 100644 index 00000000..96cd27a5 --- /dev/null +++ b/src/test/java/com/devpick/domain/subscription/service/PlanLimitServiceTest.java @@ -0,0 +1,247 @@ +package com.devpick.domain.subscription.service; + +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +@ExtendWith(MockitoExtension.class) +class PlanLimitServiceTest { + + @InjectMocks + private PlanLimitService planLimitService; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOps; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + // ── checkAndIncrementAiDaily ────────────────────────────────────────────── + + @Test + @DisplayName("FREE 유저 AI 5회 이내 — 정상 통과") + void checkAndIncrementAiDaily_free_withinLimit_passes() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(3L); + + planLimitService.checkAndIncrementAiDaily(userId, PlanType.FREE); + + verify(valueOps).increment(anyString()); + } + + @Test + @DisplayName("FREE 유저 AI 6회 — SUBSCRIPTION_LIMIT_EXCEEDED 예외") + void checkAndIncrementAiDaily_free_exceeded_throwsException() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(6L); + + assertThatThrownBy(() -> planLimitService.checkAndIncrementAiDaily(userId, PlanType.FREE)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_LIMIT_EXCEEDED)); + + verify(valueOps).decrement(anyString()); + } + + @Test + @DisplayName("PRO 유저 AI 10회 이내 — 정상 통과") + void checkAndIncrementAiDaily_pro_withinLimit_passes() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(10L); + + planLimitService.checkAndIncrementAiDaily(userId, PlanType.PRO); + + verify(valueOps).increment(anyString()); + verify(valueOps, never()).decrement(anyString()); + } + + @Test + @DisplayName("PRO 유저 AI 11회 — SUBSCRIPTION_LIMIT_EXCEEDED 예외, requiredPlan=MAX") + void checkAndIncrementAiDaily_pro_exceeded_throwsException() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(11L); + + assertThatThrownBy(() -> planLimitService.checkAndIncrementAiDaily(userId, PlanType.PRO)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> { + DevpickException de = (DevpickException) e; + assertThat(de.getErrorCode()).isEqualTo(ErrorCode.SUBSCRIPTION_LIMIT_EXCEEDED); + assertThat(detailMap(de)).containsEntry("requiredPlan", "MAX"); + }); + } + + @Test + @DisplayName("MAX 유저 AI — 체크 없이 통과 (Redis 미호출)") + void checkAndIncrementAiDaily_max_skipsCheck() { + UUID userId = UUID.randomUUID(); + + planLimitService.checkAndIncrementAiDaily(userId, PlanType.MAX); + + verify(valueOps, never()).increment(anyString()); + } + + // ── checkAndIncrementWeekly ─────────────────────────────────────────────── + + @Test + @DisplayName("FREE 유저 스킬갭 2회 이내 — 정상 통과") + void checkAndIncrementWeekly_free_withinLimit_passes() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(2L); + + planLimitService.checkAndIncrementWeekly(userId, PlanType.FREE, "skill_boost"); + + verify(valueOps).increment(anyString()); + verify(valueOps, never()).decrement(anyString()); + } + + @Test + @DisplayName("FREE 유저 스킬갭 3회 — SUBSCRIPTION_LIMIT_EXCEEDED 예외, feature=skillBoostWeekly") + void checkAndIncrementWeekly_free_exceeded_featureKeyMapped() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(3L); + + assertThatThrownBy(() -> planLimitService.checkAndIncrementWeekly(userId, PlanType.FREE, "skill_boost")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> { + DevpickException de = (DevpickException) e; + assertThat(de.getErrorCode()).isEqualTo(ErrorCode.SUBSCRIPTION_LIMIT_EXCEEDED); + assertThat(detailMap(de)).containsEntry("feature", "skillBoostWeekly"); + assertThat(detailMap(de)).containsEntry("requiredPlan", "PRO"); + }); + } + + @Test + @DisplayName("FREE 유저 면접Q&A 생성 초과 — feature=interviewQaGenerateWeekly") + void checkAndIncrementWeekly_free_interviewQaExceeded_featureKeyMapped() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(3L); + + assertThatThrownBy(() -> planLimitService.checkAndIncrementWeekly(userId, PlanType.FREE, "interview_qa_gen")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(detailMap((DevpickException) e)) + .containsEntry("feature", "interviewQaGenerateWeekly")); + } + + @Test + @DisplayName("FREE 유저 모의면접 초과 — feature=mockInterviewWeekly") + void checkAndIncrementWeekly_free_mockInterviewExceeded_featureKeyMapped() { + UUID userId = UUID.randomUUID(); + given(valueOps.increment(anyString())).willReturn(3L); + + assertThatThrownBy(() -> planLimitService.checkAndIncrementWeekly(userId, PlanType.FREE, "mock_interview")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(detailMap((DevpickException) e)) + .containsEntry("feature", "mockInterviewWeekly")); + } + + @Test + @DisplayName("MAX 유저 주간 기능 — 체크 없이 통과") + void checkAndIncrementWeekly_max_skipsCheck() { + UUID userId = UUID.randomUUID(); + + planLimitService.checkAndIncrementWeekly(userId, PlanType.MAX, "skill_boost"); + + verify(valueOps, never()).increment(anyString()); + } + + // ── exceedsFreeLimit ────────────────────────────────────────────────────── + + @Test + @DisplayName("모든 카운터 Free 기준 이내 — false 반환") + void exceedsFreeLimit_allWithinLimit_returnsFalse() { + given(valueOps.get(anyString())).willReturn("2"); + + assertThat(planLimitService.exceedsFreeLimit(UUID.randomUUID())).isFalse(); + } + + @Test + @DisplayName("AI 일 사용량 초과 — true 반환") + void exceedsFreeLimit_aiDailyExceeded_returnsTrue() { + given(valueOps.get(anyString())).willReturn("0"); + given(valueOps.get(contains(":ai:"))).willReturn("6"); + + assertThat(planLimitService.exceedsFreeLimit(UUID.randomUUID())).isTrue(); + } + + @Test + @DisplayName("주간 사용량 초과 — true 반환") + void exceedsFreeLimit_weeklyExceeded_returnsTrue() { + given(valueOps.get(anyString())).willReturn("3"); + + assertThat(planLimitService.exceedsFreeLimit(UUID.randomUUID())).isTrue(); + } + + @Test + @DisplayName("카운터 없음(null) — false 반환") + void exceedsFreeLimit_noCounters_returnsFalse() { + given(valueOps.get(anyString())).willReturn(null); + + assertThat(planLimitService.exceedsFreeLimit(UUID.randomUUID())).isFalse(); + } + + // ── getAiDailyInfo ──────────────────────────────────────────────────────── + + @Test + @DisplayName("FREE 유저 AI 일 사용량 조회 — used/max/remaining 정상 계산") + void getAiDailyInfo_free_returnsCorrectInfo() { + given(valueOps.get(anyString())).willReturn("3"); + + var info = planLimitService.getAiDailyInfo(UUID.randomUUID(), PlanType.FREE); + + assertThat(info.used()).isEqualTo(3); + assertThat(info.max()).isEqualTo(5); + assertThat(info.remaining()).isEqualTo(2); + } + + @Test + @DisplayName("MAX 유저 AI 일 사용량 조회 — 무제한(-1) 반환") + void getAiDailyInfo_max_returnsUnlimited() { + var info = planLimitService.getAiDailyInfo(UUID.randomUUID(), PlanType.MAX); + + assertThat(info.max()).isEqualTo(-1); + assertThat(info.remaining()).isEqualTo(-1); + verify(valueOps, never()).get(anyString()); + } + + // ── getWeeklyInfo ───────────────────────────────────────────────────────── + + @Test + @DisplayName("PRO 유저 주간 사용량 조회 — max=7 정상 반환") + void getWeeklyInfo_pro_returnsCorrectMax() { + given(valueOps.get(anyString())).willReturn("4"); + + var info = planLimitService.getWeeklyInfo(UUID.randomUUID(), PlanType.PRO, "skill_boost"); + + assertThat(info.max()).isEqualTo(7); + assertThat(info.remaining()).isEqualTo(3); + } + + private Map detailMap(DevpickException e) { + return (Map) e.getDetail(); + } +} diff --git a/src/test/java/com/devpick/domain/subscription/service/SubscriptionServiceTest.java b/src/test/java/com/devpick/domain/subscription/service/SubscriptionServiceTest.java new file mode 100644 index 00000000..cee47f70 --- /dev/null +++ b/src/test/java/com/devpick/domain/subscription/service/SubscriptionServiceTest.java @@ -0,0 +1,331 @@ +package com.devpick.domain.subscription.service; + +import com.devpick.domain.subscription.client.TossPaymentsClient; +import com.devpick.domain.subscription.config.TossProperties; +import com.devpick.domain.subscription.dto.BillingAuthRequest; +import com.devpick.domain.subscription.dto.BillingAuthResponse; +import com.devpick.domain.subscription.dto.PlanChangeRequest; +import com.devpick.domain.subscription.dto.PlanChangeResponse; +import com.devpick.domain.subscription.entity.PlanType; +import com.devpick.domain.subscription.entity.Subscription; +import com.devpick.domain.subscription.entity.SubscriptionStatus; +import com.devpick.domain.subscription.repository.SubscriptionRepository; +import com.devpick.domain.user.entity.Job; +import com.devpick.domain.user.entity.Level; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SubscriptionServiceTest { + + @InjectMocks private SubscriptionService subscriptionService; + + @Mock private SubscriptionRepository subscriptionRepository; + @Mock private UserRepository userRepository; + @Mock private TossPaymentsClient tossPaymentsClient; + @Mock private TossProperties tossProperties; + @Mock private PlanLimitService planLimitService; + + private UUID userId; + private User freeUser; + private User proUser; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + freeUser = User.builder().email("free@test.kr").nickname("freeUser").job(Job.BACKEND).level(Level.JUNIOR).build(); + proUser = User.builder().email("pro@test.kr").nickname("proUser").job(Job.BACKEND).level(Level.JUNIOR).build(); + proUser.upgradePlan(PlanType.PRO, "billingKey", "customerKey", null); + } + + private Subscription activeSubscription(PlanType planType) { + return Subscription.builder() + .userId(userId) + .tossBillingKey("bk_test") + .tossCustomerKey("ck_test") + .paymentKey("pk_test") + .planType(planType) + .status(SubscriptionStatus.ACTIVE) + .amount(9900) + .startedAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(1)) + .expiredAt(LocalDateTime.now(ZoneOffset.UTC).plusMonths(1)) + .build(); + } + + // ── register ───────────────────────────────────────────────────────────── + + @Test + @DisplayName("register - FREE 유저 PRO 구독 성공 시 planType=PRO, planExpiredAt=null 반환") + void register_freeUser_upgradesPro() { + BillingAuthRequest request = new BillingAuthRequest("authKey", "customerKey", PlanType.PRO); + given(userRepository.findById(userId)).willReturn(Optional.of(freeUser)); + given(tossPaymentsClient.issueBillingKey(any(), any())).willReturn("billingKey"); + given(tossPaymentsClient.charge(any(), any(), any(int.class), any(), any())).willReturn("paymentKey"); + given(tossProperties.amountPro()).willReturn(9900); + given(tossProperties.orderNamePro()).willReturn("Trace Pro 월정액"); + given(subscriptionRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + BillingAuthResponse response = subscriptionService.register(userId, request); + + assertThat(response.planType()).isEqualTo(PlanType.PRO); + assertThat(freeUser.getPlanType()).isEqualTo(PlanType.PRO); + assertThat(freeUser.getPlanExpiredAt()).isNull(); + verify(subscriptionRepository).save(any(Subscription.class)); + } + + @Test + @DisplayName("register - 이미 구독 중인 유저 — SUBSCRIPTION_ALREADY_ACTIVE 예외") + void register_alreadySubscribed_throwsException() { + BillingAuthRequest request = new BillingAuthRequest("authKey", "customerKey", PlanType.MAX); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + assertThatThrownBy(() -> subscriptionService.register(userId, request)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE)); + + verify(tossPaymentsClient, never()).issueBillingKey(any(), any()); + } + + // ── cancel ──────────────────────────────────────────────────────────────── + + @Test + @DisplayName("cancel - 구독 해지 성공 시 planExpiredAt 세팅, status=CANCELED") + void cancel_success_setsExpiredAt() { + Subscription subscription = activeSubscription(PlanType.PRO); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + BillingAuthResponse response = subscriptionService.cancel(userId); + + assertThat(response.planType()).isEqualTo(PlanType.PRO); + assertThat(response.planExpiredAt()).isNotNull(); + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.CANCELED); + assertThat(proUser.getPlanExpiredAt()).isNotNull(); + } + + @Test + @DisplayName("cancel - 활성 구독 없음 — SUBSCRIPTION_NOT_FOUND 예외") + void cancel_noActiveSubscription_throwsException() { + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> subscriptionService.cancel(userId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_NOT_FOUND)); + } + + // ── refund ──────────────────────────────────────────────────────────────── + + @Test + @DisplayName("refund - 7일 이내 + 사용량 정상 — FREE 즉시 전환") + void refund_withinPeriodAndUnderLimit_downgradesToFree() { + Subscription subscription = activeSubscription(PlanType.PRO); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(planLimitService.exceedsFreeLimit(userId)).willReturn(false); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + BillingAuthResponse response = subscriptionService.refund(userId); + + assertThat(response.planType()).isEqualTo(PlanType.FREE); + assertThat(response.planExpiredAt()).isNull(); + verify(tossPaymentsClient).cancel(any(), any()); + assertThat(proUser.getPlanType()).isEqualTo(PlanType.FREE); + } + + @Test + @DisplayName("refund - 7일 초과 — SUBSCRIPTION_REFUND_PERIOD_EXPIRED 예외") + void refund_periodExpired_throwsException() { + Subscription subscription = Subscription.builder() + .userId(userId) + .tossBillingKey("bk").tossCustomerKey("ck").paymentKey("pk") + .planType(PlanType.PRO).status(SubscriptionStatus.ACTIVE) + .amount(9900) + .startedAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(8)) + .expiredAt(LocalDateTime.now(ZoneOffset.UTC).plusMonths(1)) + .build(); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + + assertThatThrownBy(() -> subscriptionService.refund(userId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_REFUND_PERIOD_EXPIRED)); + + verify(tossPaymentsClient, never()).cancel(any(), any()); + } + + @Test + @DisplayName("refund - Free 기준치 초과 사용 — SUBSCRIPTION_REFUND_USAGE_EXCEEDED 예외") + void refund_usageExceeded_throwsException() { + Subscription subscription = activeSubscription(PlanType.PRO); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(planLimitService.exceedsFreeLimit(userId)).willReturn(true); + + assertThatThrownBy(() -> subscriptionService.refund(userId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_REFUND_USAGE_EXCEEDED)); + + verify(tossPaymentsClient, never()).cancel(any(), any()); + } + + // ── changePlan ──────────────────────────────────────────────────────────── + + @Test + @DisplayName("changePlan - PRO → MAX 변경 예약 성공") + void changePlan_proToMax_setsPendingPlanType() { + Subscription subscription = activeSubscription(PlanType.PRO); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + PlanChangeResponse response = subscriptionService.changePlan(userId, new PlanChangeRequest(PlanType.MAX)); + + assertThat(response.currentPlanType()).isEqualTo(PlanType.PRO); + assertThat(response.pendingPlanType()).isEqualTo(PlanType.MAX); + assertThat(subscription.getPendingPlanType()).isEqualTo(PlanType.MAX); + } + + @Test + @DisplayName("changePlan - 동일 플랜 + pendingPlanType 있음 → 변경 취소 (pendingPlanType=null)") + void changePlan_samePlanWithPending_cancelsPendingChange() { + Subscription subscription = activeSubscription(PlanType.PRO); + subscription.setPendingPlanType(PlanType.MAX); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + PlanChangeResponse response = subscriptionService.changePlan(userId, new PlanChangeRequest(PlanType.PRO)); + + assertThat(response.pendingPlanType()).isNull(); + assertThat(subscription.getPendingPlanType()).isNull(); + } + + @Test + @DisplayName("changePlan - 동일 플랜 + pendingPlanType 없음 → SUBSCRIPTION_ALREADY_ACTIVE 예외") + void changePlan_samePlanNoPending_throwsException() { + Subscription subscription = activeSubscription(PlanType.PRO); + given(subscriptionRepository.findTopByUserIdAndStatusOrderByStartedAtDesc(userId, SubscriptionStatus.ACTIVE)) + .willReturn(Optional.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + + assertThatThrownBy(() -> subscriptionService.changePlan(userId, new PlanChangeRequest(PlanType.PRO))) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_ALREADY_ACTIVE)); + } + + @Test + @DisplayName("changePlan - FREE 요청 — INVALID_INPUT 예외") + void changePlan_freeRequested_throwsException() { + assertThatThrownBy(() -> subscriptionService.changePlan(userId, new PlanChangeRequest(PlanType.FREE))) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT)); + } + + // ── renewSubscriptions ──────────────────────────────────────────────────── + + @Test + @DisplayName("renewSubscriptions - pendingPlanType 없으면 현재 플랜으로 갱신") + void renewSubscriptions_noPending_renewsCurrentPlan() { + Subscription subscription = Subscription.builder() + .userId(userId) + .tossBillingKey("bk").tossCustomerKey("ck").paymentKey("pk") + .planType(PlanType.PRO).status(SubscriptionStatus.ACTIVE) + .amount(9900) + .startedAt(LocalDateTime.now(ZoneOffset.UTC).minusMonths(1)) + .expiredAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)) + .build(); + given(subscriptionRepository.findByStatusAndPlanTypeInAndExpiredAtBefore(any(), any(), any())) + .willReturn(List.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + given(tossPaymentsClient.charge(any(), any(), any(int.class), any(), any())).willReturn("newPaymentKey"); + given(tossProperties.amountPro()).willReturn(9900); + given(tossProperties.orderNamePro()).willReturn("Trace Pro 월정액"); + + subscriptionService.renewSubscriptions(); + + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(subscription.getPendingPlanType()).isNull(); + verify(tossPaymentsClient).charge(any(), any(), any(int.class), any(), any()); + } + + @Test + @DisplayName("renewSubscriptions - pendingPlanType=MAX이면 MAX로 전환 후 갱신") + void renewSubscriptions_withPending_switchesToPendingPlan() { + Subscription subscription = Subscription.builder() + .userId(userId) + .tossBillingKey("bk").tossCustomerKey("ck").paymentKey("pk") + .planType(PlanType.PRO).status(SubscriptionStatus.ACTIVE) + .amount(9900) + .startedAt(LocalDateTime.now(ZoneOffset.UTC).minusMonths(1)) + .expiredAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)) + .build(); + subscription.setPendingPlanType(PlanType.MAX); + given(subscriptionRepository.findByStatusAndPlanTypeInAndExpiredAtBefore(any(), any(), any())) + .willReturn(List.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + given(tossPaymentsClient.charge(any(), any(), any(int.class), any(), any())).willReturn("newPaymentKey"); + given(tossProperties.amountMax()).willReturn(19900); + given(tossProperties.orderNameMax()).willReturn("Trace Max 월정액"); + + subscriptionService.renewSubscriptions(); + + assertThat(subscription.getPlanType()).isEqualTo(PlanType.MAX); + assertThat(subscription.getPendingPlanType()).isNull(); + assertThat(proUser.getPlanType()).isEqualTo(PlanType.MAX); + } + + @Test + @DisplayName("renewSubscriptions - 결제 실패 시 FREE 다운그레이드 + PAYMENT_FAILED 상태") + void renewSubscriptions_paymentFails_downgradesToFree() { + Subscription subscription = Subscription.builder() + .userId(userId) + .tossBillingKey("bk").tossCustomerKey("ck").paymentKey("pk") + .planType(PlanType.PRO).status(SubscriptionStatus.ACTIVE) + .amount(9900) + .startedAt(LocalDateTime.now(ZoneOffset.UTC).minusMonths(1)) + .expiredAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)) + .build(); + given(subscriptionRepository.findByStatusAndPlanTypeInAndExpiredAtBefore(any(), any(), any())) + .willReturn(List.of(subscription)); + given(userRepository.findById(userId)).willReturn(Optional.of(proUser)); + given(tossPaymentsClient.charge(any(), any(), any(int.class), any(), any())) + .willThrow(new DevpickException(ErrorCode.SUBSCRIPTION_PAYMENT_FAILED)); + given(tossProperties.amountPro()).willReturn(9900); + given(tossProperties.orderNamePro()).willReturn("Trace Pro 월정액"); + + subscriptionService.renewSubscriptions(); + + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(proUser.getPlanType()).isEqualTo(PlanType.FREE); + } +} diff --git a/src/test/java/com/devpick/domain/user/controller/UserControllerTest.java b/src/test/java/com/devpick/domain/user/controller/UserControllerTest.java index 672b46ac..8453dbe7 100644 --- a/src/test/java/com/devpick/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/devpick/domain/user/controller/UserControllerTest.java @@ -4,6 +4,7 @@ import com.devpick.domain.user.dto.UserProfileResponse; import com.devpick.domain.user.dto.UserProfileUpdateRequest; import com.devpick.domain.user.dto.UserProfileUpdateResponse; +import com.devpick.domain.subscription.entity.PlanType; import com.devpick.domain.user.entity.Job; import com.devpick.domain.user.entity.Level; import com.devpick.domain.user.service.UserService; @@ -110,7 +111,8 @@ void getPublicProfile_notFound_returns404() throws Exception { void getProfile_success() throws Exception { UserProfileResponse response = new UserProfileResponse( userId, "test@devpick.kr", "테스트유저", null, - Job.BACKEND, Level.JUNIOR, List.of("React"), Instant.now(), 0, null); + Job.BACKEND, Level.JUNIOR, List.of("React"), Instant.now(), 0, null, + PlanType.FREE, null, null, null, null); given(userService.getProfile(userId)).willReturn(response); mockMvc.perform(get("/users/me")) diff --git a/src/test/java/com/devpick/domain/user/service/UserServiceTest.java b/src/test/java/com/devpick/domain/user/service/UserServiceTest.java index effe23a3..6ad47bac 100644 --- a/src/test/java/com/devpick/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/devpick/domain/user/service/UserServiceTest.java @@ -8,6 +8,8 @@ import com.devpick.domain.point.entity.Badge; import com.devpick.domain.point.entity.UserBadge; import com.devpick.domain.point.repository.UserBadgeRepository; +import com.devpick.domain.subscription.repository.SubscriptionRepository; +import com.devpick.domain.subscription.service.PlanLimitService; import com.devpick.domain.user.dto.PublicUserProfileResponse; import com.devpick.domain.user.dto.UserProfileResponse; import com.devpick.domain.user.dto.UserProfileUpdateRequest; @@ -67,6 +69,10 @@ class UserServiceTest { private AnswerRepository answerRepository; @Mock private FileStorageService fileStorageService; + @Mock + private PlanLimitService planLimitService; + @Mock + private SubscriptionRepository subscriptionRepository; private UUID userId; private User user; @@ -357,4 +363,39 @@ void resolvePreferredAiLevel_userMissing_defaultsJunior() { assertThat(userService.resolvePreferredAiLevel(userId, null)).isEqualTo("JUNIOR"); } + + // ── checkAiLevelAccess ─────────────────────────────────────────────────── + + @Test + @DisplayName("checkAiLevelAccess — userId null이면 예외 없이 통과") + void checkAiLevelAccess_nullUserId_passes() { + userService.checkAiLevelAccess(null, "JUNIOR"); + } + + @Test + @DisplayName("checkAiLevelAccess — FREE 유저 본인 레벨 요청 시 통과") + void checkAiLevelAccess_freeUser_sameLevel_passes() { + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + userService.checkAiLevelAccess(userId, "JUNIOR"); + } + + @Test + @DisplayName("checkAiLevelAccess — FREE 유저 다른 레벨 요청 시 SUBSCRIPTION_PLAN_REQUIRED 예외") + void checkAiLevelAccess_freeUser_differentLevel_throwsException() { + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.checkAiLevelAccess(userId, "SENIOR")) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.SUBSCRIPTION_PLAN_REQUIRED)); + } + + @Test + @DisplayName("checkAiLevelAccess — 사용자 없으면 예외 없이 통과") + void checkAiLevelAccess_userNotFound_passes() { + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); + + userService.checkAiLevelAccess(userId, "SENIOR"); + } }