Skip to content

Commit c9552be

Browse files
committed
[feat] 일반 구성원 목록 조회 구현 (#579)
* [feat] 일반 구성원 목록 조회 응답 구조 추가 * [feat] 일반 구성원 목록 조회 QueryDSL 추가 * [feat] 일반 구성원 목록 조회 API 추가 * [test] 일반 구성원 목록 조회 Controller, Service, Repository 테스트 추가 * [fix] 이메일 길이로 실패하던 Question 통합테스트 수정 * [fix] Controller 테스트 실패 케이스에서 cursor 검증 수정, unique email 생성 로직 수정 * [fix] redis 컨테이너 종료로 인한 테스트 실패 조치 * [feat] 활동 상태 필터 추가 * [comment] 주석 통합 및 정리
1 parent ff59fa9 commit c9552be

29 files changed

Lines changed: 1718 additions & 178 deletions

src/main/java/org/ject/support/admin/member/controller/AdminMemberSemesterApiSpec.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package org.ject.support.admin.member.controller;
22

33
import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest;
4+
import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition;
5+
import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse;
6+
import org.ject.support.common.response.CursorPageResponse;
7+
import org.springframework.web.bind.annotation.ModelAttribute;
48
import org.springframework.web.bind.annotation.RequestBody;
59

610
import io.swagger.v3.oas.annotations.Operation;
@@ -14,4 +18,12 @@ public interface AdminMemberSemesterApiSpec {
1418
description = "일반 구성원을 추가합니다."
1519
)
1620
void createAdminMemberSemester(@RequestBody @Valid CreateMemberSemesterRequest request);
21+
22+
@Operation(
23+
summary = "일반 구성원 리스트 조회",
24+
description = "일반 구성원 목록을 커서기반 페이징과 필터를 적용해 조회합니다."
25+
)
26+
CursorPageResponse<SearchMemberSemesterResponse> searchAdminMemberSemesterList(
27+
@ModelAttribute @Valid MemberSemesterSearchCondition request
28+
);
1729
}
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package org.ject.support.admin.member.controller;
22

33
import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest;
4+
import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition;
5+
import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse;
46
import org.ject.support.admin.member.service.AdminMemberUseCase;
7+
import org.ject.support.common.response.CursorPageResponse;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.ModelAttribute;
510
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestBody;
612
import org.springframework.web.bind.annotation.RequestMapping;
713
import org.springframework.web.bind.annotation.RestController;
814

15+
import jakarta.validation.Valid;
916
import lombok.RequiredArgsConstructor;
1017

1118
@RestController
@@ -15,10 +22,19 @@ public class AdminMemberSemesterController implements AdminMemberSemesterApiSpec
1522

1623
private final AdminMemberUseCase adminMemberUsecase;
1724

