diff --git a/src/main/java/daybyquest/global/config/WebMvcConfig.java b/src/main/java/daybyquest/global/config/WebMvcConfig.java index 8a37d3a..a6d16a7 100644 --- a/src/main/java/daybyquest/global/config/WebMvcConfig.java +++ b/src/main/java/daybyquest/global/config/WebMvcConfig.java @@ -2,6 +2,7 @@ import daybyquest.auth.AuthorizationInterceptor; import daybyquest.auth.UserIdArgumentResolver; +import daybyquest.global.query.NoOffsetIdPageArgumentResolver; import java.util.List; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -19,5 +20,6 @@ public void addInterceptors(final InterceptorRegistry registry) { @Override public void addArgumentResolvers(List resolvers) { resolvers.add(new UserIdArgumentResolver()); + resolvers.add(new NoOffsetIdPageArgumentResolver()); } } diff --git a/src/main/java/daybyquest/global/error/ExceptionCode.java b/src/main/java/daybyquest/global/error/ExceptionCode.java index 991df48..85d3a1b 100644 --- a/src/main/java/daybyquest/global/error/ExceptionCode.java +++ b/src/main/java/daybyquest/global/error/ExceptionCode.java @@ -63,7 +63,7 @@ public enum ExceptionCode { NOT_EXIST_INTEREST("INT-00", BAD_REQUEST, "존재하지 않는 관심사 입니다"), // Relationship - NOT_FOLLOWING_USER("REL-00", BAD_REQUEST, "팔로우하지 않은 사용자는 취소할 수 없습니다"), + NOT_FOLLOWING_USER("REL-00", BAD_REQUEST, "팔로우하지 않은 사용자입니다"), NOT_BLOCKING_USER("REL-01", BAD_REQUEST, "차단하지 않은 사용자는 취소할 수 없습니다"), ALREADY_FOLLOWING_USER("REL-02", BAD_REQUEST, "이미 팔로우한 사용자입니다"), ALREADY_BLOCKING_USER("REL-03", BAD_REQUEST, "이미 차단한 사용자입니다"), diff --git a/src/main/java/daybyquest/global/error/exception/BadRequestException.java b/src/main/java/daybyquest/global/error/exception/BadRequestException.java index e3b1e5d..d1d1530 100644 --- a/src/main/java/daybyquest/global/error/exception/BadRequestException.java +++ b/src/main/java/daybyquest/global/error/exception/BadRequestException.java @@ -3,7 +3,11 @@ import daybyquest.global.error.ExceptionCode; import java.util.List; -public class BadRequestException extends CustomException{ +public class BadRequestException extends CustomException { + + public BadRequestException() { + super(ExceptionCode.INVALID_REQUEST); + } public BadRequestException(ExceptionCode exceptionCode, List fields) { super(exceptionCode, fields); diff --git a/src/main/java/daybyquest/global/query/LongIdList.java b/src/main/java/daybyquest/global/query/LongIdList.java new file mode 100644 index 0000000..3b2b4b6 --- /dev/null +++ b/src/main/java/daybyquest/global/query/LongIdList.java @@ -0,0 +1,21 @@ +package daybyquest.global.query; + +import java.util.List; +import lombok.Getter; + +@Getter +public class LongIdList { + + private final List ids; + + public LongIdList(final List ids) { + this.ids = ids; + } + + public Long getLastId() { + if (ids.isEmpty()) { + return Long.MAX_VALUE; + } + return ids.get(ids.size() - 1); + } +} diff --git a/src/main/java/daybyquest/global/query/NoOffsetIdPage.java b/src/main/java/daybyquest/global/query/NoOffsetIdPage.java new file mode 100644 index 0000000..474d4a8 --- /dev/null +++ b/src/main/java/daybyquest/global/query/NoOffsetIdPage.java @@ -0,0 +1,16 @@ +package daybyquest.global.query; + +import lombok.Getter; + +@Getter +public class NoOffsetIdPage { + + private final Long lastId; + + private final int limit; + + public NoOffsetIdPage(final Long lastId, final int limit) { + this.lastId = lastId; + this.limit = limit; + } +} diff --git a/src/main/java/daybyquest/global/query/NoOffsetIdPageArgumentResolver.java b/src/main/java/daybyquest/global/query/NoOffsetIdPageArgumentResolver.java new file mode 100644 index 0000000..72c7d8d --- /dev/null +++ b/src/main/java/daybyquest/global/query/NoOffsetIdPageArgumentResolver.java @@ -0,0 +1,52 @@ +package daybyquest.global.query; + +import daybyquest.global.error.exception.BadRequestException; +import jakarta.annotation.Nonnull; +import java.util.regex.Pattern; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class NoOffsetIdPageArgumentResolver implements HandlerMethodArgumentResolver { + + private static final int MAX_LIMIT = 15; + private static final Pattern NUMBER = Pattern.compile("^[0-9]*$"); + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(NoOffsetIdPage.class); + } + + @Override + public Object resolveArgument(@Nonnull MethodParameter parameter, ModelAndViewContainer mavContainer, + @Nonnull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final String lastIdArgument = webRequest.getParameter("lastId"); + final String limitArgument = webRequest.getParameter("limit"); + return new NoOffsetIdPage(convertAndValidateLastId(lastIdArgument), + convertAndValidateLimit(limitArgument)); + } + + private Long convertAndValidateLastId(String lastId) { + if (lastId == null) { + return 0L; + } + if (!NUMBER.matcher(lastId).matches()) { + throw new BadRequestException(); + } + return Long.parseLong(lastId); + } + + private int convertAndValidateLimit(String limit) { + if (limit == null || !NUMBER.matcher(limit).matches()) { + throw new BadRequestException(); + } + final int parsedLimit = Integer.parseInt(limit); + if (parsedLimit > MAX_LIMIT) { + throw new BadRequestException(); + } + return parsedLimit; + } +} diff --git a/src/main/java/daybyquest/relation/application/DeleteFollowerService.java b/src/main/java/daybyquest/relation/application/DeleteFollowerService.java new file mode 100644 index 0000000..2e0fea0 --- /dev/null +++ b/src/main/java/daybyquest/relation/application/DeleteFollowerService.java @@ -0,0 +1,25 @@ +package daybyquest.relation.application; + +import daybyquest.relation.domain.Follow; +import daybyquest.relation.domain.Follows; +import daybyquest.user.domain.Users; +import org.springframework.stereotype.Service; + +@Service +public class DeleteFollowerService { + + private final Follows follows; + + private final Users users; + + public DeleteFollowerService(final Follows follows, final Users users) { + this.follows = follows; + this.users = users; + } + + public void invoke(final Long loginId, final String targetUsername) { + final Long targetId = users.getUserIdByUsername(targetUsername); + final Follow follow = follows.getByUserIdAndTargetId(targetId, loginId); + follows.delete(follow); + } +} diff --git a/src/main/java/daybyquest/relation/application/GetFollowersService.java b/src/main/java/daybyquest/relation/application/GetFollowersService.java new file mode 100644 index 0000000..419f7dc --- /dev/null +++ b/src/main/java/daybyquest/relation/application/GetFollowersService.java @@ -0,0 +1,31 @@ +package daybyquest.relation.application; + +import daybyquest.global.query.LongIdList; +import daybyquest.global.query.NoOffsetIdPage; +import daybyquest.relation.query.FollowDao; +import daybyquest.user.dto.response.PageProfilesResponse; +import daybyquest.user.dto.response.ProfileResponse; +import daybyquest.user.query.ProfileService; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GetFollowersService { + + private final FollowDao followDao; + + private final ProfileService profileService; + + public GetFollowersService(final FollowDao followDao, final ProfileService profileService) { + this.followDao = followDao; + this.profileService = profileService; + } + + @Transactional(readOnly = true) + public PageProfilesResponse invoke(final Long loginId, final NoOffsetIdPage page) { + final LongIdList targetIds = followDao.getFollowerIds(loginId, page); + final List profiles = profileService.getProfilesByIdIn(loginId, targetIds.getIds()); + return new PageProfilesResponse(profiles, targetIds.getLastId()); + } +} diff --git a/src/main/java/daybyquest/relation/application/GetFollowingsService.java b/src/main/java/daybyquest/relation/application/GetFollowingsService.java new file mode 100644 index 0000000..0b559e3 --- /dev/null +++ b/src/main/java/daybyquest/relation/application/GetFollowingsService.java @@ -0,0 +1,31 @@ +package daybyquest.relation.application; + +import daybyquest.global.query.LongIdList; +import daybyquest.global.query.NoOffsetIdPage; +import daybyquest.relation.query.FollowDao; +import daybyquest.user.dto.response.PageProfilesResponse; +import daybyquest.user.dto.response.ProfileResponse; +import daybyquest.user.query.ProfileService; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GetFollowingsService { + + private final FollowDao followDao; + + private final ProfileService profileService; + + public GetFollowingsService(final FollowDao followDao, final ProfileService profileService) { + this.followDao = followDao; + this.profileService = profileService; + } + + @Transactional(readOnly = true) + public PageProfilesResponse invoke(final Long loginId, final NoOffsetIdPage page) { + final LongIdList targetIds = followDao.getFollowingIds(loginId, page); + final List profiles = profileService.getProfilesByIdIn(loginId, targetIds.getIds()); + return new PageProfilesResponse(profiles, targetIds.getLastId()); + } +} diff --git a/src/main/java/daybyquest/relation/presentation/FollowController.java b/src/main/java/daybyquest/relation/presentation/FollowController.java index acbc52c..e29950d 100644 --- a/src/main/java/daybyquest/relation/presentation/FollowController.java +++ b/src/main/java/daybyquest/relation/presentation/FollowController.java @@ -2,10 +2,16 @@ import daybyquest.auth.Authorization; import daybyquest.auth.UserId; +import daybyquest.global.query.NoOffsetIdPage; import daybyquest.relation.application.DeleteFollowService; +import daybyquest.relation.application.DeleteFollowerService; +import daybyquest.relation.application.GetFollowersService; +import daybyquest.relation.application.GetFollowingsService; import daybyquest.relation.application.SaveFollowService; +import daybyquest.user.dto.response.PageProfilesResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,10 +23,20 @@ public class FollowController { private final DeleteFollowService deleteFollowService; + private final DeleteFollowerService deleteFollowerService; + + private final GetFollowersService getFollowersService; + + private final GetFollowingsService getFollowingsService; + public FollowController(final SaveFollowService saveFollowService, - final DeleteFollowService deleteFollowService) { + final DeleteFollowService deleteFollowService, final DeleteFollowerService deleteFollowerService, + final GetFollowersService getFollowersService, final GetFollowingsService getFollowingsService) { this.saveFollowService = saveFollowService; this.deleteFollowService = deleteFollowService; + this.deleteFollowerService = deleteFollowerService; + this.getFollowersService = getFollowersService; + this.getFollowingsService = getFollowingsService; } @PostMapping("/profile/{username}/follow") @@ -38,4 +54,28 @@ public ResponseEntity deleteFollow(@UserId final Long loginId, deleteFollowService.invoke(loginId, username); return ResponseEntity.ok().build(); } + + @DeleteMapping("/profile/{username}/follower") + @Authorization + public ResponseEntity deleteFollower(@UserId final Long loginId, + @PathVariable final String username) { + deleteFollowerService.invoke(loginId, username); + return ResponseEntity.ok().build(); + } + + @GetMapping("/followings") + @Authorization + public ResponseEntity getFollowings(@UserId final Long loginId, + final NoOffsetIdPage page) { + final PageProfilesResponse response = getFollowingsService.invoke(loginId, page); + return ResponseEntity.ok(response); + } + + @GetMapping("/followers") + @Authorization + public ResponseEntity getFollowers(@UserId final Long loginId, + final NoOffsetIdPage page) { + final PageProfilesResponse response = getFollowersService.invoke(loginId, page); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/daybyquest/relation/query/FollowDao.java b/src/main/java/daybyquest/relation/query/FollowDao.java new file mode 100644 index 0000000..c006657 --- /dev/null +++ b/src/main/java/daybyquest/relation/query/FollowDao.java @@ -0,0 +1,11 @@ +package daybyquest.relation.query; + +import daybyquest.global.query.LongIdList; +import daybyquest.global.query.NoOffsetIdPage; + +public interface FollowDao { + + LongIdList getFollowingIds(final Long userId, final NoOffsetIdPage page); + + LongIdList getFollowerIds(final Long targetId, final NoOffsetIdPage page); +} diff --git a/src/main/java/daybyquest/relation/query/FollowDaoQuerydslImpl.java b/src/main/java/daybyquest/relation/query/FollowDaoQuerydslImpl.java new file mode 100644 index 0000000..a997699 --- /dev/null +++ b/src/main/java/daybyquest/relation/query/FollowDaoQuerydslImpl.java @@ -0,0 +1,36 @@ +package daybyquest.relation.query; + +import static daybyquest.relation.domain.QFollow.follow; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import daybyquest.global.query.LongIdList; +import daybyquest.global.query.NoOffsetIdPage; +import org.springframework.stereotype.Repository; + +@Repository +public class FollowDaoQuerydslImpl implements FollowDao { + + private final JPAQueryFactory factory; + + public FollowDaoQuerydslImpl(final JPAQueryFactory factory) { + this.factory = factory; + } + + @Override + public LongIdList getFollowingIds(final Long userId, final NoOffsetIdPage page) { + return new LongIdList(factory.select(follow.targetId) + .from(follow) + .where(follow.userId.eq(userId).and(follow.targetId.gt(page.getLastId()))) + .limit(page.getLimit()) + .fetch()); + } + + @Override + public LongIdList getFollowerIds(final Long targetId, final NoOffsetIdPage page) { + return new LongIdList(factory.select(follow.userId) + .from(follow) + .where(follow.targetId.eq(targetId).and(follow.userId.gt(page.getLastId()))) + .limit(page.getLimit()) + .fetch()); + } +} diff --git a/src/main/java/daybyquest/user/dto/response/PageProfilesResponse.java b/src/main/java/daybyquest/user/dto/response/PageProfilesResponse.java new file mode 100644 index 0000000..0bb8540 --- /dev/null +++ b/src/main/java/daybyquest/user/dto/response/PageProfilesResponse.java @@ -0,0 +1,19 @@ +package daybyquest.user.dto.response; + +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PageProfilesResponse { + + private List users; + + private Long nextId; + + public PageProfilesResponse(final List users, final Long nextId) { + this.users = users; + this.nextId = nextId; + } +} diff --git a/src/main/java/daybyquest/user/query/ProfileDao.java b/src/main/java/daybyquest/user/query/ProfileDao.java index aa2426f..56d5136 100644 --- a/src/main/java/daybyquest/user/query/ProfileDao.java +++ b/src/main/java/daybyquest/user/query/ProfileDao.java @@ -1,10 +1,14 @@ package daybyquest.user.query; import daybyquest.user.domain.Profile; +import java.util.Collection; +import java.util.List; public interface ProfileDao { Profile getByUsername(final Long userId, final String username); Profile getMine(final Long userId); + + List findAllByUserIdIn(final Long userId, final Collection targetIds); } diff --git a/src/main/java/daybyquest/user/query/ProfileDaoQuerydslImpl.java b/src/main/java/daybyquest/user/query/ProfileDaoQuerydslImpl.java index 558798b..12b36d9 100644 --- a/src/main/java/daybyquest/user/query/ProfileDaoQuerydslImpl.java +++ b/src/main/java/daybyquest/user/query/ProfileDaoQuerydslImpl.java @@ -5,11 +5,14 @@ import static daybyquest.relation.domain.QFollow.follow; import static daybyquest.user.domain.QUser.user; +import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import daybyquest.global.error.exception.NotExistUserException; import daybyquest.user.domain.Profile; +import java.util.Collection; +import java.util.List; import org.springframework.stereotype.Repository; @Repository @@ -24,21 +27,7 @@ public ProfileDaoQuerydslImpl(final JPAQueryFactory factory) { @Override public Profile getByUsername(final Long userId, final String username) { final Profile profile = factory - .select(Projections.constructor(Profile.class, - user.id, - user.username, - user.name, - user.image.imageIdentifier, - JPAExpressions.select(post.count()) - .from(post) - .where(post.userId.eq(userId)), - JPAExpressions.selectFrom(follow) - .where(follow.userId.eq(userId).and(follow.targetId.eq(user.id))) - .exists(), - JPAExpressions.selectFrom(block) - .where(block.userId.eq(userId).and(block.targetId.eq(user.id))) - .exists() - )) + .select(profileProjection(userId)) .from(user) .where(user.username.eq(username)) .fetchOne(); @@ -48,6 +37,23 @@ public Profile getByUsername(final Long userId, final String username) { return profile; } + private ConstructorExpression profileProjection(final Long userId) { + return Projections.constructor(Profile.class, + user.id, + user.username, + user.name, + user.image.imageIdentifier, + JPAExpressions.select(post.count()) + .from(post) + .where(post.userId.eq(userId)), + JPAExpressions.selectFrom(follow) + .where(follow.userId.eq(userId).and(follow.targetId.eq(user.id))) + .exists(), + JPAExpressions.selectFrom(block) + .where(block.userId.eq(userId).and(block.targetId.eq(user.id))) + .exists() + ); + } @Override public Profile getMine(final Long userId) { @@ -75,4 +81,13 @@ public Profile getMine(final Long userId) { } return profile; } + + @Override + public List findAllByUserIdIn(final Long userId, final Collection targetIds) { + return factory + .select(profileProjection(userId)) + .from(user) + .where(user.id.in(targetIds)) + .fetch(); + } } diff --git a/src/main/java/daybyquest/user/query/ProfileService.java b/src/main/java/daybyquest/user/query/ProfileService.java new file mode 100644 index 0000000..14c07be --- /dev/null +++ b/src/main/java/daybyquest/user/query/ProfileService.java @@ -0,0 +1,29 @@ +package daybyquest.user.query; + +import daybyquest.user.domain.Profile; +import daybyquest.user.domain.UserImages; +import daybyquest.user.dto.response.ProfileResponse; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Transactional(readOnly = true) +public class ProfileService { + + private final ProfileDao profileDao; + + private final UserImages userImages; + + public ProfileService(final ProfileDao profileDao, final UserImages userImages) { + this.profileDao = profileDao; + this.userImages = userImages; + } + + public List getProfilesByIdIn(final Long userId, final List ids) { + final List profiles = profileDao.findAllByUserIdIn(userId, ids); + return profiles.stream().map((profile) -> + ProfileResponse.of(profile, userImages.getPublicUrl(profile.getImageIdentifier())) + ).toList(); + } +}