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
104 changes: 104 additions & 0 deletions src/main/java/com/dokdok/gathering/api/GatheringApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dokdok.gathering.dto.request.GatheringUpdateRequest;
import com.dokdok.gathering.dto.request.JoinGatheringMemberRequest;
import com.dokdok.gathering.dto.response.*;
import com.dokdok.gathering.entity.GatheringMemberStatus;
import com.dokdok.global.response.ApiResponse;
import com.dokdok.global.response.CursorResponse;
import com.dokdok.global.response.PageResponse;
Expand Down Expand Up @@ -976,4 +977,107 @@ ResponseEntity<ApiResponse<PageResponse<GatheringBookListResponse>>> getGatherin
@Parameter(description = "페이지 크기", example = "10")
@RequestParam(defaultValue = "10") int size
);

@Operation(
summary = "모임 멤버 목록 조회 (developer: 오주현)",
description = """
모임의 멤버 목록을 상태별로 조회합니다.
- 모임장만 조회할 수 있습니다.
- 커서 기반 무한 스크롤을 지원합니다.
- ID 내림차순으로 정렬됩니다.
- 첫 페이지: cursorId 없이 호출
- 다음 페이지: 응답의 nextCursor.gatheringMemberId 값을 cursorId로 전달
ENUM
- status: PENDING(가입요청), ACTIVE(가입승인)
- role: LEADER(모임장), MEMBER(모임원)
- memberStatus: PENDING(가입요청), ACTIVE(가입승인), REJECTED(가입거절)
"""
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = CursorResponse.class),
examples = @ExampleObject(value = """
{
"code": "SUCCESS",
"message": "모임 멤버 목록 조회 성공",
"data": {
"items": [
{
"gatheringMemberId": 10,
"userId": 1,
"nickname": "독서왕",
"profileImageUrl": "https://example.com/profile.jpg",
"role": "MEMBER",
"memberStatus": "PENDING",
"joinedAt": null
}
],
"pageSize": 10,
"hasNext": false,
"nextCursor": null,
"totalCount": 1
}
}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "인증 실패 - 로그인이 필요합니다.",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "G102", "message": "인증이 필요합니다.", "data": null}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "권한 없음 - 모임장만 조회할 수 있습니다.",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "GA003", "message": "리더만 가능한 작업입니다.", "data": null}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "모임을 찾을 수 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "GA001", "message": "모임을 찾을 수 없습니다.", "data": null}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null}
""")
)
)
})
@GetMapping("/{gatheringId}/members")
ResponseEntity<ApiResponse<CursorResponse<GatheringMemberResponse, GatheringMemberCursor>>> getGatheringMembers(
@Parameter(description = "모임 ID", required = true, example = "1")
@PathVariable Long gatheringId,

@Parameter(description = "멤버 상태 (PENDING: 승인대기, ACTIVE: 승인됨)", required = true, example = "PENDING")
@RequestParam GatheringMemberStatus status,

@Parameter(description = "페이지 크기", example = "10")
@RequestParam(defaultValue = "10") int pageSize,

@Parameter(description = "커서 - 마지막 항목의 모임 멤버 ID", example = "127")
@RequestParam(required = false) Long cursorId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dokdok.gathering.dto.request.JoinGatheringMemberRequest;
import com.dokdok.gathering.dto.response.*;
import com.dokdok.gathering.dto.request.GatheringUpdateRequest;
import com.dokdok.gathering.entity.GatheringMemberStatus;
import com.dokdok.gathering.service.GatheringService;
import com.dokdok.global.response.ApiResponse;
import com.dokdok.global.response.CursorResponse;
Expand All @@ -18,7 +19,6 @@
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.List;

@RestController
@RequestMapping("/api/gatherings")
Expand Down Expand Up @@ -136,4 +136,16 @@ public ResponseEntity<ApiResponse<PageResponse<GatheringBookListResponse>>> getG
PageResponse<GatheringBookListResponse> response = gatheringService.getGatheringBooks(gatheringId, page, size);
return ApiResponse.success(response, "모임 책장 조회를 성공했습니다.");
}

