diff --git a/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java b/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java index bbead1de..3294aaa0 100644 --- a/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java +++ b/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import sixgaezzang.sidepeek.common.exception.ErrorResponse; -import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; +import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; @@ -47,7 +47,7 @@ ResponseEntity save(@Parameter(hidden = true) Long loginId, }) ResponseEntity> getByCondition( @Parameter(hidden = true) Long loginId, - @Valid @ModelAttribute CursorPaginationInfoRequest pageable); + @Valid @ModelAttribute FindProjectRequest pageable); @Operation(summary = "지난 주 인기 프로젝트 조회", description = "지난 주 좋아요를 많이 받은 순으로 최대 5개 프로젝트 목록 조회, 로그인 선택") @ApiResponses({ diff --git a/src/main/java/sixgaezzang/sidepeek/common/doc/description/ProjectDescription.java b/src/main/java/sixgaezzang/sidepeek/common/doc/description/ProjectDescription.java index ef1a36e2..f253b13e 100644 --- a/src/main/java/sixgaezzang/sidepeek/common/doc/description/ProjectDescription.java +++ b/src/main/java/sixgaezzang/sidepeek/common/doc/description/ProjectDescription.java @@ -14,12 +14,15 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ProjectDescription { - // CursorPaginationInfoRequest + + // FindProjectRequest public static final String IS_RELEASED_DESCRIPTION = "출시 서비스만 보기(기본 - false)"; public static final String SORT_DESCRIPTION = "정렬 조건 [ createdAt(default), view, like ]"; public static final String PAGE_SIZE_DESCRIPTION = "한 페이지내 보여질 데이터의 개수"; public static final String LAST_ORDER_COUNT_DESCRIPTION = "더보기 이전 마지막으로 보여진 좋아요수/조회수(첫 페이지면 null)"; public static final String LAST_PROJECT_ID_DESCRIPTION = "더보기 이전 마지막으로 보여진 프로젝트 식별자(첫 페이지면 null)"; + public static final String SKILL_DESCRIPTION = "조회할 기술 스택 목록(없으면 null)"; + public static final String SEARCH_DESCRIPTION = "검색어 [ 프로젝트 제목, 멤버 ](없으면 null)"; // SaveProjectRequest, UpdateProjectRequest public static final String NAME_DESCRIPTION = "제목, " + MAX_PROJECT_NAME_LENGTH + "자 이하"; @@ -43,6 +46,7 @@ public final class ProjectDescription { // SaveMemberRequest public static final String MEMBER_ROLE_DESCRIPTION = "멤버 역할, " + MAX_ROLE_LENGTH + "자 이하"; - public static final String MEMBER_NICKNAME_DESCRIPTION = "멤버 닉네임, 회원도 설정 가능, " + MAX_NICKNAME_LENGTH + "자 이하"; + public static final String MEMBER_NICKNAME_DESCRIPTION = + "멤버 닉네임, 회원도 설정 가능, " + MAX_NICKNAME_LENGTH + "자 이하"; public static final String MEMBER_USER_ID_DESCRIPTION = "회원 멤버 유저 식별자(비회원 멤버이면 null)"; } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java b/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java index 6101e045..b93cf4c1 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java @@ -17,7 +17,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import sixgaezzang.sidepeek.common.annotation.Login; import sixgaezzang.sidepeek.common.doc.ProjectControllerDoc; -import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; +import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; @@ -63,10 +63,10 @@ public ResponseEntity getById( @GetMapping public ResponseEntity> getByCondition( @Login Long loginId, - @Valid @ModelAttribute CursorPaginationInfoRequest pageable + @Valid @ModelAttribute FindProjectRequest request ) { CursorPaginationResponse responses = projectService.findByCondition( - loginId, pageable); + loginId, request); return ResponseEntity.ok().body(responses); } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/dto/request/CursorPaginationInfoRequest.java b/src/main/java/sixgaezzang/sidepeek/projects/dto/request/FindProjectRequest.java similarity index 67% rename from src/main/java/sixgaezzang/sidepeek/projects/dto/request/CursorPaginationInfoRequest.java rename to src/main/java/sixgaezzang/sidepeek/projects/dto/request/FindProjectRequest.java index d88d82db..f4ab1e8a 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/dto/request/CursorPaginationInfoRequest.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/dto/request/FindProjectRequest.java @@ -4,16 +4,19 @@ import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.LAST_ORDER_COUNT_DESCRIPTION; import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.LAST_PROJECT_ID_DESCRIPTION; import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.PAGE_SIZE_DESCRIPTION; +import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.SEARCH_DESCRIPTION; +import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.SKILL_DESCRIPTION; import static sixgaezzang.sidepeek.common.doc.description.ProjectDescription.SORT_DESCRIPTION; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; +import java.util.List; import lombok.Builder; @Schema(description = "프로젝트 조회 시 필터 및 페이지네이션 정보") @Builder -public record CursorPaginationInfoRequest( - +public record FindProjectRequest( + // Cursor Based Pagination @Schema(description = LAST_PROJECT_ID_DESCRIPTION) @Nullable Long lastProjectId, @@ -26,22 +29,35 @@ public record CursorPaginationInfoRequest( @Nullable Integer pageSize, + // Sort @Schema(description = SORT_DESCRIPTION) @Nullable SortType sort, + // Filter @Schema(description = IS_RELEASED_DESCRIPTION) @Nullable - Boolean isReleased + Boolean isReleased, + + @Schema(description = SKILL_DESCRIPTION) + @Nullable + List skill, + + @Schema(description = SEARCH_DESCRIPTION) + @Nullable + String search ) { - public CursorPaginationInfoRequest(@Nullable Long lastProjectId, Long lastOrderCount, - Integer pageSize, SortType sort, - Boolean isReleased) { + + public FindProjectRequest(Long lastProjectId, Long lastOrderCount, + Integer pageSize, SortType sort, + Boolean isReleased, List skill, String search) { this.lastProjectId = lastProjectId; this.lastOrderCount = lastOrderCount; this.pageSize = (pageSize != null) ? pageSize : 24; this.sort = (sort != null) ? sort : SortType.createdAt; this.isReleased = (isReleased != null) ? isReleased : false; + this.skill = skill; + this.search = search; } } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java index 0af0e48f..1a331864 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java @@ -4,7 +4,7 @@ import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; +import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; @@ -14,16 +14,17 @@ public interface ProjectRepositoryCustom { CursorPaginationResponse findByCondition( List likedProjectIds, - CursorPaginationInfoRequest pageable); + FindProjectRequest request); - List findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate, int count); + List findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate, + int count); Page findAllByUserJoined(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); Page findAllByUserLiked(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); Page findAllByUserCommented(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java index 7f9d1180..b3188bee 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java @@ -3,6 +3,7 @@ import static sixgaezzang.sidepeek.comments.domain.QComment.comment; import static sixgaezzang.sidepeek.like.domain.QLike.like; import static sixgaezzang.sidepeek.projects.domain.QProject.project; +import static sixgaezzang.sidepeek.projects.domain.QProjectSkill.projectSkill; import static sixgaezzang.sidepeek.projects.domain.member.QMember.member; import com.querydsl.core.types.OrderSpecifier; @@ -10,18 +11,19 @@ import com.querydsl.core.types.dsl.DateTemplate; import com.querydsl.core.types.dsl.EntityPathBase; import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.NumberTemplate; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import java.time.LocalDate; import java.util.List; +import java.util.Objects; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import sixgaezzang.sidepeek.projects.domain.Project; import sixgaezzang.sidepeek.projects.domain.QProject; -import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; +import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.SortType; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; @@ -40,22 +42,47 @@ public ProjectRepositoryCustomImpl(EntityManager em) { @Override public CursorPaginationResponse findByCondition( List likedProjectIds, - CursorPaginationInfoRequest pageable) { + FindProjectRequest request) { + // where BooleanExpression deployCondition = - pageable.isReleased() ? project.deployUrl.isNotNull() : null; - BooleanExpression cursorCondition = getCursorCondition(pageable.sort(), - pageable.lastProjectId(), pageable.lastOrderCount()); - OrderSpecifier orderSpecifier = getOrderSpecifier(pageable.sort()); - long totalElements = getTotalElementsByCondition(deployCondition); - - List results = queryFactory - .selectFrom(project) + request.isReleased() ? project.deployUrl.isNotNull() : null; + BooleanExpression cursorCondition = getCursorCondition(request.sort(), + request.lastProjectId(), request.lastOrderCount()); + BooleanExpression searchCondition = getSearchCondition(request.search()); + BooleanExpression skillCondition = getSkillCondition(request.skill()); + + // orderBy + OrderSpecifier orderSpecifier = getOrderSpecifier(request.sort()); + + Long totalElements = 0L; + + JPAQuery query = queryFactory + .selectFrom(project); + + if (searchCondition != null) { + query + .join(member).on(project.id.eq(member.project.id)) + .where(searchCondition); + totalElements = getTotalElementsByCondition(member, deployCondition, searchCondition, + member.project); + } else if (skillCondition != null) { + query + .where(skillCondition); + totalElements = getTotalElementsByCondition(projectSkill, deployCondition, + skillCondition, + projectSkill.project); + } else { + totalElements = getTotalElements(deployCondition); + } + + List results = query .where( deployCondition, cursorCondition ) .orderBy(orderSpecifier, project.id.desc()) - .limit(pageable.pageSize() + 1) + .limit(request.pageSize() + 1) + .fetch() .stream() .map(project -> { boolean isLiked = likedProjectIds.contains(project.getId()); @@ -63,12 +90,12 @@ public CursorPaginationResponse findByCondition( }) .toList(); - return checkEndPage(results, pageable.pageSize(), totalElements); + return checkEndPage(results, request.pageSize(), totalElements); } @Override public Page findAllByUserJoined(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression memberCondition = member.user.eq(user); return findPageByCondition(member, member.project, memberCondition, pageable, likedProjectIds); @@ -76,22 +103,22 @@ public Page findAllByUserJoined(List likedProjectIds, @Override public Page findAllByUserLiked(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression likeCondition = like.user.eq(user); return findPageByCondition(like, like.project, likeCondition, pageable, likedProjectIds); } @Override public Page findAllByUserCommented(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression commentCondition = comment.user.eq(user); return findPageByCondition(comment, comment.project, commentCondition, pageable, likedProjectIds); } private Page findPageByCondition(EntityPathBase from, - QProject join, BooleanExpression condition, Pageable pageable, - List likedProjectIds) { + QProject join, BooleanExpression condition, Pageable pageable, + List likedProjectIds) { List projects = queryFactory .select(project) .from(from) @@ -111,15 +138,39 @@ private Page findPageByCondition(EntityPathBase from, private Long getCount(EntityPathBase from, BooleanExpression condition, QProject join) { return queryFactory - .select(project.count()) + .select(project.countDistinct()) .from(from) .join(join, project) .where(condition) .fetchOne(); } + private long getTotalElements(BooleanExpression deployCondition) { + return queryFactory + .select(project.countDistinct()) + .from(project) + .where(deployCondition) + .fetchOne(); + } + + private Long getTotalElementsByCondition(EntityPathBase from, + BooleanExpression deployCondition, + BooleanExpression filterCondition, + QProject join) { + + return queryFactory + .select(project.countDistinct()) + .from(from) + .join(join, project) + .where( + deployCondition, + filterCondition + ) + .fetchOne(); + } + private List toProjectListResponseList(List likedProjectIds, - List projects) { + List projects) { return projects.stream() .map(project -> ProjectListResponse.from(project, likedProjectIds.contains(project.getId()))) @@ -127,7 +178,8 @@ private List toProjectListResponseList(List likedProj } @Override - public List findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate, int count) { + public List findAllPopularOfPeriod(LocalDate startDate, + LocalDate endDate, int count) { DateTemplate createdAt = Expressions.dateTemplate( LocalDate.class, "DATE_FORMAT({0}, {1})", like.createdAt, "%Y-%m-%d"); @@ -146,17 +198,6 @@ public List findAllPopularOfPeriod(LocalDate startDate, L .toList(); } - private long getTotalElementsByCondition(BooleanExpression deployCondition) { - NumberTemplate countTemplate = Expressions.numberTemplate(Long.class, "COUNT({0})", - project.id); - - return queryFactory - .select(countTemplate) - .from(project) - .where(deployCondition) - .fetchOne(); - } - private BooleanExpression getCursorCondition(SortType sort, Long lastProjectId, Long orderCount) { if (lastProjectId == null && orderCount == null) { // 첫 번째 페이지 @@ -177,6 +218,34 @@ private BooleanExpression getCursorCondition(SortType sort, Long lastProjectId, } } + private BooleanExpression getSearchCondition(String search) { + if (Objects.isNull(search) || search.isEmpty()) { + return null; + } + + String keyword = "%" + search.trim() + "%"; + return project.name.likeIgnoreCase(keyword) + .or(member.nickname.likeIgnoreCase(keyword)); + } + + public BooleanExpression getSkillCondition(List skillNames) { + if (Objects.isNull(skillNames) || skillNames.isEmpty()) { + return null; + } + + // 프로젝트 ID 서브쿼리를 생성하여 스킬을 모두 포함하는 프로젝트를 찾기 + JPAQuery projectHasSkillsSubQuery = queryFactory + .select(projectSkill.project.id) + .from(projectSkill) + .join(projectSkill.skill) + .where(projectSkill.skill.name.in(skillNames)) + .groupBy(projectSkill.project.id) + .having(projectSkill.project.id.count().eq(Expressions.constant(skillNames.size()))); + + // 프로젝트 ID 서브쿼리와 매칭되는 프로젝트를 찾는 조건을 반환합니다. + return project.id.in(projectHasSkillsSubQuery); + } + private OrderSpecifier getOrderSpecifier(SortType sort) { switch (sort) { case like: diff --git a/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java b/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java index b8332917..7b3a0682 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java @@ -29,7 +29,7 @@ import sixgaezzang.sidepeek.like.repository.LikeRepository; import sixgaezzang.sidepeek.projects.domain.Project; import sixgaezzang.sidepeek.projects.domain.UserProjectSearchType; -import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; +import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.SaveMemberRequest; import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; @@ -76,11 +76,11 @@ public Project getById(Long projectId) { } public CursorPaginationResponse findByCondition(Long loginId, - CursorPaginationInfoRequest pageable) { + FindProjectRequest request) { // 사용자가 좋아요한 프로젝트 ID를 조회 List likedProjectIds = getLikedProjectIds(loginId); - return projectRepository.findByCondition(likedProjectIds, pageable); + return projectRepository.findByCondition(likedProjectIds, request); } @Transactional diff --git a/src/main/resources/db/data/afterMigrate.sql b/src/main/resources/db/data/afterMigrate.sql index dbdcd7f8..dde11f4d 100644 --- a/src/main/resources/db/data/afterMigrate.sql +++ b/src/main/resources/db/data/afterMigrate.sql @@ -320,13 +320,62 @@ values (21, 'Vercel', -- PROJECT_SKILL insert into project_skill(id, project_id, skill_id, category) -values (21, 1, 1, '프론트'); +values (1, 1, 14, '프론트'); insert into project_skill(id, project_id, skill_id, category) -values (22, 1, 2, '백'); +values (2, 1, 16, '백'); insert into project_skill(id, project_id, skill_id, category) -values (23, 1, 3, '협업툴'); +values (3, 1, 11, '협업툴'); insert into project_skill(id, project_id, skill_id, category) -values (24, 1, 4, '프론트'); +values (4, 1, 1, '인프라'); +insert into project_skill(id, project_id, skill_id, category) +values (5, 1, 2, '인프라'); +insert into project_skill(id, project_id, skill_id, category) +values (6, 2, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (7, 2, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (8, 2, 6, '협업툴'); +insert into project_skill(id, project_id, skill_id, category) +values (9, 2, 1, '인프라'); +insert into project_skill(id, project_id, skill_id, category) +values (10, 2, 4, '디자인'); +insert into project_skill(id, project_id, skill_id, category) +values (11, 3, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (12, 3, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (13, 4, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (14, 4, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (15, 5, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (16, 5, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (17, 6, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (18, 6, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (19, 7, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (20, 7, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (21, 8, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (22, 8, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (23, 9, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (24, 9, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (25, 10, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (26, 10, 16, '백'); +insert into project_skill(id, project_id, skill_id, category) +values (27, 11, 14, '프론트'); +insert into project_skill(id, project_id, skill_id, category) +values (28, 11, 16, '백'); + -- LIKE insert into likes(id, user_id, project_id)