Skip to content

Commit

Permalink
[Feat] 팔로잉/팔로워 검색 API (#334)
Browse files Browse the repository at this point in the history
* [Feat] 초대 유저 검색 API

* [Test] 초대 유저 닉네임 검색 API 문서화용 테스트

* [Fix] ddl reaction 테이블 외래키 이름 수정

* [Feat] {nickname}:{userId}로 커서 수정. 정렬 및 where 조건에 userId 추가

* [Fix] 테스트 파라미터 수정
  • Loading branch information
heoboseong7 authored Jun 11, 2023
1 parent 1fcb96c commit 0b4ed3a
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,11 @@ public FollowFromInsightCountResponse toFollowFromInsightCountResponse(Long foll
public ProfileVisitFromInsightCountResponse toProfileVisitFromInsightCountResponse(Long profileVisitFromInsightCount) {
return ProfileVisitFromInsightCountResponse.of(profileVisitFromInsightCount);
}

public InviteeSearchResponse toInviteeSearchResponse(List<User> invitees, String nextCursor) {
List<InviteeResponse> inviteeResponse = invitees.stream()
.map(this::toRelatedUserResponse)
.collect(Collectors.toList());
return InviteeSearchResponse.of(nextCursor, inviteeResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import ccc.keeweapi.dto.ApiResponse;
import ccc.keeweapi.dto.user.InviteeListResponse;
import ccc.keeweapi.dto.user.InviteeSearchResponse;
import ccc.keeweapi.service.user.ProfileApiService;
import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor;
import ccc.keewedomain.persistence.repository.utils.CursorPageable;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
Expand All @@ -26,4 +28,14 @@ public ApiResponse<InviteeListResponse> paginateInvitees(
CursorPageable<LocalDateTime> cPage = CursorPageable.of(cursor, limit);
return ApiResponse.ok(profileApiService.paginateInvitees(cPage));
}

@GetMapping("/invitee/search")
public ApiResponse<InviteeSearchResponse> searchInvitees(
@RequestParam String searchWord,
@RequestParam(required = false) String cursor,
@RequestParam Long limit
) {
CursorPageable<InviteeSearchCursor> cPage = CursorPageable.of(InviteeSearchCursor.from(cursor), limit);
return ApiResponse.ok(profileApiService.searchInvitees(searchWord, cPage));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ccc.keeweapi.dto.user;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor(staticName = "of")
public class InviteeSearchResponse {
// 응답의 닉네임 중 사전순 가장 뒤
private String nextCursor;
private List<InviteeResponse> invitees;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import ccc.keeweapi.dto.user.FollowUserListResponse;
import ccc.keeweapi.dto.user.InterestsResponse;
import ccc.keeweapi.dto.user.InviteeListResponse;
import ccc.keeweapi.dto.user.InviteeSearchResponse;
import ccc.keeweapi.dto.user.MyBlockUserListResponse;
import ccc.keeweapi.dto.user.MyPageTitleResponse;
import ccc.keeweapi.dto.user.OnboardRequest;
Expand All @@ -21,6 +22,7 @@
import ccc.keeweapi.utils.SecurityUtil;
import ccc.keewecore.utils.ListUtils;
import ccc.keewedomain.dto.user.FollowCheckDto;
import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor;
import ccc.keewedomain.persistence.domain.challenge.Challenge;
import ccc.keewedomain.persistence.domain.challenge.ChallengeParticipation;
import ccc.keewedomain.persistence.domain.title.TitleAchievement;
Expand Down Expand Up @@ -170,22 +172,25 @@ public InterestsResponse getInterests() {
public InviteeListResponse paginateInvitees(CursorPageable<LocalDateTime> cPage) {
Long userId = SecurityUtil.getUserId();
List<Follow> relatedFollows = profileQueryDomainService.findRelatedFollows(userId, cPage);
List<User> invitees = relatedFollows.stream()
.map(follow -> {
if (follow.getFollower().getId().equals(userId)) { // follower가 나인 경우
return follow.getFollowee();
} else { // followee가 나인 경우
return follow.getFollower();
}
})
.distinct() // 양방향으로 팔로우 되어 있는 경우 중복 제거
.collect(Collectors.toList());
List<User> invitees = getInvitees(userId, relatedFollows);
String nextCursor = relatedFollows.size() == cPage.getLimit()
? ListUtils.getLast(relatedFollows).getCreatedAt().toString()
: null;
return profileAssembler.toInviteeListResponse(invitees, nextCursor);
}

@Transactional(readOnly = true)
public InviteeSearchResponse searchInvitees(String searchWord, CursorPageable<InviteeSearchCursor> cPage) {
Long userId = SecurityUtil.getUserId();
List<Follow> searchedFollows = profileQueryDomainService.searchRelatedUsers(userId, searchWord, cPage);
List<User> invitees = getInvitees(userId, searchedFollows);
User lastInvitee = ListUtils.getLast(invitees);
InviteeSearchCursor nextCursor = searchedFollows.size() == cPage.getLimit()
? InviteeSearchCursor.of(lastInvitee.getNickname(), lastInvitee.getId())
: null;
return profileAssembler.toInviteeSearchResponse(invitees, nextCursor != null ? nextCursor.toString() : null);
}

public AccountResponse getAccount() {
User user = SecurityUtil.getUser();
return profileAssembler.toAccountResponse(user);
Expand All @@ -196,4 +201,17 @@ private void afterGetMyProfile(Long userId, Long insightId) {
insightStatisticsCommandDomainService.publishProfileVisitFromInsightEvent(userId, insightId);
}
}

private static List<User> getInvitees(Long userId, List<Follow> searchedFollows) {
return searchedFollows.stream()
.map(follow -> {
if (follow.getFollower().getId().equals(userId)) { // follower가 나인 경우
return follow.getFollowee();
} else { // followee가 나인 경우
return follow.getFollower();
}
})
.distinct() // 양방향으로 팔로우 되어 있는 경우 중복 제거
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import ccc.keeweapi.document.utils.ApiDocumentationTest;
import ccc.keeweapi.dto.user.InviteeListResponse;
import ccc.keeweapi.dto.user.InviteeResponse;
import ccc.keeweapi.dto.user.InviteeSearchResponse;
import ccc.keeweapi.service.user.ProfileApiService;
import com.epages.restdocs.apispec.ResourceSnippetParameters;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -83,4 +84,51 @@ void get_related_users() throws Exception {
.build()
)));
}

@Test
@DisplayName("초대 유저 닉네임 검색 API")
void search_related_users() throws Exception {
long limit = 10L;
String cursor = "닉네임:1";
String searchWord = "닉네";
List<InviteeResponse> inviteeResponse = List.of(
InviteeResponse.of(1L, "hello", "www.api-keewe.com/images/128398681"),
InviteeResponse.of(2L, "world", "www.api-keewe.com/images/128398681")
);

InviteeSearchResponse response = InviteeSearchResponse.of(cursor, inviteeResponse);

when(profileApiService.searchInvitees(any(), any()))
.thenReturn(response);

ResultActions resultActions = mockMvc.perform(get("/api/v1/invitee/search")
.param("searchWord", searchWord)
.param("cursor", cursor)
.param("limit", Long.toString(limit))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + JWT)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());

resultActions.andDo(restDocs.document(resource(
ResourceSnippetParameters.builder()
.description("초대 유저 닉네임 검색 API 입니다")
.summary("초대 유저 닉네임 검색 API")
.requestHeaders(
headerWithName("Authorization").description("유저의 JWT"))
.requestParameters(
parameterWithName("searchWord").description("검색할 닉네임 prefix"),
parameterWithName("cursor").description("페이징을 위한 커서. 응답의 nextCursor. 첫 요청엔 미포함").optional(),
parameterWithName("limit").description("한번에 조회할 개수"))
.responseFields(
fieldWithPath("message").description("요청 결과 메세지"),
fieldWithPath("code").description("결과 코드"),
fieldWithPath("data.nextCursor").description("다음 조회를 위한 커서. 없을 경우 null"),
fieldWithPath("data.invitees[].userId").description("유저의 ID"),
fieldWithPath("data.invitees[].nickname").description("유저의 닉네임"),
fieldWithPath("data.invitees[].imageURL").description("유저의 프로필 이미지 URL")
)
.tag("UserInvitation")
.build()
)));
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package ccc.keewedomain.persistence.repository.user;

import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor;
import ccc.keewedomain.persistence.domain.user.Follow;
import ccc.keewedomain.persistence.domain.user.User;
import ccc.keewedomain.persistence.repository.utils.CursorPageable;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.JPQLQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
Expand All @@ -11,11 +13,9 @@

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

import static ccc.keewedomain.persistence.domain.title.QTitle.title;
import static ccc.keewedomain.persistence.domain.user.QFollow.follow;
import static ccc.keewedomain.persistence.domain.user.QProfilePhoto.profilePhoto;
import static ccc.keewedomain.persistence.domain.user.QUser.user;

@Repository
Expand Down Expand Up @@ -65,6 +65,31 @@ public List<Follow> findAllByUserIdOrderByCreatedAtDesc(Long userId, CursorPagea
.fetch();
}

public List<Follow> findByUserIdAndStartsWithNickname(Long userId, String word, CursorPageable<InviteeSearchCursor> cPage) {
return queryFactory.selectFrom(follow)
.innerJoin(follow.follower, user)
.fetchJoin()
.innerJoin(follow.followee, user)
.fetchJoin()
.where(follow.follower.id.eq(userId)
.or(follow.followee.id.eq(userId)))
.where(user.nickname.startsWith(word)
.and(nicknameGt(cPage.getCursor().getNickname()))
.and(userIdGt(cPage.getCursor().getUserId())))
.orderBy(user.nickname.asc(), user.id.asc())
.limit(cPage.getLimit())
.fetch();
}

private BooleanExpression userIdGt(Long userId) {
return userId != null ? user.id.gt(userId) : null;
}

// note. cursor가 null인 경우 조건을 추가하지 않음
private BooleanExpression nicknameGt(String nickname) {
return nickname != null ? user.nickname.gt(nickname) : null;
}

private JPQLQuery<Long> findFolloweeIdsOrderByCreatedAtDesc(User target, CursorPageable<LocalDateTime> cPage) {
return JPAExpressions.select(follow.followee.id)
.from(follow)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package ccc.keewedomain.persistence.repository.user.cursor;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class InviteeSearchCursor {
private String nickname;
private Long userId;

public static InviteeSearchCursor from(String cursorStr) {
if(cursorStr == null) {
return new InviteeSearchCursor(null, null);
}

int lastIndex = cursorStr.lastIndexOf(":");
String nickname = cursorStr.substring(0, lastIndex);
Long userId = Long.valueOf(cursorStr.substring(lastIndex + 1));
return new InviteeSearchCursor(nickname, userId);
}

public static InviteeSearchCursor of(String nickname, Long userId) {
return new InviteeSearchCursor(nickname, userId);
}

@Override
public String toString() {
return nickname + ":" + userId;
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
package ccc.keewedomain.service.user.query;

import ccc.keewedomain.dto.user.FollowCheckDto;
import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor;
import ccc.keewedomain.persistence.domain.common.Interest;
import ccc.keewedomain.persistence.domain.title.TitleAchievement;
import ccc.keewedomain.persistence.domain.user.Block;
import ccc.keewedomain.persistence.domain.user.Follow;
import ccc.keewedomain.persistence.domain.user.User;
import ccc.keewedomain.persistence.domain.user.id.FollowId;
import ccc.keewedomain.persistence.repository.insight.FollowFromInsightQueryRepository;
import ccc.keewedomain.persistence.repository.user.BlockQueryRepository;
import ccc.keewedomain.persistence.repository.user.FollowQueryRepository;
import ccc.keewedomain.persistence.repository.user.FollowRepository;
import ccc.keewedomain.persistence.repository.user.TitleAchievedQueryRepository;
import ccc.keewedomain.persistence.repository.user.TitleAchievementRepository;
import ccc.keewedomain.persistence.repository.user.UserQueryRepository;
import ccc.keewedomain.persistence.repository.utils.CursorPageable;
import ccc.keewedomain.service.user.UserDomainService;
import lombok.RequiredArgsConstructor;
Expand All @@ -35,9 +34,7 @@ public class ProfileQueryDomainService {
private final TitleAchievementRepository titleAchievementRepository;
private final TitleAchievedQueryRepository titleAchievedQueryRepository;
private final FollowQueryRepository followQueryRepository;
private final UserQueryRepository userQueryRepository;
private final BlockQueryRepository blockQueryRepository;
private final FollowFromInsightQueryRepository followFromInsightQueryRepository;

public boolean isFollowing(FollowCheckDto followCheckDto) {
return followRepository.existsById(FollowId.of(followCheckDto.getUserId(), followCheckDto.getTargetId()));
Expand Down Expand Up @@ -102,4 +99,9 @@ public List<String> getInterests(User user) {
public List<Follow> findRelatedFollows(Long userId, CursorPageable<LocalDateTime> cPage) {
return followQueryRepository.findAllByUserIdOrderByCreatedAtDesc(userId, cPage);
}

@Transactional(readOnly = true)
public List<Follow> searchRelatedUsers(Long userId, String searchWord, CursorPageable<InviteeSearchCursor> cPage) {
return followQueryRepository.findByUserIdAndStartsWithNickname(userId, searchWord, cPage);
}
}
2 changes: 1 addition & 1 deletion keewe-domain/src/main/resources/ddl/ddl.sql
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ CREATE TABLE IF NOT EXISTS `reaction`
updated_at DATETIME(6) NOT NULL,

FOREIGN KEY (insight_id) REFERENCES `insight`(insight_id),
FOREIGN KEY (reaction_id) REFERENCES `user`(user_id),
FOREIGN KEY (reactor_id) REFERENCES `user`(user_id),
PRIMARY KEY (reaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Expand Down

0 comments on commit 0b4ed3a

Please sign in to comment.