@Override
@GetMapping("/{gatheringId}/members")
public ResponseEntity<ApiResponse<CursorResponse<GatheringMemberResponse, GatheringMemberCursor>>> getGatheringMembers(
@PathVariable Long gatheringId,
@RequestParam GatheringMemberStatus status,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long cursorId
){
CursorResponse<GatheringMemberResponse, GatheringMemberCursor> response = gatheringService.getGatheringMembers(gatheringId, status, pageSize, cursorId);
return ApiResponse.success(response,"모임 멤버 관리 조회 성공");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.dokdok.gathering.dto.response;

import com.dokdok.gathering.entity.GatheringMember;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "모임 멤버 관리 커서")
public record GatheringMemberCursor(
@Schema(description = "모임 멤버 ID", example = "10")
Long gatheringMemberId
) {
public static GatheringMemberCursor from(GatheringMember member){
return new GatheringMemberCursor(member.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.dokdok.gathering.dto.response;

import com.dokdok.gathering.entity.GatheringMember;
import com.dokdok.gathering.entity.GatheringMemberStatus;
import com.dokdok.gathering.entity.GatheringRole;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.time.LocalDateTime;

@Schema(description = "모임 멤버 관리 정보")
@Builder
public record GatheringMemberResponse(
@Schema(description = "모임 멤버 ID", example = "10")
Long gatheringMemberId,

@Schema(description = "사용자 ID", example = "1")
Long userId,

@Schema(description = "닉네임", example = "독서왕")
String nickname,

@Schema(description = "프로필 이미지 URL")
String profileImageUrl,

@Schema(description = "모임 역할", example = "MEMBER")
GatheringRole role,

@Schema(description = "멤버 상태", example = "PENDING")
GatheringMemberStatus memberStatus,

@Schema(description = "가입 승인 일시")
LocalDateTime joinedAt
) {
public static GatheringMemberResponse from(GatheringMember member, String presignedProfileImageUrl) {
return GatheringMemberResponse.builder()
.gatheringMemberId(member.getId())
.userId(member.getUser().getId())
.nickname(member.getUser().getNickname())
.profileImageUrl(presignedProfileImageUrl)
.role(member.getRole())
.memberStatus(member.getMemberStatus())
.joinedAt(member.getJoinedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.dokdok.gathering.repository;

import com.dokdok.gathering.entity.GatheringMember;
import com.dokdok.gathering.entity.GatheringMemberStatus;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -123,4 +124,48 @@ List<GatheringMember> findMyGatheringsAfterCursor(
"AND g.gatheringStatus = 'ACTIVE' " +
"AND gm.removedAt IS NULL")
int countMyGatherings(@Param("userId") Long userId);

/**
* 모임 멤버 상태별 조회 (첫 페이지)
*/
@Query("SELECT gm FROM GatheringMember gm " +
"JOIN FETCH gm.user u " +
"WHERE gm.gathering.id = :gatheringId " +
"AND gm.memberStatus = :status " +
"AND gm.removedAt IS NULL " +
"ORDER BY gm.id DESC")
List<GatheringMember> findMembersByStatusFirstPage(
@Param("gatheringId") Long gatheringId,
@Param("status") GatheringMemberStatus status,
Pageable pageable
);

/**
* 모임 멤버 상태별 조회 (다음 페이지)
*/
@Query("SELECT gm FROM GatheringMember gm " +
"JOIN FETCH gm.user u " +
"WHERE gm.gathering.id = :gatheringId " +
"AND gm.memberStatus = :status " +
"AND gm.removedAt IS NULL " +
"AND gm.id < :cursorId " +
"ORDER BY gm.id DESC")
List<GatheringMember> findMembersByStatusAfterCursor(
@Param("gatheringId") Long gatheringId,
@Param("status") GatheringMemberStatus status,
@Param("cursorId") Long cursorId,
Pageable pageable
);

/**
* 모임 멤버 상태별 총 개수 조회
*/
@Query("SELECT count(gm) FROM GatheringMember gm " +
"WHERE gm.gathering.id = :gatheringId " +
"AND gm.memberStatus = :status " +
"AND gm.removedAt IS NULL")
int countMembersByStatus(
@Param("gatheringId") Long gatheringId,
@Param("status") GatheringMemberStatus status
);
}
53 changes: 49 additions & 4 deletions src/main/java/com/dokdok/gathering/service/GatheringService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.dokdok.gathering.service;

import com.dokdok.book.entity.Book;
import com.dokdok.book.repository.BookReviewRepository;
import com.dokdok.gathering.dto.request.GatheringCreateRequest;
import com.dokdok.gathering.dto.request.GatheringUpdateRequest;
Expand All @@ -15,7 +14,6 @@
import com.dokdok.global.response.PageResponse;
import com.dokdok.global.util.SecurityUtil;
import com.dokdok.gathering.util.InvitationCodeGenerator;
import com.dokdok.meeting.entity.MeetingMember;
import com.dokdok.meeting.entity.MeetingStatus;
import com.dokdok.meeting.repository.MeetingMemberRepository;
import com.dokdok.meeting.repository.MeetingRepository;
Expand All @@ -28,12 +26,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
Expand Down Expand Up @@ -347,6 +343,55 @@ public void updateFavorite(Long gatheringId) {
member.updateFavorite();
}

/**
* 모임 멤버 관리 목록을 상태별로 조회합니다. (커시 기반 무한 스크롤)
*/
public CursorResponse<GatheringMemberResponse, GatheringMemberCursor> getGatheringMembers(
Long gatheringId,
GatheringMemberStatus status,
int pageSize,
Long cursorId
) {
Long userId = SecurityUtil.getCurrentUserId();

// 모임 존재 여부 및 리더 권한 검증
gatheringValidator.validateAndGetGathering(gatheringId);
gatheringValidator.validateLeader(gatheringId, userId);

Pageable pageable = PageRequest.of(0, pageSize +1);

List<GatheringMember> members;
Integer totalCount = null;

if (cursorId == null) {
// 첫 페이지
members = gatheringMemberRepository.findMembersByStatusFirstPage(gatheringId, status, pageable);
totalCount = gatheringMemberRepository.countMembersByStatus(gatheringId, status);
} else {
// 다음 페이지
members = gatheringMemberRepository.findMembersByStatusAfterCursor(gatheringId, status, cursorId, pageable);
}

boolean hasNext = members.size() > pageSize;
List<GatheringMember> pageMembers = hasNext ? members.subList(0, pageSize) : members;

List<GatheringMemberResponse> items = pageMembers.stream()
.map(member -> {
String presignedUrl = storageService.getPresignedProfileImage(
member.getUser().getProfileImageUrl()
);
return GatheringMemberResponse.from(member, presignedUrl);
})
.toList();

GatheringMember lastMember = pageMembers.isEmpty() ? null : pageMembers.get(pageMembers.size() - 1);
GatheringMemberCursor nextCursor = hasNext && lastMember != null
? GatheringMemberCursor.from(lastMember)
: null;

return CursorResponse.of(items, pageSize, hasNext, nextCursor, totalCount);
}

/**
* 공통 메서드
* 모임 멤버를 추가합니다.
Expand Down
Loading