18-
//일반 구성원 추가
25+
// 일반 구성원 추가
1926
@Override
2027
@PostMapping
21-
public void createAdminMemberSemester(CreateMemberSemesterRequest request) {
28+
public void createAdminMemberSemester(
29+
@RequestBody @Valid CreateMemberSemesterRequest request) {
2230
adminMemberUsecase.createMemberSemester(request);
2331
}
32+
33+
// 일반 구성원 목록 조회
34+
@Override
35+
@GetMapping
36+
public CursorPageResponse<SearchMemberSemesterResponse> searchAdminMemberSemesterList(
37+
@ModelAttribute @Valid MemberSemesterSearchCondition request) {
38+
return adminMemberUsecase.searchMemberSemester(request);
39+
}
2440
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.ject.support.admin.member.dto.projection;
2+
3+
import org.ject.support.domain.member.ActivityStatus;
4+
import org.ject.support.domain.member.CareerDetails;
5+
import org.ject.support.domain.member.ExperiencePeriod;
6+
import org.ject.support.domain.member.JobFamily;
7+
8+
public record SearchMemberSemesterProjection(
9+
Long memberActivityId,
10+
String name,
11+
JobFamily jobFamily,
12+
String phoneNumber,
13+
CareerDetails careerDetails,
14+
ExperiencePeriod experiencePeriod,
15+
ActivityStatus status
16+
) {
17+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.ject.support.admin.member.dto.request;
2+
3+
import java.util.List;
4+
5+
import org.ject.support.domain.member.ActivityStatus;
6+
import org.ject.support.domain.member.CareerDetails;
7+
import org.ject.support.domain.member.JobFamily;
8+
import org.ject.support.domain.recruit.domain.RecruitTypeDetail;
9+
10+
import jakarta.validation.constraints.Max;
11+
import jakarta.validation.constraints.Min;
12+
import jakarta.validation.constraints.Positive;
13+
14+
public record MemberSemesterSearchCondition(
15+
16+
//-- 페이징 --
17+
18+
//MemberActivityId 기준 내림차순
19+
@Positive
20+
Long cursor,
21+
22+
// 정책 변경에 대비해 여유롭게 설정
23+
@Min(1)
24+
@Max(100)
25+
Integer size,
26+
27+
//-- 필터 --
28+
29+
// 기수
30+
@Positive
31+
Long semesterId,
32+
// 직군 (다중 선택)
33+
List<JobFamily> jobFamilies,
34+
// 모집 단위 (다중 선택)
35+
List<RecruitTypeDetail> recruitTypeDetails,
36+
// 신분 (다중 선택)
37+
List<CareerDetails> careerDetails,
38+
// 팀 (다중 선택)
39+
List<@Positive Long> teamIds,
40+
// 활동 상태 (다중 선택)
41+
List<ActivityStatus> statuses
42+
) {
43+
//size가 없으면 기본 30개
44+
public int getSizeOrDefault() {
45+
return size == null ? 30 : size;
46+
}
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.ject.support.admin.member.dto.response;
2+
3+
import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection;
4+
import org.ject.support.domain.member.ActivityStatus;
5+
import org.ject.support.domain.member.CareerDetails;
6+
import org.ject.support.domain.member.ExperiencePeriod;
7+
import org.ject.support.domain.member.JobFamily;
8+
9+
public record SearchMemberSemesterResponse(
10+
Long memberActivityId,
11+
String name,
12+
JobFamily jobFamily,
13+
String phoneNumber,
14+
CareerDetails careerDetails,
15+
ExperiencePeriod experiencePeriod,
16+
ActivityStatus status
17+
) {
18+
public static SearchMemberSemesterResponse from(SearchMemberSemesterProjection projection) {
19+
return new SearchMemberSemesterResponse(
20+
projection.memberActivityId(),
21+
projection.name(),
22+
projection.jobFamily(),
23+
projection.phoneNumber(),
24+
projection.careerDetails(),
25+
projection.experiencePeriod(),
26+
projection.status()
27+
);
28+
}
29+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.ject.support.admin.member.dto.result;
2+
3+
import java.util.List;
4+
5+
import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection;
6+
7+
public record SearchMemberSemesterPageResult(
8+
List<SearchMemberSemesterProjection> content,
9+
long totalCount
10+
) {
11+
}

src/main/java/org/ject/support/admin/member/service/AdminMemberActivityService.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
package org.ject.support.admin.member.service;
22

3+
import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection;
34
import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest;
5+
import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition;
6+
import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult;
47
import org.ject.support.domain.member.MemberType;
58
import org.ject.support.domain.member.entity.MemberActivity;
69
import org.ject.support.domain.member.exception.MemberException;
710
import org.ject.support.domain.member.repository.MemberActivityRepository;
811
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
913

1014
import lombok.RequiredArgsConstructor;
1115

1216
import static org.ject.support.domain.member.exception.MemberErrorCode.ALREADY_EXIST_MEMBER_SEMESTER_ACTIVITY;
1317

18+
import java.util.List;
19+
1420
@Service
1521
@RequiredArgsConstructor
1622
public class AdminMemberActivityService {
@@ -37,6 +43,20 @@ public void createMemberSemesterActivity(CreateMemberSemesterRequest request, Lo
3743
memberActivityRepository.save(memberActivity);
3844
}
3945

46+
// 동적 필터로 일반 구성원 목록 조회
47+
public SearchMemberSemesterPageResult searchMemberSemesterList(MemberSemesterSearchCondition condition) {
48+
// size+1로 조회 (다음 페이지 유무 확인)
49+
List<SearchMemberSemesterProjection> projections =
50+
memberActivityRepository.searchMemberSemesters(condition, condition.getSizeOrDefault()+1);
51+
long totalCount = memberActivityRepository.countMemberSemesters(condition);
52+
return new SearchMemberSemesterPageResult(projections, totalCount);
53+
}
54+
55+
56+
/*
57+
유틸, 검증 함수
58+
*/
59+
4060
// 동일 기수 일반 구성원 활동 중복 검증
4161
private void validateDuplicateSemesterActivity(Long memberId, Long semesterId) {
4262
if (memberActivityRepository.existsSemesterActivity(memberId, MemberType.SEMESTER, semesterId)) {

src/main/java/org/ject/support/admin/member/service/AdminMemberService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public class AdminMemberService {
1717
(2026.06.23)
1818
1. email로 삭제 포함 구성원 신상 전부 조회
1919
2. 삭제된 구성원 존재시: isDeleted를 복구하고 새로 입력받은 값으로 덮어쓰기
20-
3. 활성 구성원 존재시: 기존 값 재사용
20+
3. 활성 구성원 존재시: 기존 값 재사용 Todo: 대체 가능한 값은 새로운 입력으로 대체
2121
4. 미 존재시: 새로 생성
2222
*/
2323

src/main/java/org/ject/support/admin/member/service/AdminMemberUseCase.java

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
package org.ject.support.admin.member.service;
22

3-
import static org.ject.support.domain.member.exception.MemberErrorCode.NOT_FOUND_TEAM_OF_SEMESTER;
3+
import static org.ject.support.domain.member.exception.MemberErrorCode.*;
44

5+
import java.util.HashSet;
6+
import java.util.List;
7+
import java.util.Set;
8+
9+
import org.ject.support.admin.member.dto.projection.SearchMemberSemesterProjection;
510
import org.ject.support.admin.member.dto.request.CreateMemberSemesterRequest;
11+
import org.ject.support.admin.member.dto.request.MemberSemesterSearchCondition;
12+
import org.ject.support.admin.member.dto.response.SearchMemberSemesterResponse;
13+
import org.ject.support.admin.member.dto.result.SearchMemberSemesterPageResult;
14+
import org.ject.support.common.response.CursorPageResponse;
15+
import org.ject.support.domain.member.ActivityStatus;
16+
import org.ject.support.domain.member.MemberType;
617
import org.ject.support.domain.member.exception.MemberException;
718
import org.ject.support.domain.recruit.service.SemesterInquiryUsecase;
819
import org.springframework.stereotype.Service;
@@ -22,7 +33,7 @@ public class AdminMemberUseCase {
2233
// 일반 구성원 생성 흐름 처리
2334
@Transactional
2435
public void createMemberSemester(CreateMemberSemesterRequest request) {
25-
// 기수 유효성 검증
36+
// 유효성 검증
2637
semesterInquiryUsecase.getSemester(request.semesterId());
2738
validateTeam(request.semesterId(), request.teamId());
2839

@@ -33,6 +44,56 @@ public void createMemberSemester(CreateMemberSemesterRequest request) {
3344
adminMemberActivityService.createMemberSemesterActivity(request, memberId);
3445
}
3546

47+
48+
// 일반 구성원 목록 조회 (커서 기반 페이징)
49+
@Transactional(readOnly=true)
50+
public CursorPageResponse<SearchMemberSemesterResponse> searchMemberSemester(
51+
MemberSemesterSearchCondition condition
52+
) {
53+
// 유효성 검증
54+
validateSemester(condition.semesterId());
55+
validateTeams(condition.semesterId(), condition.teamIds());
56+
validateStatuses(condition.statuses(), MemberType.SEMESTER);
57+
// size만큼 조회 + 전체 행 수 조회
58+
SearchMemberSemesterPageResult pageResult = adminMemberActivityService.searchMemberSemesterList(condition);
59+
// 페이징 값 처리
60+
boolean hasNext = pageResult.content().size() > condition.getSizeOrDefault();
61+
long totalCount = pageResult.totalCount();
62+
List<SearchMemberSemesterProjection> content = hasNext
63+
? pageResult.content().subList(0,condition.getSizeOrDefault())
64+
: pageResult.content();
65+
66+
List<SearchMemberSemesterResponse> responses = content.stream()
67+
.map(SearchMemberSemesterResponse::from)
68+
.toList();
69+
70+
Long nextCursor = hasNext && !content.isEmpty()
71+
? content.get(content.size()-1).memberActivityId()
72+
: null;
73+
74+
// 응답
75+
return CursorPageResponse.of(
76+
responses,
77+
condition.getSizeOrDefault(),
78+
hasNext,
79+
nextCursor,
80+
totalCount
81+
);
82+
}
83+
84+
/*
85+
유틸 함수
86+
*/
87+
88+
// 존재하는 기수인지 검증
89+
private void validateSemester(Long semesterId) {
90+
if(semesterId == null){
91+
return;
92+
}
93+
// 존재하지 않으면 semester 도메인에서 예외 처리
94+
semesterInquiryUsecase.getSemester(semesterId);
95+
}
96+
3697
// 선택한 팀의 기수 소속 검증
3798
private void validateTeam(Long semesterId, Long teamId) {
3899
if (teamId == null) {
@@ -43,4 +104,32 @@ private void validateTeam(Long semesterId, Long teamId) {
43104
throw new MemberException(NOT_FOUND_TEAM_OF_SEMESTER);
44105
}
45106
}
107+
108+
// 넘겨받은 팀 목록 전체의 기수 소속 및 유효성 검증
109+
private void validateTeams(Long semesterId, List<Long> teamIds) {
110+
if(teamIds == null || teamIds.isEmpty()){
111+
return;
112+
}
113+
114+
// 팀이 있는데 기수가 없으면 잘못된 요청
115+
if (semesterId == null) {
116+
throw new MemberException(REQUIRED_SEMESTER_FOR_TEAM_FILTER);
117+
}
118+
119+
Set<Long> validTeamIds = new HashSet<>(
120+
adminMemberTeamService.getTeamIdsBySemesterId(semesterId)
121+
);
122+
123+
if(!validTeamIds.containsAll(teamIds)) {
124+
throw new MemberException(NOT_FOUND_TEAM_OF_SEMESTER);
125+
}
126+
}
127+
128+
// 구성원 유형에서 허용되는 활동 상태인지 검증
129+
private void validateStatuses(List<ActivityStatus> statuses, MemberType type) {
130+
if (!ActivityStatus.isAllAvailableFor(statuses, type)) {
131+
throw new MemberException(INVALID_ACTIVITY_STATUS);
132+
}
133+
}
134+
46135
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.ject.support.common.response;
2+
3+
import java.util.List;
4+
5+
public record CursorPageResponse<T>(
6+
List<T> content,
7+
int size,
8+
boolean hasNext,
9+
Long nextCursor,
10+
long totalCount
11+
) {
12+
public static <T> CursorPageResponse<T> of(
13+
List<T> content,
14+
int size,
15+
boolean hasNext,
16+
Long nextCursor,
17+
long totalCount
18+
) {
19+
return new CursorPageResponse<>(content, size, hasNext, nextCursor, totalCount);
20+
}
21+
}

0 commit comments

Comments
 (0)