diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java index 4e57f6a..5fbf47f 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java @@ -87,9 +87,25 @@ public ApiResponse getApplicants( description = """ 공고 작성자와 지원 본인만 조회할 수 있다. 그 외에는 403 이다. - 지원자 프로필, 자기소개(`message`), 참고 링크, 첨부 파일 목록을 함께 반환한다. + 지원자 프로필 화면에 필요한 값을 한 번에 반환한다. + `GET /users/{userId}` 는 부를 필요가 없고, 프로젝트 이력도 최신 5건까지는 이 응답으로 끝난다. + + - 지원 정보 — `message`, `referenceLink`, `files` + - 지원자 프로필 — `applicant` 에 자기소개·지역·역할·관심 카테고리·활동 통계 + - 프로젝트 이력 — `applicant.portfolios` 에 최신 5건 + + `applicant.email` 은 이 API 에만 있다. 공개 프로필과 지원자 목록에는 담기지 않으며, + 수락한 지원자에게 연락할 수단으로 공고 작성자와 지원 본인에게만 열린다. + + `applicant.portfolios` 는 목록 API 와 같은 구조다. + 이력이 5건을 넘을 때만 `hasNext` 가 `true` 이며, 전체를 보여줘야 한다면 그때 `nextCursor` 를 + 그대로 넘겨 `GET /users/{userId}/portfolios` 로 이어서 조회한다. + `files` 에는 파일 본문이 아니라 메타데이터만 담긴다. 실제 내려받기는 첨부 파일 다운로드 API 를 쓴다. + + 탈퇴한 지원자의 지원도 조회된다. 지원 이력이 사라지면 공고 작성자가 지원자 목록에서 + 본 항목을 열 수 없게 되기 때문이다. """ ) @GetMapping("/{applicationId}") diff --git a/src/main/java/com/slatto/domain/recruitment/converter/RecruitmentConverter.java b/src/main/java/com/slatto/domain/recruitment/converter/RecruitmentConverter.java index 7740505..9a7a693 100644 --- a/src/main/java/com/slatto/domain/recruitment/converter/RecruitmentConverter.java +++ b/src/main/java/com/slatto/domain/recruitment/converter/RecruitmentConverter.java @@ -12,6 +12,8 @@ import com.slatto.domain.recruitment.dto.RecruitmentListResponse; import com.slatto.domain.recruitment.dto.RecruitmentRecommendationResponse; import com.slatto.domain.recruitment.dto.RecruitmentSummary; +import com.slatto.domain.user.dto.PortfolioListResponse; +import com.slatto.domain.user.dto.UserPublicProfileResponse; import com.slatto.domain.recruitment.entity.Recruitment; import com.slatto.domain.recruitment.entity.RecruitmentApplication; import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus; @@ -186,6 +188,7 @@ public RecruitmentApplicantListResponse.ApplicantSummary toApplicantSummary( .id(applicant.getId()) .nickname(applicant.getNickname()) .profileImageUrl(applicant.getProfileImageUrl()) + .bio(applicant.getBio()) .primaryRole(primaryRole) .locations(regions) .build()) @@ -203,14 +206,14 @@ public RecruitmentApplicationResponse toApplicationResponse(RecruitmentApplicati .build(); } + // 이메일은 공개 프로필에 없어 따로 받는다. 공개 프로필에 넣으면 로그인한 누구나 조회하게 된다. public RecruitmentApplicationDetailResponse toApplicationDetailResponse( RecruitmentApplication application, - RoleName primaryRole, - List regions, + UserPublicProfileResponse applicantProfile, + String applicantEmail, + PortfolioListResponse portfolios, List files ) { - Users applicant = application.getUser(); - return RecruitmentApplicationDetailResponse.builder() .applicationId(application.getId()) .recruitmentId(application.getRecruitment().getId()) @@ -219,11 +222,17 @@ public RecruitmentApplicationDetailResponse toApplicationDetailResponse( .referenceLink(application.getReferenceLink()) .appliedAt(application.getCreatedAt()) .applicant(RecruitmentApplicationDetailResponse.ApplicantProfile.builder() - .id(applicant.getId()) - .nickname(applicant.getNickname()) - .profileImageUrl(applicant.getProfileImageUrl()) - .primaryRole(primaryRole) - .locations(regions) + .id(applicantProfile.getId()) + .nickname(applicantProfile.getNickname()) + .email(applicantEmail) + .profileImageUrl(applicantProfile.getProfileImageUrl()) + .bio(applicantProfile.getBio()) + .primaryRole(applicantProfile.getPrimaryRole()) + .roles(applicantProfile.getRoles()) + .locations(applicantProfile.getLocations()) + .categories(applicantProfile.getCategories()) + .stats(applicantProfile.getStats()) + .portfolios(portfolios) .build()) .files(files) .build(); diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicantListResponse.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicantListResponse.java index e98567e..598c58f 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicantListResponse.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicantListResponse.java @@ -48,6 +48,8 @@ public static class ApplicantProfile { private String profileImageUrl; + private String bio; + private RoleName primaryRole; private List locations; diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicationDetailResponse.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicationDetailResponse.java index d741b78..7d2624b 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicationDetailResponse.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentApplicationDetailResponse.java @@ -1,6 +1,9 @@ package com.slatto.domain.recruitment.dto; import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus; +import com.slatto.domain.user.dto.PortfolioListResponse; +import com.slatto.domain.user.dto.UserStatsResponse; +import com.slatto.domain.user.enums.CategoryName; import com.slatto.domain.user.enums.RegionName; import com.slatto.domain.user.enums.RoleName; import lombok.Builder; @@ -30,6 +33,10 @@ public class RecruitmentApplicationDetailResponse { // 파일 본문은 이 응답에 담기지 않는다. 다운로드 API 로 개별 조회한다. private List files; + // 공개 프로필(GET /users/{userId}) 항목에 이메일을 더한 것이다. + // 이메일은 공개 프로필에 없다. 수락한 지원자에게 연락할 수단이 필요해 여기에만 담으며, + // 이 응답은 공고 작성자와 지원 본인만 열 수 있어 노출 범위가 제한된다. + // 목록 응답에는 넣지 않는다. 한 번의 조회로 지원자 전원의 이메일이 넘어가기 때문이다. @Getter @Builder public static class ApplicantProfile { @@ -38,10 +45,23 @@ public static class ApplicantProfile { private String nickname; + private String email; + private String profileImageUrl; + private String bio; + private RoleName primaryRole; + private List roles; + private List locations; + + private List categories; + + private UserStatsResponse stats; + + // 최신 몇 건만 담는다. 전체 목록은 GET /users/{userId}/portfolios 로 이어서 조회한다. + private PortfolioListResponse portfolios; } } diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java index 86c053d..d5aeb9b 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java @@ -21,6 +21,8 @@ import com.slatto.domain.user.repository.LocationRepository; import com.slatto.domain.user.repository.UserRepository; import com.slatto.domain.user.repository.UserRoleRepository; +import com.slatto.domain.user.service.PortfolioService; +import com.slatto.domain.user.service.UserService; import com.slatto.global.exception.BaseException; import com.slatto.global.response.code.CommonErrorCode; import lombok.RequiredArgsConstructor; @@ -47,6 +49,8 @@ public class RecruitmentApplicationService { private static final int DEFAULT_PAGE_SIZE = 10; private static final int MAX_PAGE_SIZE = 50; + // 지원 상세에 끼워 넣는 포트폴리오 건수다. 전체 목록은 GET /users/{userId}/portfolios 로 이어서 받는다. + private static final int APPLICANT_PORTFOLIO_SIZE = 5; private final RecruitmentRepository recruitmentRepository; private final RecruitmentApplicationRepository recruitmentApplicationRepository; @@ -57,6 +61,8 @@ public class RecruitmentApplicationService { private final RecruitmentConverter recruitmentConverter; private final RecruitmentNotificationDispatcher recruitmentNotificationDispatcher; private final RecruitmentApplicationFileService recruitmentApplicationFileService; + private final UserService userService; + private final PortfolioService portfolioService; @Transactional public RecruitmentApplicationResponse applyToRecruitment( @@ -160,13 +166,15 @@ public RecruitmentApplicationDetailResponse getApplicationDetail( validateApplicationAccess(recruitment, application, currentUserId); - Long applicantId = application.getUser().getId(); - List applicantIds = List.of(applicantId); + // 탈퇴한 지원자도 목록에 남으므로 유저 검증이 들어간 조회를 쓰지 않는다. + // getPublicProfile(Long) 을 쓰면 목록에는 보이는데 상세만 404 가 된다. + Users applicant = application.getUser(); return recruitmentConverter.toApplicationDetailResponse( application, - getPrimaryRoleByUserId(applicantIds).get(applicantId), - getRegionsByUserId(applicantIds).getOrDefault(applicantId, List.of()), + userService.getPublicProfileOf(applicant), + applicant.getEmail(), + portfolioService.getPortfoliosOf(applicant.getId(), null, APPLICANT_PORTFOLIO_SIZE), recruitmentApplicationFileService.getFileResponses(List.of(applicationId)) ); } diff --git a/src/main/java/com/slatto/domain/user/service/PortfolioService.java b/src/main/java/com/slatto/domain/user/service/PortfolioService.java index ef2da6f..828778f 100644 --- a/src/main/java/com/slatto/domain/user/service/PortfolioService.java +++ b/src/main/java/com/slatto/domain/user/service/PortfolioService.java @@ -152,6 +152,13 @@ public PortfolioDetailResponse getPortfolio(Long userId, Long portfolioId) { public PortfolioListResponse getPortfolios(Long userId, Long cursor, int size) { getUserOrThrow(userId); + return getPortfoliosOf(userId, cursor, size); + } + + // 탈퇴 여부를 판정하지 않는다. getPortfolios 를 쓰면 목록에는 보이는 지원이 상세에서만 404 가 된다. + // 탈퇴 시 포트폴리오도 함께 soft delete 되므로 탈퇴 유저의 이력 자체는 빈 목록으로 나간다. + // 닉네임·자기소개를 익명화하는 탈퇴 정책과 맞춘 동작이다. + public PortfolioListResponse getPortfoliosOf(Long userId, Long cursor, int size) { int pageSize = normalizePageSize(size); List portfolios = userPortfolioRepository.findActivePortfoliosByCursor( userId, diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index a3c43ed..39ed813 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -251,7 +251,14 @@ public UserProfileImageResponse uploadProfileImage(Long userId, MultipartFile fi } public UserPublicProfileResponse getPublicProfile(Long userId) { - Users user = getUserOrThrow(userId); + return getPublicProfileOf(getUserOrThrow(userId)); + } + + // 탈퇴 여부를 판정하지 않는다. 지원 상세처럼 목록에 남은 항목을 404 없이 열어야 하는 곳에서 쓴다. + // 탈퇴 시 닉네임·자기소개·프로필 이미지가 익명화되므로 그대로 내보내도 개인정보가 노출되지 않는다. + // 탈퇴 유저를 걸러야 하는 호출자는 getPublicProfile(Long) 을 쓴다. + public UserPublicProfileResponse getPublicProfileOf(Users user) { + Long userId = user.getId(); List roles = userRoleRepository.findAllByUserIdOrderByIdAsc(userId) .stream() diff --git a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java new file mode 100644 index 0000000..6dd0833 --- /dev/null +++ b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java @@ -0,0 +1,147 @@ +package com.slatto.domain.recruitment.service; + +import com.slatto.domain.recruitment.dto.RecruitmentApplicationDetailResponse; +import com.slatto.domain.recruitment.entity.Recruitment; +import com.slatto.domain.recruitment.entity.RecruitmentApplication; +import com.slatto.domain.recruitment.repository.RecruitmentApplicationRepository; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; +import com.slatto.domain.user.entity.UserPortfolio; +import com.slatto.domain.user.entity.UserPortfolioRole; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.RegionName; +import com.slatto.domain.user.enums.RoleName; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserPortfolioRepository; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +class RecruitmentApplicationDetailIntegrationTest { + + @Autowired + private RecruitmentApplicationService recruitmentApplicationService; + + @Autowired + private RecruitmentApplicationRepository recruitmentApplicationRepository; + + @Autowired + private RecruitmentRepository recruitmentRepository; + + @Autowired + private UserPortfolioRepository userPortfolioRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + private Users writer; + private Users applicant; + private Recruitment recruitment; + private RecruitmentApplication application; + + @BeforeEach + void setUp() { + writer = saveUser("writer@example.com", "작성자", "google-writer"); + applicant = saveUser("applicant@example.com", "지원자", "google-applicant"); + applicant.updateProfile("지원자", "영상 편집 5년차입니다", null); + + recruitment = recruitmentRepository.save(Recruitment.create( + writer, "공고 제목입니다", CategoryName.FILM_DRAMA, null, RoleName.DIRECTOR, + RegionName.SEOUL, null, null, "010-0000-0000", "설명", null + )); + application = recruitmentApplicationRepository.save( + RecruitmentApplication.create(applicant, recruitment, "지원합니다", "https://portfolio.example") + ); + } + + @Test + @DisplayName("지원 상세가 지원자 프로필과 프로젝트 이력을 함께 반환한다") + void returnsApplicantProfileAndPortfolios() { + savePortfolio(CategoryName.FILM_DRAMA, RoleName.EDITOR); + + RecruitmentApplicationDetailResponse response = recruitmentApplicationService.getApplicationDetail( + writer.getId(), recruitment.getId(), application.getId() + ); + + RecruitmentApplicationDetailResponse.ApplicantProfile profile = response.getApplicant(); + assertThat(profile.getBio()).isEqualTo("영상 편집 5년차입니다"); + assertThat(profile.getPortfolios().getItems()).hasSize(1); + assertThat(profile.getStats().getProjectTypes()).hasSize(1); + assertThat(profile.getStats().getRoles()).hasSize(1); + } + + @Test + @DisplayName("포트폴리오가 5건을 넘으면 hasNext 로 더 있음을 알린다") + void marksHasNextWhenPortfolioExceedsLimit() { + for (int i = 0; i < 6; i++) { + savePortfolio(CategoryName.FILM_DRAMA, RoleName.EDITOR); + } + + RecruitmentApplicationDetailResponse response = recruitmentApplicationService.getApplicationDetail( + writer.getId(), recruitment.getId(), application.getId() + ); + + assertThat(response.getApplicant().getPortfolios().getItems()).hasSize(5); + assertThat(response.getApplicant().getPortfolios().getHasNext()).isTrue(); + assertThat(response.getApplicant().getPortfolios().getNextCursor()).isNotNull(); + } + + @Test + @DisplayName("탈퇴한 지원자의 지원도 상세 조회된다") + void returnsDetailForWithdrawnApplicant() { + // 지원자 목록은 탈퇴 여부를 거르지 않는다. 상세만 404 가 되면 작성자가 목록에서 연 항목이 열리지 않는다. + applicant.withdraw(LocalDateTime.now()); + entityManager.flush(); + + RecruitmentApplicationDetailResponse response = recruitmentApplicationService.getApplicationDetail( + writer.getId(), recruitment.getId(), application.getId() + ); + + assertThat(response.getApplicant().getId()).isEqualTo(applicant.getId()); + } + + @Test + @DisplayName("공고 작성자도 지원 본인도 아니면 상세를 볼 수 없다") + void deniesDetailToStranger() { + Users stranger = saveUser("stranger@example.com", "제삼자", "google-stranger"); + + assertThatThrownBy(() -> recruitmentApplicationService.getApplicationDetail( + stranger.getId(), recruitment.getId(), application.getId() + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(CommonErrorCode.FORBIDDEN); + } + + private Users saveUser(String email, String nickname, String socialId) { + return userRepository.save(Users.createSocialUser( + email, nickname, null, SocialType.GOOGLE, socialId + )); + } + + private void savePortfolio(CategoryName type, RoleName roleName) { + UserPortfolio portfolio = userPortfolioRepository.save(UserPortfolio.create( + applicant, "작업 제목", type, null, Kind.PERSONAL, null, null, null, null, null + )); + entityManager.persist(UserPortfolioRole.create(portfolio, applicant, roleName)); + entityManager.flush(); + } +}