diff --git a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java index 4398f966..3cb05778 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/controller/HomeController.java @@ -1,10 +1,9 @@ package com.nect.api.domain.home.controller; -import com.nect.api.domain.home.dto.HomeHeaderResponse; -import com.nect.api.domain.home.dto.HomeMembersResponse; -import com.nect.api.domain.home.dto.HomeProjectResponse; -import com.nect.api.domain.home.dto.HomeStatisticResponse; +import com.nect.api.domain.home.dto.*; import com.nect.api.domain.home.facade.MainHomeFacade; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto; +import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; import com.nect.core.entity.user.enums.InterestField; @@ -19,6 +18,7 @@ public class HomeController { private final MainHomeFacade mainHomeFacade; + private final MypageService mypageService; // 모집 중인 프로젝트 조회, role, interest 필수 x @GetMapping("/projects") @@ -33,6 +33,15 @@ public ApiResponse recruitingProjects( return ApiResponse.ok(projects); } + // 모집 중인 프로젝트 상세 조회 + @GetMapping("/projects/{projectId}") + public ApiResponse recruitingProjectDetails( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long projectId + ) { + return ApiResponse.ok(mainHomeFacade.getRecruitingProjectsDetails(projectId)); + } + // 홈화면 프로젝트 추천 @GetMapping("/recommendations/projects") public ApiResponse recommendedProjects(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ @@ -54,9 +63,19 @@ public ApiResponse matchableMembers( return ApiResponse.ok(members); } + // 홈화면 매칭 가능한 넥터 - 세부정보 + @GetMapping("/members/{userId}") + public ApiResponse getMemberInfo( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long userId + ){ + return ApiResponse.ok(mypageService.getProfile(userId)); + } + // 홈화면 팀원 추천 @GetMapping("/recommendations/members") - public ApiResponse recommendedMembers(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count){ + public ApiResponse recommendedMembers(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestParam("count") int count) { + Long userId = resolveUserId(userDetails); HomeMembersResponse members = mainHomeFacade.getRecommendedMembers(userId, count); return ApiResponse.ok(members); diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java index e266685b..752751b2 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeMemberItem.java @@ -18,6 +18,7 @@ public class HomeMemberItem { private String name; private String part; private String introduction; + private String coreCompetencies; private String status; private Boolean isScrapped; private List roles; @@ -28,6 +29,7 @@ public static HomeMemberItem of( String name, String part, String introduction, + String coreCompetencies, String status, Boolean isScrapped, List roles @@ -38,6 +40,7 @@ public static HomeMemberItem of( .name(name) .part(part) .introduction(introduction) + .coreCompetencies(coreCompetencies) .status(status) .isScrapped(isScrapped) .roles(roles) diff --git a/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectDetailResponse.java b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectDetailResponse.java new file mode 100644 index 00000000..2c5e8283 --- /dev/null +++ b/nect-api/src/main/java/com/nect/api/domain/home/dto/HomeProjectDetailResponse.java @@ -0,0 +1,20 @@ +package com.nect.api.domain.home.dto; + +import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; +import lombok.*; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HomeProjectDetailResponse { + + private MyProjectsResponseDto.ProjectInfo defaultInfo; // 기본정보 + private MyProjectsResponseDto.ProjectFieldResponse fields; // 프로젝트 분야 + // private stack...; // TODO 필수스택 + private MyProjectsResponseDto.StringListResponse purposes; // 프로젝트 목표 + private MyProjectsResponseDto.StringListResponse functions; // 주요기능 + private MyProjectsResponseDto.StringListResponse serviceUsers; // 서비스 사용자 + private MyProjectsResponseDto.ProjectPlanFilesResponse planFiles; // 기획 파일 + +} diff --git a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java index a3852d5f..945d7028 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/facade/MainHomeFacade.java @@ -5,6 +5,8 @@ import com.nect.api.domain.home.service.HomeMemberQueryService; import com.nect.api.domain.home.service.HomeProjectQueryService; import com.nect.api.domain.home.service.HomeStatisticsQueryService; +import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; +import com.nect.api.domain.mypage.service.MyPageProjectQueryService; import com.nect.api.global.infra.S3Service; import com.nect.core.entity.team.Project; import com.nect.core.entity.user.User; @@ -31,6 +33,7 @@ public class MainHomeFacade { private final HomeMemberQueryService homeMemberQueryService; private final HomeStatisticsQueryService statisticsQueryService; private final S3Service s3Service; + private final MyPageProjectQueryService myPageProjectQueryService; // 모집 중인 프로젝트 public HomeProjectResponse getRecruitingProjects(Long userId, int count, Role role, InterestField interest){ @@ -40,26 +43,40 @@ public HomeProjectResponse getRecruitingProjects(Long userId, int count, Role ro // 페이징 정보 PageRequest pageRequest = PageRequest.of(0, safeCount); - // List 미리 생성 -// List projects = new ArrayList<>(); - -// // 둘 중 하나가 null일 수는 없음 -// if ((role == null && interest != null) || (role != null && interest == null)) { -// throw new HomeInvalidParametersException("role과 interest 중 하나만 null일 수 없습니다."); -// } -// -// // role이 null일 때 -// if (role == null) { -// -// }else{ -// -// } + // 둘 중 하나가 null일 수는 없음 + if ((role == null && interest != null) || (role != null && interest == null)) { + throw new HomeInvalidParametersException("role과 interest 중 하나만 null일 수 없습니다."); + } - List projects = homeProjectQueryService.getProjects(userId, pageRequest); + List projects; + if (role != null) { + projects = homeProjectQueryService.getFilteredProjects(userId, pageRequest, role, interest); + } else { + projects = homeProjectQueryService.getProjects(userId, pageRequest); + } return buildProjectResponse(projects); } + // 모집 중인 프로젝트 + public HomeProjectDetailResponse getRecruitingProjectsDetails(Long projectId) { + + MyProjectsResponseDto.ProjectInfo defaultInfo = homeProjectQueryService.getProject(projectId); + MyProjectsResponseDto.ProjectFieldResponse fields = myPageProjectQueryService.getProjectFields(projectId); + MyProjectsResponseDto.StringListResponse purposes = myPageProjectQueryService.getPurposes(projectId); + MyProjectsResponseDto.StringListResponse functions = myPageProjectQueryService.getFunctions(projectId); + MyProjectsResponseDto.StringListResponse serviceUsers = myPageProjectQueryService.getServiceUsers(projectId); + MyProjectsResponseDto.ProjectPlanFilesResponse planFiles = myPageProjectQueryService.getPlanFiles(projectId); + return HomeProjectDetailResponse.builder() + .defaultInfo(defaultInfo) + .fields(fields) + .purposes(purposes) + .functions(functions) + .serviceUsers(serviceUsers) + .planFiles(planFiles) + .build(); + } + // 홈화면 추천 프로젝트들 public HomeProjectResponse getRecommendedProjects(Long userId, int count) { int safeCount = safeCount(count); @@ -168,7 +185,8 @@ private List responsesFromMembers(List users) { s3Service.getPresignedGetUrl(user.getProfileImageName()), user.getName(), user.getRole() != null ? user.getRole().name() : null, - null, + user.getBio(), + user.getCoreCompetencies(), user.getUserStatus() != null ? user.getUserStatus().name() : null, false, parts diff --git a/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java index 1f9d54fd..852b56ac 100644 --- a/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/home/service/HomeProjectQueryService.java @@ -1,12 +1,24 @@ package com.nect.api.domain.home.service; +import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; +import com.nect.api.domain.team.project.enums.code.ProjectErrorCode; +import com.nect.api.domain.team.project.exception.ProjectException; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.matching.Recruitment; import com.nect.core.entity.team.Project; +import com.nect.core.entity.team.ProjectTeamRole; +import com.nect.core.entity.team.ProjectUser; +import com.nect.core.entity.team.enums.ProjectMemberStatus; +import com.nect.core.entity.team.enums.ProjectMemberType; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.Role; import com.nect.core.entity.user.enums.RoleField; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.user.User; import com.nect.core.repository.matching.RecruitmentRepository; import com.nect.core.repository.team.ProjectRepository; +import com.nect.core.repository.team.ProjectTeamRoleRepository; +import com.nect.core.repository.user.ProjectUserRepositoryComplete; import com.nect.core.repository.team.ProjectUserRepository; import com.nect.core.repository.user.UserRepository; import lombok.RequiredArgsConstructor; @@ -32,6 +44,9 @@ public class HomeProjectQueryService { private final ProjectUserRepository projectUserRepository; private final RecruitmentRepository recruitmentRepository; private final UserRepository userRepository; + private final ProjectUserRepositoryComplete projectUserRepositoryComplete; + private final ProjectTeamRoleRepository projectTeamRoleRepository; + private final S3Service s3Service; public record HomeProjectBatch( Map authorByProjectId, @@ -113,19 +128,108 @@ public List getProjects(Long userId, PageRequest pageRequest){ : projectRepository.findHomeProjects(userId, RecruitmentStatus.OPEN, pageRequest); } + public List getFilteredProjects(Long userId, PageRequest pageRequest, Role role, InterestField interest) { + List roleFields = Arrays.stream(RoleField.getFieldsByRole(role)) + .filter(field -> field.getRole() == role) + .toList(); + + if (roleFields.isEmpty()) { + return List.of(); + } + + return projectRepository.findHomeProjectsByRoleAndInterest( + userId, + RecruitmentStatus.OPEN, + interest, + roleFields, + pageRequest + ); + } + public List getProjects(Long userId) { return (userId == null) ? projectRepository.findHomeProjectsWithoutUser(RecruitmentStatus.OPEN) : projectRepository.findHomeProjects(userId, RecruitmentStatus.OPEN); } + public MyProjectsResponseDto.ProjectInfo getProject(Long projectId) { + if (projectId == null) { + throw new ProjectException(ProjectErrorCode.INVALID_REQUEST, "projectId is required"); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); + + List teamRoles = projectTeamRoleRepository.findByProjectId(projectId); + List roleInfos = teamRoles.stream() + .map(role -> MyProjectsResponseDto.TeamRoleInfo.builder() + .roleField(role.getRoleField()) + .requiredCount(role.getRequiredCount()) + .build()) + .toList(); + + MyProjectsResponseDto.LeaderInfo leaderInfo = projectUserRepositoryComplete + .findByProjectIdAndMemberType(projectId, ProjectMemberType.LEADER) + .map(ProjectUser::getUserId) + .flatMap(userRepository::findById) + .map(leader -> MyProjectsResponseDto.LeaderInfo.builder() + .userId(leader.getUserId()) + .name(leader.getName()) + .profileImageUrl(s3Service.getPresignedGetUrl(leader.getProfileImageName())) + .build()) + .orElse(null); + + List activeMembers = projectUserRepositoryComplete + .findByProjectIdAndMemberStatus(projectId, ProjectMemberStatus.ACTIVE); + List teamMemberProjects = + getTeamMemberProjectsByProject(activeMembers, projectId); + + return MyProjectsResponseDto.ProjectInfo.builder() + .projectId(projectId) + .projectTitle(project.getTitle()) + .description(project.getDescription()) + .imageName(s3Service.getPresignedGetUrl(project.getImageName())) + .plannedStartedOn(project.getPlannedStartedOn()) + .plannedEndedOn(project.getPlannedEndedOn()) + .teamRoles(roleInfos) + .leader(leaderInfo) + .teamMemberProjects(teamMemberProjects) + .build(); + } + public Integer getDDay(Project project) { LocalDateTime endedAt = project.getEndedAt(); LocalDate today = LocalDate.now(); LocalDate endDate = endedAt.toLocalDate(); return (int) ChronoUnit.DAYS.between(today, endDate); } -} + private List getTeamMemberProjectsByProject(List activeMembers, Long projectId) { + List teamMemberIds = activeMembers.stream() + .map(ProjectUser::getUserId) + .distinct() + .toList(); + if (teamMemberIds.isEmpty()) { + return List.of(); + } + + List teamMemberProjects = projectUserRepositoryComplete + .findByUserIdInAndMemberStatus(teamMemberIds, ProjectMemberStatus.ACTIVE); + + return teamMemberProjects.stream() + .map(ProjectUser::getProject) + .filter(project -> !project.getId().equals(projectId)) + .distinct() + .map(project -> MyProjectsResponseDto.TeamMemberProjectInfo.builder() + .projectId(project.getId()) + .title(project.getTitle()) + .description(project.getDescription()) + .imageName(s3Service.getPresignedGetUrl(project.getImageName())) + .createdAt(project.getCreatedAt()) + .endedAt(project.getEndedAt()) + .build()) + .toList(); + } +} diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java index 80b8411d..ff2d43b3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java @@ -131,7 +131,7 @@ public ApiResponse getFunctions( @PathVariable Long projectId, @AuthenticationPrincipal UserDetailsImpl userDetails ) { - return ApiResponse.ok(projectQueryService.getPurposes(projectId)); + return ApiResponse.ok(projectQueryService.getFunctions(projectId)); } // 주요기능 작성 diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java index 77ac7dcf..2afdedc4 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/service/MyPageProjectQueryService.java @@ -115,6 +115,14 @@ public MyProjectsResponseDto.StringListResponse getPurposes(Long projectId) { return new MyProjectsResponseDto.StringListResponse(projectId, slicedPurpose); } + public MyProjectsResponseDto.StringListResponse getFunctions(Long projectId) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); + String functions = project.getMainFunctions(); + List slicedPurpose = projectListConverter.convertToEntityAttribute(functions); + return new MyProjectsResponseDto.StringListResponse(projectId, slicedPurpose); + } + public MyProjectsResponseDto.StringListResponse getServiceUsers(Long projectId) { Project project = projectRepository.findById(projectId) .orElseThrow(() -> new ProjectException(ProjectErrorCode.PROJECT_NOT_FOUND)); diff --git a/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java b/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java index fc4d3408..d27fceb3 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/chat/util/FileValidator.java @@ -9,7 +9,7 @@ public class FileValidator { - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB private static final List ALLOWED_IMAGE_TYPES = Arrays.asList( "image/jpeg", "image/jpg", diff --git a/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java b/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java index d19b56d4..fac5dea0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/file/util/FileUploadValidator.java @@ -12,10 +12,10 @@ public final class FileUploadValidator { private static final long MB = 1024L * 1024L; - private static final long MAX_5MB = 5L * MB; + private static final long MAX_10MB = 10L * MB; private static final long MAX_20MB = 20L * MB; - private static final Set LIMIT_5MB = EnumSet.of(FileExt.JPG, FileExt.PNG, FileExt.SVG); + private static final Set LIMIT_10MB = EnumSet.of(FileExt.JPG, FileExt.PNG, FileExt.SVG); private static final Set LIMIT_20MB = EnumSet.of(FileExt.PDF, FileExt.DOCS, FileExt.PPTX, FileExt.FIG, FileExt.ZIP); private FileUploadValidator() { @@ -33,8 +33,8 @@ public static void validateNotEmpty(MultipartFile file) { public static void validateSizeOrThrow(FileExt ext, long fileSize) { long max; - if (LIMIT_5MB.contains(ext)) { - max = MAX_5MB; + if (LIMIT_10MB.contains(ext)) { + max = MAX_10MB; } else if (LIMIT_20MB.contains(ext)) { max = MAX_20MB; } else { diff --git a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java index 36fc1d71..215dc4ac 100644 --- a/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/home/controller/HomeControllerTest.java @@ -4,16 +4,23 @@ import com.nect.api.domain.home.dto.HomeHeaderResponse; import com.nect.api.domain.home.dto.HomeMemberItem; import com.nect.api.domain.home.dto.HomeMembersResponse; +import com.nect.api.domain.home.dto.HomeProjectDetailResponse; import com.nect.api.domain.home.dto.HomeProjectItem; import com.nect.api.domain.home.dto.HomeProjectResponse; import com.nect.api.domain.home.dto.HomeStatisticResponse; import com.nect.api.domain.home.facade.MainHomeFacade; +import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; +import com.nect.api.domain.mypage.dto.ProfileSettingsDto; +import com.nect.api.domain.mypage.service.MypageService; import com.nect.api.global.jwt.JwtUtil; import com.nect.api.global.jwt.service.TokenBlacklistService; import com.nect.api.global.security.UserDetailsImpl; import com.nect.api.global.security.UserDetailsServiceImpl; +import com.nect.core.entity.team.enums.FileExt; +import com.nect.core.entity.team.enums.PlanFileType; import com.nect.core.entity.user.enums.InterestField; import com.nect.core.entity.user.enums.Role; +import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,6 +35,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -55,6 +65,9 @@ class HomeControllerTest { @MockitoBean private MainHomeFacade mainHomeFacade; + @MockitoBean + private MypageService mypageService; + @MockitoBean private JwtUtil jwtUtil; @@ -117,6 +130,34 @@ void setUpAuth() { )); } + @Test + @DisplayName("모집 중인 프로젝트 상세 조회 API") + void 모집_중인_프로젝트_상세_조회_API() throws Exception { + given(mainHomeFacade.getRecruitingProjectsDetails(eq(10L))) + .willReturn(mockProjectDetailResponse()); + + mockMvc.perform(get("/api/v1/home/projects/{projectId}", 10L) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("home-projects-recruiting-detail", + resource(ResourceSnippetParameters.builder() + .tag("Home") + .summary("모집 중인 프로젝트 상세 조회") + .description("홈 화면에서 모집 중인 프로젝트의 상세 정보를 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .responseFields(projectDetailResponseFields()) + .build() + ) + )); + } + @Test @DisplayName("홈화면 프로젝트 추천 API") void 홈화면_프로젝트_추천_API() throws Exception { @@ -225,6 +266,34 @@ void setUpAuth() { )); } + @Test + @DisplayName("홈화면 매칭 가능한 넥터 상세조회 API") + void 홈화면_매칭_가능한_넥터_상세조회_API() throws Exception { + given(mypageService.getProfile(21L)) + .willReturn(mockProfileSettingsResponse()); + + mockMvc.perform(get("/api/v1/home/members/{userId}", 21L) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("home-members-matchable-detail", + resource(ResourceSnippetParameters.builder() + .tag("Home") + .summary("홈화면 매칭 가능한 넥터 상세 조회") + .description("홈 화면에서 매칭 가능한 넥터의 상세 프로필 정보를 조회합니다.") + .requestHeaders( + headerWithName("Authorization").description("액세스 토큰 (Bearer 스키마)") + ) + .pathParameters( + parameterWithName("userId").description("유저 ID") + ) + .responseFields(profileSettingsResponseFields()) + .build() + ) + )); + } + @Test @DisplayName("홈화면 팀원 추천 API") void 홈화면_팀원_추천_API() throws Exception { @@ -330,6 +399,7 @@ private HomeMembersResponse mockMembersResponse() { "이영희", "DESIGNER", "사용자 경험 중심의 디자인을 지향합니다.", + "저는 핵심역량이에요", "JOB_SEEKING", true, List.of("PM", "Design") @@ -340,6 +410,7 @@ private HomeMembersResponse mockMembersResponse() { "박민수", "DEVELOPER", "대규모 트래픽 처리를 경험했습니다.", + "저는 핵심역량이에요", "EMPLOYED", false, List.of("Server", "Frontend") @@ -347,6 +418,149 @@ private HomeMembersResponse mockMembersResponse() { )); } + private HomeProjectDetailResponse mockProjectDetailResponse() { + MyProjectsResponseDto.ProjectInfo projectInfo = MyProjectsResponseDto.ProjectInfo.builder() + .projectId(10L) + .projectTitle("AI 협업툴 개발") + .description("팀 협업 효율을 높이는 AI 기반 협업툴 프로젝트입니다.") + .plannedStartedOn(LocalDate.of(2025, 9, 1)) + .plannedEndedOn(LocalDate.of(2026, 2, 1)) + .imageName("project-10.png") + .teamRoles(List.of( + MyProjectsResponseDto.TeamRoleInfo.builder() + .roleField(RoleField.BACKEND) + .requiredCount(2) + .build(), + MyProjectsResponseDto.TeamRoleInfo.builder() + .roleField(RoleField.UI_UX) + .requiredCount(1) + .build() + )) + .leader(MyProjectsResponseDto.LeaderInfo.builder() + .userId(1L) + .name("홍길동") + .profileImageUrl("https://example.com/profile/1.png") + .build()) + .teamMemberProjects(List.of( + MyProjectsResponseDto.TeamMemberProjectInfo.builder() + .projectId(99L) + .title("모바일 일정 관리") + .description("개인 맞춤 일정 관리 앱을 개발합니다.") + .imageName("project-99.png") + .createdAt(LocalDateTime.of(2024, 1, 1, 10, 0)) + .endedAt(LocalDateTime.of(2024, 12, 31, 23, 59)) + .build() + )) + .build(); + + MyProjectsResponseDto.ProjectFieldResponse fields = sampleProjectFields(10L); + + MyProjectsResponseDto.StringListResponse purposes = new MyProjectsResponseDto.StringListResponse( + 10L, + List.of("협업 효율 개선", "AI 기반 자동화") + ); + + MyProjectsResponseDto.StringListResponse functions = new MyProjectsResponseDto.StringListResponse( + 10L, + List.of("태스크 자동 분류", "회의 요약") + ); + + MyProjectsResponseDto.StringListResponse serviceUsers = new MyProjectsResponseDto.StringListResponse( + 10L, + List.of("프로덕트 팀", "개발 팀") + ); + + MyProjectsResponseDto.ProjectPlanFilesResponse planFiles = new MyProjectsResponseDto.ProjectPlanFilesResponse( + 10L, + List.of( + new MyProjectsResponseDto.ProjectPlanFileInfo( + 1L, + "기획서", + "plan.pdf", + PlanFileType.FILE, + FileExt.PDF + ) + ) + ); + + return HomeProjectDetailResponse.builder() + .defaultInfo(projectInfo) + .fields(fields) + .purposes(purposes) + .functions(functions) + .serviceUsers(serviceUsers) + .planFiles(planFiles) + .build(); + } + + private MyProjectsResponseDto.ProjectFieldResponse sampleProjectFields(Long projectId) { + try { + var interestInfoClass = Arrays.stream(MyProjectsResponseDto.class.getDeclaredClasses()) + .filter(c -> "InterestInfo".equals(c.getSimpleName())) + .findFirst() + .orElseThrow(); + var interestCtor = interestInfoClass.getDeclaredConstructor(String.class, Boolean.class); + interestCtor.setAccessible(true); + Object interestInfo = interestCtor.newInstance("IT/웹모바일", true); + + var ctor = MyProjectsResponseDto.ProjectFieldResponse.class + .getDeclaredConstructor(Long.class, List.class); + ctor.setAccessible(true); + return ctor.newInstance(projectId, List.of(interestInfo)); + } catch (Exception e) { + throw new IllegalStateException("failed to create ProjectFieldResponse", e); + } + } + + private ProfileSettingsDto.ProfileSettingsResponseDto mockProfileSettingsResponse() { + return new ProfileSettingsDto.ProfileSettingsResponseDto( + 21L, + "이영희", + "lee-0", + "lee@example.com", + "DESIGNER", + "https://example.com/profile/21.png", + "사용자 경험 중심의 디자인을 지향합니다.", + "Figma, UX Research", + "JOB_SEEKING", + true, + "1년", + "UI/UX 디자이너", + "IT/웹모바일", + List.of(new ProfileSettingsDto.CareerDto( + 1L, + "UX 프로젝트", + "IT", + "2023.01", + "2023.12", + false, + "디자이너", + List.of(new ProfileSettingsDto.AchievementDto(1L, "성과", "사용자 만족도 20% 향상")) + )), + List.of(new ProfileSettingsDto.PortfolioDto( + 1L, + "디자인 포트폴리오", + "https://example.com/portfolio", + "https://example.com/portfolio.pdf" + )), + List.of(new ProfileSettingsDto.ProjectHistoryDto( + 1L, + "커머스 리디자인", + "https://example.com/project.png", + "커머스 UX 개선", + "2023.01", + "2023.06" + )), + List.of(new ProfileSettingsDto.SkillDto( + "DESIGN", + "디자인", + List.of(new ProfileSettingsDto.SkillItemDto("FIGMA", "Figma", true)) + )), + "디자인 주도형", + List.of("#UX", "#디자인") + ); + } + private static List projectResponseFields() { return List.of( fieldWithPath("status.statusCode").description("응답 상태 코드"), @@ -384,6 +598,7 @@ private static List memberResponseFields() { fieldWithPath("body.members[].name").description("이름"), fieldWithPath("body.members[].part").description("파트(역할)"), fieldWithPath("body.members[].introduction").description("소개"), + fieldWithPath("body.members[].coreCompetencies").description("핵심 역량"), fieldWithPath("body.members[].status").description("상태"), fieldWithPath("body.members[].isScrapped").description("스크랩 여부"), fieldWithPath("body.members[].roles").description("역할 목록") @@ -403,4 +618,115 @@ private static List headerProfileResponseFields() { ); } + private static List projectDetailResponseFields() { + return List.of( + fieldWithPath("status.statusCode").description("응답 상태 코드"), + fieldWithPath("status.message").description("응답 메시지"), + fieldWithPath("status.description").optional().description("응답 상세 설명"), + + fieldWithPath("body.defaultInfo").description("프로젝트 기본 정보"), + fieldWithPath("body.defaultInfo.project_id").description("프로젝트 ID"), + fieldWithPath("body.defaultInfo.project_title").description("프로젝트 제목"), + fieldWithPath("body.defaultInfo.description").description("프로젝트 소개"), + fieldWithPath("body.defaultInfo.planned_started_on").type(JsonFieldType.STRING).optional().description("프로젝트 시작 예정일"), + fieldWithPath("body.defaultInfo.planned_ended_on").type(JsonFieldType.STRING).optional().description("프로젝트 종료 예정일"), + fieldWithPath("body.defaultInfo.image_name").type(JsonFieldType.STRING).optional().description("프로젝트 이미지 파일명"), + fieldWithPath("body.defaultInfo.team_roles").description("프로젝트 팀 역할 목록"), + fieldWithPath("body.defaultInfo.team_roles[].role_field").description("팀 역할(RoleField)"), + fieldWithPath("body.defaultInfo.team_roles[].required_count").description("필요 인원"), + fieldWithPath("body.defaultInfo.leader").description("프로젝트 리더 정보"), + fieldWithPath("body.defaultInfo.leader.user_id").description("리더 유저 ID"), + fieldWithPath("body.defaultInfo.leader.name").description("리더 이름"), + fieldWithPath("body.defaultInfo.leader.profile_image_url").type(JsonFieldType.STRING).optional().description("리더 프로필 이미지 URL"), + fieldWithPath("body.defaultInfo.team_member_projects").description("팀원들의 다른 프로젝트 목록"), + fieldWithPath("body.defaultInfo.team_member_projects[].project_id").description("프로젝트 ID"), + fieldWithPath("body.defaultInfo.team_member_projects[].title").description("프로젝트 제목"), + fieldWithPath("body.defaultInfo.team_member_projects[].description").description("프로젝트 설명"), + fieldWithPath("body.defaultInfo.team_member_projects[].image_name").type(JsonFieldType.STRING).optional().description("프로젝트 이미지 파일명"), + fieldWithPath("body.defaultInfo.team_member_projects[].created_at").description("프로젝트 생성일"), + fieldWithPath("body.defaultInfo.team_member_projects[].ended_at").type(JsonFieldType.STRING).optional().description("프로젝트 종료일"), + + fieldWithPath("body.fields").description("프로젝트 분야"), + fieldWithPath("body.fields.project_id").description("프로젝트 ID"), + fieldWithPath("body.fields.fields").type(JsonFieldType.ARRAY).description("프로젝트 분야 목록"), + fieldWithPath("body.fields.fields[].field_name").type(JsonFieldType.STRING).optional().description("분야 이름"), + fieldWithPath("body.fields.fields[].is_selected").type(JsonFieldType.BOOLEAN).optional().description("선택 여부"), + + fieldWithPath("body.purposes").description("프로젝트 목표"), + fieldWithPath("body.purposes.project_id").description("프로젝트 ID"), + fieldWithPath("body.purposes.values").description("프로젝트 목표 목록"), + + fieldWithPath("body.functions").description("주요 기능"), + fieldWithPath("body.functions.project_id").description("프로젝트 ID"), + fieldWithPath("body.functions.values").description("주요 기능 목록"), + + fieldWithPath("body.serviceUsers").description("서비스 사용자"), + fieldWithPath("body.serviceUsers.project_id").description("프로젝트 ID"), + fieldWithPath("body.serviceUsers.values").description("서비스 사용자 목록"), + + fieldWithPath("body.planFiles").description("기획 파일 목록"), + fieldWithPath("body.planFiles.project_id").description("프로젝트 ID"), + fieldWithPath("body.planFiles.files").description("기획 파일 목록"), + fieldWithPath("body.planFiles.files[].plan_file_id").description("기획 파일 ID"), + fieldWithPath("body.planFiles.files[].name").description("기획 파일 이름"), + fieldWithPath("body.planFiles.files[].file_name").description("파일명"), + fieldWithPath("body.planFiles.files[].plan_file_type").description("기획 파일 타입"), + fieldWithPath("body.planFiles.files[].file_ext").description("파일 확장자") + ); + } + + private static List profileSettingsResponseFields() { + return List.of( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").type(JsonFieldType.STRING).description("상태 설명").optional(), + fieldWithPath("body.userId").type(JsonFieldType.NUMBER).description("사용자 ID"), + fieldWithPath("body.name").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("body.nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("body.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("body.role").type(JsonFieldType.STRING).description("역할 (DEVELOPER, DESIGNER, PLANNER, MARKETER)").optional(), + fieldWithPath("body.profileImageUrl").type(JsonFieldType.STRING).description("프로필 사진 URL").optional(), + fieldWithPath("body.bio").type(JsonFieldType.STRING).description("자기소개").optional(), + fieldWithPath("body.coreCompetencies").type(JsonFieldType.STRING).description("핵심 역량").optional(), + fieldWithPath("body.userStatus").type(JsonFieldType.STRING).description("사용자 상태 (예: 재학중, 구직중, 재직중)").optional(), + fieldWithPath("body.isPublicMatching").type(JsonFieldType.BOOLEAN).description("공개 매칭 여부"), + fieldWithPath("body.careerDuration").type(JsonFieldType.STRING).description("경력 기간").optional(), + fieldWithPath("body.interestedJob").type(JsonFieldType.STRING).description("관심 직무").optional(), + fieldWithPath("body.interestedField").type(JsonFieldType.STRING).description("관심 직종").optional(), + fieldWithPath("body.careers").type(JsonFieldType.ARRAY).description("경력 목록"), + fieldWithPath("body.careers[].userCareerId").type(JsonFieldType.NUMBER).description("경력 ID"), + fieldWithPath("body.careers[].projectName").type(JsonFieldType.STRING).description("프로젝트명"), + fieldWithPath("body.careers[].industryField").type(JsonFieldType.STRING).description("산업 분야"), + fieldWithPath("body.careers[].startDate").type(JsonFieldType.STRING).description("시작일"), + fieldWithPath("body.careers[].endDate").type(JsonFieldType.STRING).description("종료일").optional(), + fieldWithPath("body.careers[].isOngoing").type(JsonFieldType.BOOLEAN).description("진행중 여부"), + fieldWithPath("body.careers[].role").type(JsonFieldType.STRING).description("역할"), + fieldWithPath("body.careers[].achievements").type(JsonFieldType.ARRAY).description("성과 목록"), + fieldWithPath("body.careers[].achievements[].userAchievementId").type(JsonFieldType.NUMBER).description("성과 ID"), + fieldWithPath("body.careers[].achievements[].title").type(JsonFieldType.STRING).description("성과 제목"), + fieldWithPath("body.careers[].achievements[].content").type(JsonFieldType.STRING).description("성과 내용"), + fieldWithPath("body.portfolios").type(JsonFieldType.ARRAY).description("포트폴리오 목록"), + fieldWithPath("body.portfolios[].userPortfolioId").type(JsonFieldType.NUMBER).description("포트폴리오 ID"), + fieldWithPath("body.portfolios[].title").type(JsonFieldType.STRING).description("포트폴리오 제목"), + fieldWithPath("body.portfolios[].link").type(JsonFieldType.STRING).description("포트폴리오 링크").optional(), + fieldWithPath("body.portfolios[].fileUrl").type(JsonFieldType.STRING).description("포트폴리오 파일 URL").optional(), + fieldWithPath("body.projectHistories").type(JsonFieldType.ARRAY).description("프로젝트 히스토리 목록"), + fieldWithPath("body.projectHistories[].userProjectHistoryId").type(JsonFieldType.NUMBER).description("프로젝트 히스토리 ID"), + fieldWithPath("body.projectHistories[].projectName").type(JsonFieldType.STRING).description("프로젝트 이름"), + fieldWithPath("body.projectHistories[].projectImage").type(JsonFieldType.STRING).description("프로젝트 이미지").optional(), + fieldWithPath("body.projectHistories[].projectDescription").type(JsonFieldType.STRING).description("프로젝트 설명").optional(), + fieldWithPath("body.projectHistories[].startYearMonth").type(JsonFieldType.STRING).description("시작 연월"), + fieldWithPath("body.projectHistories[].endYearMonth").type(JsonFieldType.STRING).description("종료 연월").optional(), + fieldWithPath("body.skills").type(JsonFieldType.ARRAY).description("스킬 목록"), + fieldWithPath("body.skills[].category").type(JsonFieldType.STRING).description("스킬 카테고리"), + fieldWithPath("body.skills[].categoryLabel").type(JsonFieldType.STRING).description("스킬 카테고리 라벨"), + fieldWithPath("body.skills[].skills").type(JsonFieldType.ARRAY).description("스킬 항목 목록"), + fieldWithPath("body.skills[].skills[].skill").type(JsonFieldType.STRING).description("스킬 코드"), + fieldWithPath("body.skills[].skills[].skillLabel").type(JsonFieldType.STRING).description("스킬 라벨"), + fieldWithPath("body.skills[].skills[].isSelected").type(JsonFieldType.BOOLEAN).description("선택 여부"), + fieldWithPath("body.profileType").type(JsonFieldType.STRING).description("AI 프로필 분석 타입 (예: 기술 주도형 개발자)").optional(), + fieldWithPath("body.tags").type(JsonFieldType.ARRAY).description("프로필 분석 키워드 태그 (예: #프로그래밍전문가, #백엔드개발자)").optional() + ); + } + } diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index f0c17ad4..4d8071b3 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -422,7 +422,7 @@ void getFunctions() throws Exception { MyProjectsResponseDto.StringListResponse response = new MyProjectsResponseDto.StringListResponse(projectId, List.of("기능1", "기능2")); - given(projectQueryService.getPurposes(eq(projectId))).willReturn(response); + given(projectQueryService.getFunctions(eq(projectId))).willReturn(response); mockMvc.perform( get("/api/v1/mypage/projects/{projectId}/functions", projectId) diff --git a/nect-core/src/main/java/com/nect/core/repository/team/ProjectRepository.java b/nect-core/src/main/java/com/nect/core/repository/team/ProjectRepository.java index 8e5bfc43..94177f07 100644 --- a/nect-core/src/main/java/com/nect/core/repository/team/ProjectRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/team/ProjectRepository.java @@ -2,6 +2,8 @@ import com.nect.core.entity.team.Project; import com.nect.core.entity.team.enums.RecruitmentStatus; +import com.nect.core.entity.user.enums.InterestField; +import com.nect.core.entity.user.enums.RoleField; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -55,4 +57,33 @@ AND NOT EXISTS ( """) List findHomeProjectsWithoutUser(@Param("status") RecruitmentStatus status); + @Query(""" + SELECT DISTINCT p + FROM Project p + JOIN FETCH ProjectInterest pi ON pi.project = p + JOIN FETCH Recruitment r ON r.project = p + JOIN FETCH ProjectUser pu ON pu.project = p + WHERE p.recruitmentStatus = :status + AND pi.interestField = :interest + AND pi.selected = true + AND r.capacity > 0 + AND r.field IN :roleFields + AND ( + :userId IS NULL OR NOT EXISTS ( + SELECT 1 + FROM ProjectUser pu + WHERE pu.project = p + AND pu.userId = :userId + ) + ) + ORDER BY p.createdAt DESC + """) + List findHomeProjectsByRoleAndInterest( + @Param("userId") Long userId, + @Param("status") RecruitmentStatus status, + @Param("interest") InterestField interest, + @Param("roleFields") List roleFields, + Pageable pageable + ); + } diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java index 31f2ae2d..f0e832e5 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserInterestRepository.java @@ -15,14 +15,6 @@ public interface UserInterestRepository extends JpaRepository findByUserUserId(Long userId); void deleteByUserUserId(Long userId); - @Query(""" - SELECT ui.user - FROM UserInterest ui - JOIN ui.user u - WHERE ui.interestField = :interest - """) - List findUsersByInterest(@Param("interest") InterestField interest, Pageable pageable); - @Query(""" SELECT ui.user FROM UserInterest ui @@ -30,6 +22,7 @@ public interface UserInterestRepository extends JpaRepository :userId) + AND u.isPublicMatching = true """) List findUsersByInterestAndRoleExcludingUser( @Param("interest") InterestField interest, diff --git a/nect-core/src/main/java/com/nect/core/repository/user/UserRepository.java b/nect-core/src/main/java/com/nect/core/repository/user/UserRepository.java index dadfd581..66184e92 100644 --- a/nect-core/src/main/java/com/nect/core/repository/user/UserRepository.java +++ b/nect-core/src/main/java/com/nect/core/repository/user/UserRepository.java @@ -23,7 +23,12 @@ public interface UserRepository extends JpaRepository { boolean existsByNickname(String nickname); - List findByUserIdNot(Long userId, Pageable pageable); + @Query(""" + SELECT u FROM User u + WHERE u.userId != :userId + AND u.isPublicMatching = true + """) + List findByUserIdNot(@Param("userId") Long userId, Pageable pageable); List findByUserIdIn(List userIds);