From 4cb72ea422391a0bc3240609c62f9d139a0a3267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Fri, 6 Feb 2026 18:43:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?:sparkles:Feat:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EB=8F=99=ED=98=B8=ED=9A=8C=20=EC=A1=B0=ED=9A=8C=20API=20+=20?= =?UTF-8?q?=EB=8F=99=ED=98=B8=ED=9A=8C=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubController.java | 22 +++ .../club/dto/response/ClubDetailResponse.java | 46 +++++ .../dto/response/ClubListItemResponse.java | 42 ++++ .../club/dto/response/ClubScrollResponse.java | 18 ++ .../club/repository/ClubMemberRepository.java | 1 + .../club/repository/ClubRepository.java | 18 ++ .../domain/club/service/ClubService.java | 12 +- .../domain/club/service/ClubServiceImpl.java | 183 +++++++++++------- .../global/config/RedisCacheConfig.java | 1 + 9 files changed, 270 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index a7776b5..767e490 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -2,8 +2,10 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse; import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; +import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse; import com.be.sportizebe.domain.club.service.ClubServiceImpl; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.response.BaseResponse; @@ -37,7 +39,27 @@ public ResponseEntity> createClub( return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.success("동호회 생성 성공", response)); } + @GetMapping + @Operation(summary = "모든 동호회 조회 (무한스크롤)", + description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.") + public ResponseEntity> getClubs( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ClubScrollResponse response = clubService.getClubsByScroll(cursor, size); + return ResponseEntity.ok( + BaseResponse.success("동호회 목록 조회 성공", response) + ); + } + @GetMapping("/{clubId}") + @Operation(summary = "동호회 상세 조회", description = "동호회 단건 상세 정보를 조회합니다.") + public ResponseEntity> getClub( + @Parameter(description = "동호회 ID") @PathVariable Long clubId + ) { + ClubDetailResponse response = clubService.getClub(clubId); + return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response)); + } @PutMapping("/{clubId}") @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") public ResponseEntity> updateClub( diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java new file mode 100644 index 0000000..0d70862 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java @@ -0,0 +1,46 @@ +package com.be.sportizebe.domain.club.dto.response; + +import com.be.sportizebe.domain.club.entity.Club; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(title = "ClubDetailResponse", description = "동호회 상세 조회 응답") +public record ClubDetailResponse( + + @Schema(description = "동호회 ID", example = "1") + Long clubId, + + @Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회") + String name, + + @Schema(description = "동호회 소개글 (전체)", example = "매주 화, 토 저녁에 풋살을 즐기는 동호회입니다.") + String introduce, + + @Schema(description = "동호회 종목", example = "SOCCER") + String clubType, + + @Schema(description = "최대 정원", example = "20") + int maxMembers, + + @Schema(description = "현재 동호회 인원 수", example = "12") + int currentMembers, + + @Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg") + String clubImageUrl, + + @Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00") + LocalDateTime createdAt +) { + public static ClubDetailResponse from(Club club, int memberCount) { + return new ClubDetailResponse( + club.getId(), + club.getName(), + club.getIntroduce(), + club.getClubType().name(), + club.getMaxMembers(), + memberCount, + club.getClubImage(), + club.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java new file mode 100644 index 0000000..b57430b --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java @@ -0,0 +1,42 @@ +package com.be.sportizebe.domain.club.dto.response; + +import com.be.sportizebe.domain.club.entity.Club; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(title = "ClubListItemResponse", description = "동호회 목록(무한스크롤) 카드 단위 응답") +public record ClubListItemResponse( + + @Schema(description = "동호회 ID", example = "1") + Long clubId, + + @Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회") + String name, + + @Schema(description = "동호회 종목", example = "SOCCER") + String sportType, + + @Schema(description = "동호회 소개글 (목록용 요약)", example = "주 2회 풋살을 즐기는 동호회입니다.") + String description, + + @Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg") + String imageUrl, + + @Schema(description = "현재 동호회 인원 수", example = "12") + int memberCount, + + @Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00") + LocalDateTime createdAt +) { + public static ClubListItemResponse from(Club club, int memberCount) { + return new ClubListItemResponse( + club.getId(), + club.getName(), + club.getClubType().name(), + club.getIntroduce(), + club.getClubImage(), + memberCount, + club.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java new file mode 100644 index 0000000..c11f235 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java @@ -0,0 +1,18 @@ +package com.be.sportizebe.domain.club.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(title = "ClubScrollResponse", description = "동호회 목록 무한스크롤 조회 응답") +public record ClubScrollResponse( + + @Schema(description = "동호회 목록") + List items, + + @Schema(description = "다음 조회를 위한 커서 값 (마지막 clubId)", example = "15") + Long nextCursor, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java index c904683..33387d9 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ClubMemberRepository extends JpaRepository { + int countByClubId(Long clubId); } diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java index f5f5110..0e97815 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java @@ -3,6 +3,24 @@ import com.be.sportizebe.domain.club.entity.Club; import org.springframework.data.jpa.repository.JpaRepository; +import com.be.sportizebe.domain.club.entity.Club; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + public interface ClubRepository extends JpaRepository { boolean existsByName(String name); + + @Query(""" + SELECT c FROM Club c + WHERE (:cursor IS NULL OR c.id < :cursor) + ORDER BY c.id DESC + """) + List findClubsByCursor( + @Param("cursor") Long cursor, + Pageable pageable + ); } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java index 4ac4c0e..a5ea68d 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java @@ -2,15 +2,21 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse; import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; +import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse; import com.be.sportizebe.domain.user.entity.User; import org.springframework.web.multipart.MultipartFile; public interface ClubService { - ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user); // 동호회 생성 + ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user); // 동호회 생성 - ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user); // 동호회 수정 + ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user); // 동호회 수정 - ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user); // 동호회 사진 수정 + ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user); // 동호회 사진 수정 + + ClubDetailResponse getClub(Long clubId); // 동호회 개별 조회 + + ClubScrollResponse getClubsByScroll(Long cursor, int size); // 동호회 전체 조회 (무한 스크롤) } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java index 49de51c..ba32e05 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java @@ -3,8 +3,7 @@ import com.be.sportizebe.domain.chat.service.ChatRoomService; import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; -import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; -import com.be.sportizebe.domain.club.dto.response.ClubResponse; +import com.be.sportizebe.domain.club.dto.response.*; import com.be.sportizebe.domain.club.entity.Club; import com.be.sportizebe.domain.club.entity.ClubMember; import com.be.sportizebe.domain.club.exception.ClubErrorCode; @@ -15,97 +14,141 @@ import com.be.sportizebe.global.s3.enums.PathName; import com.be.sportizebe.global.s3.service.S3Service; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ClubServiceImpl implements ClubService { - private final ClubRepository clubRepository; - private final ChatRoomService chatRoomService; - private final ClubMemberRepository clubMemberRepository; - private final S3Service s3Service; - - @Override - @Transactional - public ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user) { - if (clubRepository.existsByName(request.name())) { - throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED); + private final ClubRepository clubRepository; + private final ChatRoomService chatRoomService; + private final ClubMemberRepository clubMemberRepository; + private final S3Service s3Service; + + @Override + @Transactional + public ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user) { + if (clubRepository.existsByName(request.name())) { + throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED); + } + + // 이미지가 있으면 S3에 업로드 + String clubImageUrl = null; + if (image != null && !image.isEmpty()) { + clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); + } + + // 동호회 엔티티 생성 + Club club = request.toEntity(user, clubImageUrl); + clubRepository.save(club); + + // 동호회 멤버 테이블에 방장(동호회 생성자) 추가 + ClubMember leaderMember = ClubMember.builder() + .club(club) + .user(user) + .role(ClubMember.ClubRole.LEADER) + .build(); + clubMemberRepository.save(leaderMember); + + // 동호회 단체 채팅방 생성 + chatRoomService.createGroup(club); + + return ClubResponse.from(club); } - // 이미지가 있으면 S3에 업로드 - String clubImageUrl = null; - if (image != null && !image.isEmpty()) { - clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); - } + @Override + @Transactional + public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); - // 동호회 엔티티 생성 - Club club = request.toEntity(user, clubImageUrl); - clubRepository.save(club); - - // 동호회 멤버 테이블에 방장(동호회 생성자) 추가 - ClubMember leaderMember = ClubMember.builder() - .club(club) - .user(user) - .role(ClubMember.ClubRole.LEADER) - .build(); - clubMemberRepository.save(leaderMember); - - // 동호회 단체 채팅방 생성 - chatRoomService.createGroup(club); - - return ClubResponse.from(club); - } - - @Override - @Transactional - public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user) { - Club club = clubRepository.findById(clubId) - .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); - - // 동호회 방장만 수정 가능하도록 검증 - if (club.getLeader().getId() != user.getId()) { - throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); - } + // 동호회 방장만 수정 가능하도록 검증 + if (club.getLeader().getId() != user.getId()) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } - if (!club.getName().equals(request.name()) && clubRepository.existsByName(request.name())) { - throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED); - } + if (!club.getName().equals(request.name()) && clubRepository.existsByName(request.name())) { + throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED); + } - if (request.maxMembers() != null && request.maxMembers() < club.getMembers().size()) { - throw new CustomException(ClubErrorCode.CLUB_MAX_MEMBERS_TOO_SMALL); + if (request.maxMembers() != null && request.maxMembers() < club.getMembers().size()) { + throw new CustomException(ClubErrorCode.CLUB_MAX_MEMBERS_TOO_SMALL); + } + + club.update(request.name(), request.introduce(), request.maxMembers(), request.clubType()); + + return ClubResponse.from(club); } - club.update(request.name(), request.introduce(), request.maxMembers(), request.clubType()); + @Override + @Transactional + public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + + // 동호회 방장만 수정 가능하도록 검증 + if (club.getLeader().getId() != user.getId()) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + // 기존 이미지가 있으면 S3에서 삭제 + if (club.getClubImage() != null) { + s3Service.deleteFile(club.getClubImage()); + } - return ClubResponse.from(club); - } + // 새 이미지 S3에 업로드 + String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); - @Override - @Transactional - public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user) { - Club club = clubRepository.findById(clubId) - .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + // 동호회 이미지 URL 업데이트 + club.updateClubImage(clubImageUrl); - // 동호회 방장만 수정 가능하도록 검증 - if (club.getLeader().getId() != user.getId()) { - throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + return ClubImageResponse.from(clubImageUrl); } - // 기존 이미지가 있으면 S3에서 삭제 - if (club.getClubImage() != null) { - s3Service.deleteFile(club.getClubImage()); + @Override + @Transactional(readOnly = true) + public ClubScrollResponse getClubsByScroll(Long cursor, int size) { + + // +1 조회해서 다음 페이지 존재 여부 판단 + Pageable pageable = PageRequest.of(0, size + 1); + + List clubs = clubRepository.findClubsByCursor(cursor, pageable); + + boolean hasNext = clubs.size() > size; + + if (hasNext) { + clubs = clubs.subList(0, size); + } + + List items = clubs.stream() + .map(club -> { + int memberCount = clubMemberRepository.countByClubId(club.getId()); + return ClubListItemResponse.from(club, memberCount); + }) + .toList(); + + Long nextCursor = items.isEmpty() + ? null + : items.get(items.size() - 1).clubId(); + + return new ClubScrollResponse(items, nextCursor, hasNext); } - // 새 이미지 S3에 업로드 - String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); + @Override + @Transactional(readOnly = true) + public ClubDetailResponse getClub(Long clubId) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); - // 동호회 이미지 URL 업데이트 - club.updateClubImage(clubImageUrl); + int memberCount = clubMemberRepository.countByClubId(clubId); - return ClubImageResponse.from(clubImageUrl); - } + return ClubDetailResponse.from(club, memberCount); + } } diff --git a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java index 7bc269e..ba84393 100644 --- a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -32,6 +32,7 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ❗ 타입 정보 절대 쓰지 않음 .build(); + // 캐시에 들어가는 값의 직렬화 방식 결정 // 기본 캐시(대부분)는 Object로 직렬화/역직렬화 Jackson2JsonRedisSerializer defaultValueSerializer = From 13c04a1115d7f3b6977d72f4ebd50b3595c5d78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sun, 8 Feb 2026 13:14:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?:sparkles:Feat:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20API?= =?UTF-8?q?=20+=20=EB=8F=99=ED=98=B8=ED=9A=8C=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20(=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubController.java | 69 +++++++++++-------- .../club/repository/ClubRepository.java | 15 ++-- .../domain/club/service/ClubService.java | 3 + .../domain/club/service/ClubServiceImpl.java | 36 ++++++++-- .../post/controller/PostController.java | 14 ++++ .../post/dto/response/CursorPageResponse.java | 20 ++++++ .../post/repository/PostRepository.java | 11 ++- .../domain/post/service/PostService.java | 12 ++-- .../domain/post/service/PostServiceImpl.java | 30 ++++++++ .../global/exception/GlobalErrorCode.java | 3 +- 10 files changed, 168 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index 767e490..6835580 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -27,18 +27,28 @@ @Tag(name = "club", description = "동호회 관련 API") public class ClubController { - private final ClubServiceImpl clubService; + private final ClubServiceImpl clubService; - @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") - public ResponseEntity> createClub( - @RequestPart("request") @Valid ClubCreateRequest request, - @RequestPart(value = "image", required = false) MultipartFile image, - @AuthenticationPrincipal User user) { - ClubResponse response = clubService.createClub(request, image, user); - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.success("동호회 생성 성공", response)); - } + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") + public ResponseEntity> createClub( + @RequestPart("request") @Valid ClubCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal User user) { + ClubResponse response = clubService.createClub(request, image, user); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.success("동호회 생성 성공", response)); + } + @GetMapping("/me") + @Operation(summary = "내가 가입한 동호회 조회(무한스크롤)", description = "로그인한 사용자가 가입한 동호회를 커서 기반 무한스크롤로 조회합니다.") + public ResponseEntity> getMyClubs( + @Parameter(description = "커서(마지막 clubId). 첫 조회는 null") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수", example = "10") @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal User user + ) { + ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user); + return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response)); + } @GetMapping @Operation(summary = "모든 동호회 조회 (무한스크롤)", description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.") @@ -60,23 +70,24 @@ public ResponseEntity> getClub( ClubDetailResponse response = clubService.getClub(clubId); return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response)); } - @PutMapping("/{clubId}") - @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClub( - @PathVariable Long clubId, - @RequestBody @Valid ClubUpdateRequest request, - @AuthenticationPrincipal User user) { - ClubResponse response = clubService.updateClub(clubId, request, user); - return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); - } - @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClubImage( - @Parameter(description = "동호회 ID") @PathVariable Long clubId, - @RequestPart("image") MultipartFile image, - @AuthenticationPrincipal User user) { - ClubImageResponse response = clubService.updateClubImage(clubId, image, user); - return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); - } + @PutMapping("/{clubId}") + @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClub( + @PathVariable Long clubId, + @RequestBody @Valid ClubUpdateRequest request, + @AuthenticationPrincipal User user) { + ClubResponse response = clubService.updateClub(clubId, request, user); + return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); + } + + @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClubImage( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @RequestPart("image") MultipartFile image, + @AuthenticationPrincipal User user) { + ClubImageResponse response = clubService.updateClubImage(clubId, image, user); + return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); + } } diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java index 0e97815..2c6a934 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java @@ -19,8 +19,15 @@ public interface ClubRepository extends JpaRepository { WHERE (:cursor IS NULL OR c.id < :cursor) ORDER BY c.id DESC """) - List findClubsByCursor( - @Param("cursor") Long cursor, - Pageable pageable - ); + List findClubsByCursor(@Param("cursor") Long cursor, Pageable pageable); + + @Query(""" + select c + from ClubMember cm + join cm.club c + where cm.user.id = :userId + and (:cursor is null or c.id < :cursor) + order by c.id desc + """) + List findMyClubsByCursor(@Param("userId") Long userId, @Param("cursor") Long cursor, Pageable pageable); } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java index a5ea68d..be8c316 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java @@ -19,4 +19,7 @@ public interface ClubService { ClubDetailResponse getClub(Long clubId); // 동호회 개별 조회 ClubScrollResponse getClubsByScroll(Long cursor, int size); // 동호회 전체 조회 (무한 스크롤) + + ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user); // 내가 가입한 동호회 조회 (무한 스크롤) + } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java index ba32e05..dcb759f 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java @@ -111,7 +111,16 @@ public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User return ClubImageResponse.from(clubImageUrl); } + @Override + @Transactional(readOnly = true) + public ClubDetailResponse getClub(Long clubId) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + int memberCount = clubMemberRepository.countByClubId(clubId); + + return ClubDetailResponse.from(club, memberCount); + } @Override @Transactional(readOnly = true) public ClubScrollResponse getClubsByScroll(Long cursor, int size) { @@ -140,15 +149,30 @@ public ClubScrollResponse getClubsByScroll(Long cursor, int size) { return new ClubScrollResponse(items, nextCursor, hasNext); } - @Override @Transactional(readOnly = true) - public ClubDetailResponse getClub(Long clubId) { - Club club = clubRepository.findById(clubId) - .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + public ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user) { - int memberCount = clubMemberRepository.countByClubId(clubId); + Pageable pageable = PageRequest.of(0, size + 1); - return ClubDetailResponse.from(club, memberCount); + List clubs = clubRepository.findMyClubsByCursor(user.getId(), cursor, pageable); + + boolean hasNext = clubs.size() > size; + + if (hasNext) { + clubs = clubs.subList(0, size); + } + + List items = clubs.stream() + .map(club -> { + int memberCount = clubMemberRepository.countByClubId(club.getId()); + return ClubListItemResponse.from(club, memberCount); + }) + .toList(); + + Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).clubId(); + + return new ClubScrollResponse(items, nextCursor, hasNext); } + } diff --git a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java index 3cacfeb..adf3792 100644 --- a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java +++ b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java @@ -2,6 +2,7 @@ import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; +import com.be.sportizebe.domain.post.dto.response.CursorPageResponse; import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; import com.be.sportizebe.domain.post.entity.PostProperty; @@ -10,9 +11,11 @@ import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -71,4 +74,15 @@ public ResponseEntity> getPosts( PostPageResponse response = postService.getPosts(property, pageable); return ResponseEntity.ok(BaseResponse.success("게시글 목록 조회 성공", response)); } + @GetMapping("/posts/me") + @Operation(summary = "내 게시글 목록 조회", description = "로그인한 사용자가 작성한 게시글을 최신순으로 무한 스크롤(커서 기반) 조회합니다.") + public ResponseEntity>> getMyPosts( + @Parameter(description = "커서(마지막으로 조회된 게시글 ID). 첫 요청은 생략", example = "123") + @RequestParam(required = false) Long cursor, + @AuthenticationPrincipal User user) + { + CursorPageResponse response = postService.getMyPostsCursor(user, cursor); + return ResponseEntity.ok(BaseResponse.success("내 게시글 목록 조회 성공", response)); + } + } diff --git a/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java b/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java new file mode 100644 index 0000000..505e22f --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java @@ -0,0 +1,20 @@ +package com.be.sportizebe.domain.post.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "커서 기반 무한 스크롤 응답") +public record CursorPageResponse( + + @Schema(description = "조회된 데이터 목록") + List items, + + @Schema(description = "다음 조회를 위한 커서 값 (없으면 null)", example = "123") + Long nextCursor, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + public static CursorPageResponse of(List items, Long nextCursor, boolean hasNext) { + return new CursorPageResponse<>(items, nextCursor, hasNext); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java b/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java index 29e7a29..6012abe 100644 --- a/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java +++ b/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java @@ -6,6 +6,15 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface PostRepository extends JpaRepository { - Page findByProperty(PostProperty property, Pageable pageable); + Page findByProperty(PostProperty property, Pageable pageable); + + // 첫 페이지: 11개(10 + 1) + List findTop11ByUserIdOrderByIdDesc(Long userId); + + // 다음 페이지: 11개(10 + 1) + List findTop11ByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long cursor); + } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java index 6191f2f..4153149 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java @@ -2,6 +2,7 @@ import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; +import com.be.sportizebe.domain.post.dto.response.CursorPageResponse; import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; import com.be.sportizebe.domain.post.entity.PostProperty; @@ -11,11 +12,14 @@ import org.springframework.web.multipart.MultipartFile; public interface PostService { - PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user); // 게시글 생성 + PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user); // 게시글 생성 - PostResponse updatePost(Long postId, UpdatePostRequest request, User user); // 게시글 수정 + PostResponse updatePost(Long postId, UpdatePostRequest request, User user); // 게시글 수정 - void deletePost(Long postId, User user); // 게시글 삭제 + void deletePost(Long postId, User user); // 게시글 삭제 - PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 + PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 + + CursorPageResponse getMyPostsCursor(User user, Long cursor); // 내가 쓴 게시글 무한 스크롤 조회 } + diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java index f77e0b0..5567250 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java @@ -1,6 +1,7 @@ package com.be.sportizebe.domain.post.service; import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; +import com.be.sportizebe.domain.post.dto.response.CursorPageResponse; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; @@ -10,6 +11,7 @@ import com.be.sportizebe.domain.post.repository.PostRepository; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.exception.GlobalErrorCode; import com.be.sportizebe.global.s3.enums.PathName; import com.be.sportizebe.global.s3.service.S3Service; import jakarta.transaction.Transactional; @@ -18,10 +20,13 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -29,6 +34,7 @@ public class PostServiceImpl implements PostService { private final PostRepository postRepository; private final S3Service s3Service; + private static final int PAGE_SIZE = 10; @Override @CacheEvict(cacheNames = "postList", allEntries = true) @@ -85,4 +91,28 @@ public PostPageResponse getPosts(PostProperty property, Pageable pageable) { Page page = postRepository.findByProperty(property, pageable); return PostPageResponse.from(page); } + + + @Override + public CursorPageResponse getMyPostsCursor(User user, Long cursor) { + + if (user == null) { + throw new CustomException(GlobalErrorCode.UNAUTHORIZED); + } + // cursor 없으면 첫 페이지, cursor 있으면 다음 페이지 + List posts = (cursor == null) + ? postRepository.findTop11ByUserIdOrderByIdDesc(user.getId()) + : postRepository.findTop11ByUserIdAndIdLessThanOrderByIdDesc(user.getId(), cursor); + + boolean hasNext = posts.size() > PAGE_SIZE; + if (hasNext) posts = posts.subList(0, PAGE_SIZE); + + Long nextCursor = posts.isEmpty() ? null : posts.get(posts.size() - 1).getId(); + + return CursorPageResponse.of( + posts.stream().map(PostResponse::from).toList(), + hasNext ? nextCursor : null, + hasNext + ); + } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java b/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java index 90f8662..24a5ba0 100644 --- a/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java +++ b/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java @@ -10,7 +10,8 @@ public enum GlobalErrorCode implements BaseErrorCode { INVALID_INPUT_VALUE("G001", "유효하지 않은 입력입니다.", HttpStatus.BAD_REQUEST), RESOURCE_NOT_FOUND("G002", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + UNAUTHORIZED("G004", "로그인 인증이 필요합니다.", HttpStatus.UNAUTHORIZED); private final String code; private final String message; From 53488889a17a94f95dbdffdcc66c0c68497ba1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sun, 8 Feb 2026 18:36:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?:recycle:Refactor:=20=EC=A3=BC=EC=9A=A9=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=B0=98=EC=98=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD(=EC=9D=BC=EB=B6=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubController.java | 100 ++++++++++++------ .../club/repository/ClubMemberRepository.java | 5 +- .../club/repository/ClubRepository.java | 3 - 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index 760d615..9c82b15 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -6,7 +6,8 @@ import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse; -import com.be.sportizebe.domain.club.service.ClubServiceImpl; +import com.be.sportizebe.domain.club.service.ClubService; +import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.cache.dto.UserAuthInfo; import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -27,36 +28,69 @@ @Tag(name = "club", description = "동호회 관리 관련 API") public class ClubController { - private final ClubServiceImpl clubService; - - @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") - public ResponseEntity> createClub( - @RequestPart("request") @Valid ClubCreateRequest request, - @RequestPart(value = "image", required = false) MultipartFile image, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.success("동호회 생성 성공", response)); - } - - @PutMapping("/{clubId}") - @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClub( - @PathVariable Long clubId, - @RequestBody @Valid ClubUpdateRequest request, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId()); - return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); - } - - @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClubImage( - @Parameter(description = "동호회 ID") @PathVariable Long clubId, - @RequestPart("image") MultipartFile image, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId()); - return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); - } + private final ClubService clubService; + + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") + public ResponseEntity> createClub( + @RequestPart("request") @Valid ClubCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.success("동호회 생성 성공", response)); + } + + @PutMapping("/{clubId}") + @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClub( + @PathVariable Long clubId, + @RequestBody @Valid ClubUpdateRequest request, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); + } + + @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClubImage( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @RequestPart("image") MultipartFile image, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); + } + + @GetMapping + @Operation(summary = "모든 동호회 조회 (무한스크롤)", + description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.") + public ResponseEntity> getClubs( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ClubScrollResponse response = clubService.getClubsByScroll(cursor, size); + return ResponseEntity.ok( + BaseResponse.success("동호회 목록 조회 성공", response) + ); + } + + @GetMapping("/{clubId}") + @Operation(summary = "동호회 상세 조회", description = "동호회 단건 상세 정보를 조회합니다.") + public ResponseEntity> getClub( + @Parameter(description = "동호회 ID") @PathVariable Long clubId + ) { + ClubDetailResponse response = clubService.getClub(clubId); + return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response)); + } + + @GetMapping("/me") + @Operation(summary = "내가 가입한 동호회 조회(무한스크롤)", description = "로그인한 사용자가 가입한 동호회를 커서 기반 무한스크롤로 조회합니다.") + public ResponseEntity> getMyClubs( + @Parameter(description = "커서(마지막 clubId). 첫 조회는 null") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수", example = "10") @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal User user + ) { + ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user); + return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response)); + } } diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java index 76b4c4c..fc9e982 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java @@ -7,7 +7,8 @@ public interface ClubMemberRepository extends JpaRepository { - // 특정 사용자가 특정 동호회에 이미 가입했는지 확인 - boolean existsByClubAndUser(Club club, User user); + // 특정 사용자가 특정 동호회에 이미 가입했는지 확인 + boolean existsByClubAndUser(Club club, User user); + int countByClubId(Long clubId); } diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java index 2c6a934..ac946e8 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java @@ -2,12 +2,9 @@ import com.be.sportizebe.domain.club.entity.Club; import org.springframework.data.jpa.repository.JpaRepository; - -import com.be.sportizebe.domain.club.entity.Club; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.data.jpa.repository.JpaRepository; import java.util.List;