diff --git a/build.gradle b/build.gradle index d3b5f89..506a1f3 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,22 @@ dependencies { //Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + //QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + +} + +def generated = "build/generated/sources/annotationProcessor/java/main" + +sourceSets { + main { + java { + srcDirs += [generated] + } + } } tasks.named('test') { diff --git a/src/main/java/team/wego/wegobackend/common/config/QuerydslConfig.java b/src/main/java/team/wego/wegobackend/common/config/QuerydslConfig.java new file mode 100644 index 0000000..03e01b3 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package team.wego.wegobackend.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/team/wego/wegobackend/common/support/LocalDataInitializer.java b/src/main/java/team/wego/wegobackend/common/support/LocalDataInitializer.java new file mode 100644 index 0000000..56d62c3 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/common/support/LocalDataInitializer.java @@ -0,0 +1,48 @@ +package team.wego.wegobackend.common.support; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import team.wego.wegobackend.common.security.Role; +import team.wego.wegobackend.user.domain.Follow; +import team.wego.wegobackend.user.domain.User; + +/** + * local 테스트 데이터 초기화를 위한 설정 + * */ +@Configuration +@Profile("local") +public class LocalDataInitializer implements ApplicationRunner { + + private final EntityManager em; + + public LocalDataInitializer(EntityManager em) { + this.em = em; + } + + @Override + @Transactional + public void run(ApplicationArguments args) throws Exception { + // test user ~100 + for(long i = 0; i <= 100; i++) { + em.persist(User.builder() + .email("user" + i + "@test.com") + .password("1q2w3e4r!") + .nickName("user" + i) + .role(Role.ROLE_USER) + .build() + ); + } + + // user1 → user2~50 + for (long i = 2; i <= 50; i++) { + em.persist(new Follow( + em.getReference(User.class, 1L), + em.getReference(User.class, i) + )); + } + } +} diff --git a/src/main/java/team/wego/wegobackend/group/presentation/GroupImageController.java b/src/main/java/team/wego/wegobackend/group/presentation/GroupImageController.java index d0b5c15..bb60d26 100644 --- a/src/main/java/team/wego/wegobackend/group/presentation/GroupImageController.java +++ b/src/main/java/team/wego/wegobackend/group/presentation/GroupImageController.java @@ -5,6 +5,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -30,7 +31,7 @@ public class GroupImageController implements GroupImageControllerDocs { private final GroupImageService groupImageService; - @PostMapping("/upload") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> uploadImages( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam("images") List images @@ -43,7 +44,7 @@ public ResponseEntity> uploadImages( .body(ApiResponse.success(HttpStatus.CREATED.value(), response)); } - @PatchMapping("/{groupId}") + @PatchMapping(value = "/{groupId}") public ResponseEntity>> updateGroupImages( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long groupId, @@ -58,7 +59,7 @@ public ResponseEntity>> updateGroupImag } - @DeleteMapping("/{groupId}") + @DeleteMapping(value = "/{groupId}") public ResponseEntity> deleteGroupImages( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long groupId diff --git a/src/main/java/team/wego/wegobackend/user/application/FollowService.java b/src/main/java/team/wego/wegobackend/user/application/FollowService.java index 4d27c30..ff68cca 100644 --- a/src/main/java/team/wego/wegobackend/user/application/FollowService.java +++ b/src/main/java/team/wego/wegobackend/user/application/FollowService.java @@ -1,9 +1,12 @@ package team.wego.wegobackend.user.application; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.user.application.dto.response.FollowListResponse; +import team.wego.wegobackend.user.application.dto.response.FollowResponse; import team.wego.wegobackend.user.domain.Follow; import team.wego.wegobackend.user.domain.User; import team.wego.wegobackend.user.exception.ExistFollowException; @@ -68,4 +71,17 @@ public void unFollow(String unFollowNickname, Long followerId) { follower.decreaseFolloweeCount(); follow.decreaseFollowerCount(); } + + public FollowListResponse followList(Long userId, Long cursor, Integer size) { + + if(!userRepository.existsById(userId)) { + throw new UserNotFoundException(); + } + + List list = followRepository.findFollowingList(userId, cursor, size); + + Long nextCursor = list.isEmpty() ? null : list.getLast().getFollowId(); + + return new FollowListResponse(list, nextCursor); + } } \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowListResponse.java b/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowListResponse.java new file mode 100644 index 0000000..f73e2a4 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowListResponse.java @@ -0,0 +1,7 @@ +package team.wego.wegobackend.user.application.dto.response; + +import java.util.List; + +public record FollowListResponse(List items, Long nextCursor) { + +} diff --git a/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowResponse.java b/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowResponse.java new file mode 100644 index 0000000..9064f56 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/user/application/dto/response/FollowResponse.java @@ -0,0 +1,29 @@ +package team.wego.wegobackend.user.application.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public class FollowResponse { + + private final Long followId; //cursor + private final Long userId; + private final String profileImage; + private final String nickname; + private final String profileMessage; + + @QueryProjection + public FollowResponse( + Long followId, + Long userId, + String profileImage, + String nickname, + String profileMessage + ) { + this.followId = followId; + this.userId = userId; + this.profileImage = profileImage; + this.nickname = nickname; + this.profileMessage = profileMessage; + } +} diff --git a/src/main/java/team/wego/wegobackend/user/presentation/UserController.java b/src/main/java/team/wego/wegobackend/user/presentation/UserController.java index ddcc1b7..582211e 100644 --- a/src/main/java/team/wego/wegobackend/user/presentation/UserController.java +++ b/src/main/java/team/wego/wegobackend/user/presentation/UserController.java @@ -1,10 +1,13 @@ package team.wego.wegobackend.user.presentation; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -25,6 +28,7 @@ import team.wego.wegobackend.user.application.UserService; import team.wego.wegobackend.user.application.dto.request.ProfileUpdateRequest; import team.wego.wegobackend.user.application.dto.response.AvailabilityResponse; +import team.wego.wegobackend.user.application.dto.response.FollowListResponse; import team.wego.wegobackend.user.application.dto.response.UserInfoResponse; @Slf4j @@ -53,7 +57,7 @@ public ResponseEntity> profile(@PathVariable Long /** * 프로필 이미지 변경 * */ - @PatchMapping("/profile-image") + @PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> profileImage( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestPart("file") MultipartFile file @@ -131,6 +135,23 @@ public ResponseEntity> unFollow( .body(ApiResponse.success(200, "팔로우 취소 성공")); } + /** + * 팔로우 리스트 조회 + * */ + @GetMapping("/{userId}/follow") + public ResponseEntity> followList( + @PathVariable Long userId, //다른 유저 조회를 위한 파라메터 + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size + ) { + + FollowListResponse response = followService.followList(userId, cursor, size); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(200, response)); + } + /** * 이메일 중복검사 * */ diff --git a/src/main/java/team/wego/wegobackend/user/presentation/UserControllerDocs.java b/src/main/java/team/wego/wegobackend/user/presentation/UserControllerDocs.java index 9693653..da5b21a 100644 --- a/src/main/java/team/wego/wegobackend/user/presentation/UserControllerDocs.java +++ b/src/main/java/team/wego/wegobackend/user/presentation/UserControllerDocs.java @@ -1,8 +1,11 @@ package team.wego.wegobackend.user.presentation; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; @@ -14,6 +17,7 @@ import team.wego.wegobackend.common.security.CustomUserDetails; import team.wego.wegobackend.user.application.dto.request.ProfileUpdateRequest; import team.wego.wegobackend.user.application.dto.response.AvailabilityResponse; +import team.wego.wegobackend.user.application.dto.response.FollowListResponse; import team.wego.wegobackend.user.application.dto.response.UserInfoResponse; @Tag(name = "유저 API", description = "유저와 관련된 API 리스트 \uD83D\uDC08") @@ -52,6 +56,13 @@ ResponseEntity> unFollow( @Valid @RequestParam("unFollowNickname") String unFollowNickname ); + @Operation(summary = "팔로우 리스트 조회 API", description = "userId에 해당하는 유저의 팔로우 리스트를 조회합니다.") + ResponseEntity> followList( + @Parameter(description = "조회할 유저 ID") @PathVariable Long userId, + @Parameter(description = "페이지네이션 커서 (첫 페이지는 null)") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수 (1-100)") @RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size + ); + @Operation(summary = "이메일 중복 검사 API", description = "이메일 중복검사에 대한 응답은 bool값입니다.") ResponseEntity> checkEmailAvailability( @RequestParam("email") String email diff --git a/src/main/java/team/wego/wegobackend/user/repository/FollowRepository.java b/src/main/java/team/wego/wegobackend/user/repository/FollowRepository.java index c5672a8..a5fa6f9 100644 --- a/src/main/java/team/wego/wegobackend/user/repository/FollowRepository.java +++ b/src/main/java/team/wego/wegobackend/user/repository/FollowRepository.java @@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import team.wego.wegobackend.user.domain.Follow; -public interface FollowRepository extends JpaRepository { +public interface FollowRepository extends JpaRepository, FollowRepositoryCustom { Optional findByFollowerIdAndFolloweeId(Long followerId, Long followingId); boolean existsByFollowerIdAndFolloweeId(Long followerId, Long followingId); diff --git a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java new file mode 100644 index 0000000..dd49b42 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryCustom.java @@ -0,0 +1,13 @@ +package team.wego.wegobackend.user.repository; + +import java.util.List; +import team.wego.wegobackend.user.application.dto.response.FollowResponse; + +public interface FollowRepositoryCustom { + + List findFollowingList( + Long followerId, + Long cursorFollowId, + int size + ); +} diff --git a/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java new file mode 100644 index 0000000..64f3a67 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/user/repository/FollowRepositoryImpl.java @@ -0,0 +1,51 @@ +package team.wego.wegobackend.user.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import team.wego.wegobackend.user.application.dto.response.FollowResponse; +import team.wego.wegobackend.user.application.dto.response.QFollowResponse; +import team.wego.wegobackend.user.domain.QFollow; +import team.wego.wegobackend.user.domain.QUser; + +@RequiredArgsConstructor +public class FollowRepositoryImpl implements FollowRepositoryCustom{ + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findFollowingList( + Long followerId, + Long cursorFollowId, + int size + ) { + + QFollow follow = QFollow.follow; + QUser user = QUser.user; + + return jpaQueryFactory + .select(new QFollowResponse( + follow.id, + user.id, + user.profileImage, + user.nickName, + user.profileMessage + )) + .from(follow) + .join(follow.followee, user) + .where( + follow.follower.id.eq(followerId), + ItCursor(cursorFollowId) + ) + .orderBy(follow.id.desc()) //최신 팔로우 순 + .limit(size) + .fetch(); + } + + private BooleanExpression ItCursor(Long cursorFollowId) { + return cursorFollowId == null + ? null + : QFollow.follow.id.lt(cursorFollowId); + } +} diff --git a/src/test/http/user/user-api.http b/src/test/http/user/user-api.http index 6a9a16a..4231722 100644 --- a/src/test/http/user/user-api.http +++ b/src/test/http/user/user-api.http @@ -13,7 +13,7 @@ POST http://localhost:8080/api/v1/auth/login Content-Type: application/json { - "email": "test@example.com", + "email": "test@test.com", "password": "Test1234!@#" } @@ -76,3 +76,17 @@ GET http://localhost:8080/api/v1/users/email/availability ### 닉네임 중복검사 GET http://localhost:8080/api/v1/users/nickname/availability?nickname=test + +### 팔로우 리스트 조회 (초기값) +GET http://localhost:8080/api/v1/users/1/follow?size=10 + +> {% + client.global.set("nextCursor", response.body.data.nextCursor); +%} + +### 팔로우 리스트 조회 (cursor) +GET http://localhost:8080/api/v1/users/1/follow?cursor={{nextCursor}}&size=10 + +> {% + client.global.set("nextCursor", response.body.data.nextCursor); +%} \ No newline at end of file