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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/main/java/com/devpick/domain/job/service/JobService.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
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.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;
Expand Down Expand Up @@ -79,6 +81,7 @@ public class JobService {
private final JobAiClient jobAiClient;
private final ObjectMapper objectMapper;
private final HistoryRepository historyRepository;
private final PointService pointService;

@Transactional(readOnly = true)
public List<TechTagFacetResponse> listTechTagFacets(Integer limit) {
Expand Down Expand Up @@ -308,6 +311,14 @@ public void bookmark(UUID userId, UUID jobId) {
.userId(userId)
.jobPosting(p)
.build());
var user = userRepository.findByIdAndIsActiveTrue(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
historyRepository.save(History.builder()
.user(user)
.actionType("job_bookmarked")
.jobPosting(p)
.build());
pointService.earn(user, PointAction.JOB_BOOKMARK, jobId);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
import com.devpick.domain.job.entity.MockInterviewTurnType;
import com.devpick.domain.job.repository.JobPostingRepository;
import com.devpick.domain.job.repository.MockInterviewSessionRepository;
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.repository.MasterResumeRepository;
import com.devpick.domain.resume.service.ResumeCryptoService;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import com.fasterxml.jackson.core.type.TypeReference;
Expand Down Expand Up @@ -58,6 +63,9 @@ public class MockInterviewService {
private final MockInterviewModelRegistry modelRegistry;
private final JobAiClient jobAiClient;
private final ObjectMapper objectMapper;
private final HistoryRepository historyRepository;
private final UserRepository userRepository;
private final PointService pointService;

@Transactional(readOnly = true)
public HistoryListResponse listForUser(UUID userId) {
Expand Down Expand Up @@ -383,6 +391,15 @@ private void finalizeSession(MockInterviewSession session, QuestionPlanResponse
finalResult.put("totalQuestions", MockInterviewPlanner.TOTAL_QUESTIONS);
finalResult.put("coverageFactor", coverageFactor(session.getAnsweredCount(), early));
session.setResultJson(writeJson(finalResult));

userRepository.findByIdAndIsActiveTrue(session.getUserId()).ifPresent(user -> {
historyRepository.save(History.builder()
.user(user)
.actionType("mock_interview_completed")
.jobPosting(session.getJobPosting())
.build());
pointService.earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, session.getId());
});
}

private double coverageFactor(int answered, boolean early) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public void run(ApplicationArguments args) {
Badge.builder().id("POINT_100").name("새싹 개발자").description("누적 포인트 100p 달성").sortOrder(4).build(),
Badge.builder().id("POINT_500").name("성장 중").description("누적 포인트 500p 달성").sortOrder(5).build(),
Badge.builder().id("POINT_1000").name("시니어 픽커").description("누적 포인트 1000p 달성").sortOrder(6).build(),
Badge.builder().id("STREAK_7").name("7일 연속").description("연속 로그인 7일 달성").sortOrder(7).build()
Badge.builder().id("STREAK_7").name("7일 연속").description("연속 로그인 7일 달성").sortOrder(7).build(),
Badge.builder().id("INTERVIEW_MASTER").name("면접 마스터").description("모의면접 5회 완료").sortOrder(8).build()
);

for (Badge seed : seeds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public enum PointAction {
QUESTION_WRITE(10),
ANSWER_WRITE(15),
ANSWER_ADOPTED(30),
DAILY_LOGIN(1);
DAILY_LOGIN(1),
JOB_BOOKMARK(5),
MOCK_INTERVIEW_COMPLETE(20);

private final int points;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public void checkAndUnlock(User user, PointAction justEarned) {
checkAnswerMaster(user);
checkPointBadges(user);
checkStreak7(user, justEarned);
checkInterviewMaster(user);
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -107,6 +108,11 @@ private void checkStreak7(User user, PointAction justEarned) {
unlockIfConditionMet(user, "STREAK_7", calculateStreak(user.getId(), justEarned) >= 7);
}

private void checkInterviewMaster(User user) {
unlockIfConditionMet(user, "INTERVIEW_MASTER",
pointLogRepository.countByUser_IdAndAction(user.getId(), PointAction.MOCK_INTERVIEW_COMPLETE) >= 5);
}

private void unlockIfConditionMet(User user, String badgeId, boolean condition) {
if (!condition) return;
if (userBadgeRepository.existsByUser_IdAndBadge_Id(user.getId(), badgeId)) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ public void refundAnswerPoints(User user, boolean wasAdopted) {

private boolean isDuplicate(UUID userId, PointAction action, UUID referenceId) {
return switch (action) {
case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS ->
case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS,
JOB_BOOKMARK, MOCK_INTERVIEW_COMPLETE ->
referenceId != null &&
pointLogRepository.existsByUser_IdAndActionAndReferenceId(userId, action, referenceId);
case DAILY_LOGIN -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,29 @@
import java.util.UUID;

/**
* {@code GET /history/activity} — 활동 피드 (content_liked 포함). points 필드는 제외.
* {@code GET /history/activity} — 활동 피드 (content_liked 포함).
*/
public record ActivityItemResponse(
UUID id,
String actionType,
Integer points,
HistoryItemResponse.ContentInfo content,
HistoryItemResponse.PostInfo post,
HistoryItemResponse.AnswerInfo answer,
HistoryItemResponse.CommentInfo comment,
HistoryItemResponse.JobPostingInfo jobPosting,
Instant createdAt
) {
public static ActivityItemResponse from(HistoryItemResponse h) {
return new ActivityItemResponse(
h.id(),
h.actionType(),
h.points(),
h.content(),
h.post(),
h.answer(),
h.comment(),
h.jobPosting(),
h.createdAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ public record HistoryItemResponse(
PostInfo post,
AnswerInfo answer,
CommentInfo comment,
JobPostingInfo jobPosting,
Instant createdAt
) {
public record ContentInfo(UUID id, String title, String translatedTitle, String preview) {}
public record PostInfo(UUID id, String title) {}
public record AnswerInfo(UUID id, String preview) {}
public record CommentInfo(UUID id, String preview) {}
public record JobPostingInfo(UUID id, String title, String companyName) {}

public static HistoryItemResponse of(History history) {
return of(history, Map.of());
Expand Down Expand Up @@ -54,15 +56,24 @@ public static HistoryItemResponse of(History history, Map<UUID, String> summaryM
truncate(history.getComment().getContent(), 100))
: null;

JobPostingInfo jobPostingInfo = history.getJobPosting() != null
? new JobPostingInfo(
history.getJobPosting().getId(),
history.getJobPosting().getTitle(),
history.getJobPosting().getCompanyName())
: null;

Integer points = switch (history.getActionType()) {
case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints();
case "content_liked" -> PointAction.CONTENT_LIKE.getPoints();
case "question_created" -> PointAction.QUESTION_WRITE.getPoints();
case "answer_written" -> PointAction.ANSWER_WRITE.getPoints();
case "answer_adopted" -> PointAction.ANSWER_ADOPTED.getPoints();
case "daily_login" -> PointAction.DAILY_LOGIN.getPoints();
case "ai_quiz_completed" -> PointAction.AI_QUIZ_PASS.getPoints();
default -> null;
case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints();
case "content_liked" -> PointAction.CONTENT_LIKE.getPoints();
case "question_created" -> PointAction.QUESTION_WRITE.getPoints();
case "answer_written" -> PointAction.ANSWER_WRITE.getPoints();
case "answer_adopted" -> PointAction.ANSWER_ADOPTED.getPoints();
case "daily_login" -> PointAction.DAILY_LOGIN.getPoints();
case "ai_quiz_completed" -> PointAction.AI_QUIZ_PASS.getPoints();
case "job_bookmarked" -> PointAction.JOB_BOOKMARK.getPoints();
case "mock_interview_completed" -> PointAction.MOCK_INTERVIEW_COMPLETE.getPoints();
default -> null;
};

return new HistoryItemResponse(
Expand All @@ -73,6 +84,7 @@ public static HistoryItemResponse of(History history, Map<UUID, String> summaryM
postInfo,
answerInfo,
commentInfo,
jobPostingInfo,
history.getCreatedAt() != null ? history.getCreatedAt().toInstant(ZoneOffset.UTC) : null
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,28 +188,28 @@ Page<UUID> findAllHistoryIds(
@Param("userId") UUID userId,
Pageable pageable);

/** 학습 히스토리: content_liked 제외, 날짜 필터 없음 */
/** 학습 히스토리: 활동 전용 타입 제외, 날짜 필터 없음 */
@Query(value = "SELECT h.id FROM History h " +
"WHERE h.user.id = :userId " +
"AND h.actionType <> 'content_liked' " +
"AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " +
"ORDER BY h.createdAt DESC",
countQuery = "SELECT COUNT(h) FROM History h " +
"WHERE h.user.id = :userId " +
"AND h.actionType <> 'content_liked'")
"AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed')")
Page<UUID> findHistoryIdsExcludingContentLiked(
@Param("userId") UUID userId,
Pageable pageable);

/** 학습 히스토리: content_liked 제외, 날짜 필터 있음 */
/** 학습 히스토리: 활동 전용 타입 제외, 날짜 필터 있음 */
@Query(value = "SELECT h.id FROM History h " +
"WHERE h.user.id = :userId " +
"AND h.actionType <> 'content_liked' " +
"AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " +
"AND h.createdAt >= :startDate " +
"AND h.createdAt <= :endDate " +
"ORDER BY h.createdAt DESC",
countQuery = "SELECT COUNT(h) FROM History h " +
"WHERE h.user.id = :userId " +
"AND h.actionType <> 'content_liked' " +
"AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " +
"AND h.createdAt >= :startDate " +
"AND h.createdAt <= :endDate")
Page<UUID> findHistoryIdsByDateRangeExcludingContentLiked(
Expand Down
106 changes: 106 additions & 0 deletions src/test/java/com/devpick/domain/job/service/JobServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.devpick.domain.job.service;

import com.devpick.domain.job.entity.JobPosting;
import com.devpick.domain.job.repository.JobBookmarkRepository;
import com.devpick.domain.job.repository.JobPostingRepository;
import com.devpick.domain.point.entity.PointAction;
import com.devpick.domain.point.service.PointService;
import com.devpick.domain.report.repository.HistoryRepository;
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.fasterxml.jackson.databind.ObjectMapper;
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.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;

@ExtendWith(MockitoExtension.class)
class JobServiceTest {

@InjectMocks private JobService jobService;
@Mock private JobPostingRepository jobPostingRepository;
@Mock private JobBookmarkRepository jobBookmarkRepository;
@Mock private com.devpick.domain.resume.repository.MasterResumeRepository masterResumeRepository;
@Mock private com.devpick.domain.resume.service.ResumeCryptoService resumeCryptoService;
@Mock private UserRepository userRepository;
@Mock private com.devpick.domain.user.repository.TagRepository tagRepository;
@Mock private com.devpick.domain.content.repository.ContentRepository contentRepository;
@Mock private com.devpick.domain.job.client.JobAiClient jobAiClient;
@Mock private ObjectMapper objectMapper;
@Mock private HistoryRepository historyRepository;
@Mock private PointService pointService;

@Test
@DisplayName("채용공고 북마크 시 activity 히스토리 및 포인트가 기록된다")
void bookmark_newBookmark_recordsHistoryAndPoint() {
UUID userId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();

JobPosting job = mock(JobPosting.class);
given(job.getTitle()).willReturn("백엔드 개발자");
given(job.getCompanyName()).willReturn("카카오");
given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job));
given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(false);

User user = mock(User.class);
given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user));

jobService.bookmark(userId, jobId);

then(historyRepository).should().save(argThat(h ->
"job_bookmarked".equals(h.getActionType()) && h.getJobPosting() == job));
then(pointService).should().earn(user, PointAction.JOB_BOOKMARK, jobId);
}

@Test
@DisplayName("이미 북마크한 공고는 히스토리 및 포인트가 기록되지 않는다")
void bookmark_alreadyBookmarked_doesNotRecordHistoryOrPoint() {
UUID userId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();

JobPosting job = mock(JobPosting.class);
given(job.getTitle()).willReturn("백엔드 개발자");
given(job.getCompanyName()).willReturn("카카오");
given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job));
given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(true);

jobService.bookmark(userId, jobId);

then(historyRepository).should(never()).save(any());
then(pointService).should(never()).earn(any(), any(), any());
}

@Test
@DisplayName("존재하지 않는 사용자 북마크 시 예외가 발생한다")
void bookmark_userNotFound_throwsException() {
UUID userId = UUID.randomUUID();
UUID jobId = UUID.randomUUID();

JobPosting job = mock(JobPosting.class);
given(job.getTitle()).willReturn("백엔드 개발자");
given(job.getCompanyName()).willReturn("카카오");
given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job));
given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(false);
given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty());

assertThatThrownBy(() -> jobService.bookmark(userId, jobId))
.isInstanceOf(DevpickException.class)
.satisfies(e -> org.assertj.core.api.Assertions.assertThat(
((DevpickException) e).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND));
}
}
Loading
Loading