Skip to content

Commit 21e6375

Browse files
authored
Merge pull request #64 from Apple-Square/develop
[feat] (키워드 기반) 게시글 검색 기능 추가
2 parents 82d6840 + d6145f2 commit 21e6375

14 files changed

+741
-274
lines changed

src/main/java/applesquare/moment/post/controller/MomentController.java renamed to src/main/java/applesquare/moment/post/controller/MomentReadController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
@RestController
1717
@RequiredArgsConstructor
1818
@RequestMapping("/api")
19-
public class MomentController {
19+
public class MomentReadController {
2020
private final MomentReadService momentReadService;
2121

2222

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package applesquare.moment.post.controller;
2+
3+
import applesquare.moment.common.dto.PageRequestDTO;
4+
import applesquare.moment.common.dto.PageResponseDTO;
5+
import applesquare.moment.common.exception.ResponseMap;
6+
import applesquare.moment.post.dto.MomentDetailReadAllResponseDTO;
7+
import applesquare.moment.post.service.MomentSearchService;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.http.HttpStatus;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RequestParam;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
import java.util.Map;
17+
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/posts/moments")
21+
public class MomentSearchController {
22+
private final MomentSearchService momentSearchService;
23+
24+
25+
@GetMapping("/search")
26+
public ResponseEntity<Map<String, Object>> searchDetail(@RequestParam(value = "type", required = false, defaultValue = "DETAIL") PostReadType type,
27+
@RequestParam(value = "size", required = false, defaultValue = "10") int size,
28+
@RequestParam(value = "cursor", required = false) String cursor,
29+
@RequestParam(value = "keyword", required = false) String keyword){
30+
// 페이지 요청 설정
31+
PageRequestDTO pageRequestDTO=PageRequestDTO.builder()
32+
.size(size)
33+
.cursor(cursor)
34+
.keyword(keyword)
35+
.build();
36+
37+
// 응답 객체 구성
38+
ResponseMap responseMap=new ResponseMap();
39+
40+
switch(type){
41+
case DETAIL:
42+
// 모먼트 목록 조회
43+
PageResponseDTO<MomentDetailReadAllResponseDTO> pageResponseDTO=momentSearchService.searchDetail(pageRequestDTO);
44+
responseMap.put("content", pageResponseDTO.getContent());
45+
responseMap.put("hasNext", pageResponseDTO.isHasNext());
46+
break;
47+
case THUMBNAIL:
48+
throw new IllegalArgumentException("지원하지 않는 조회 타입입니다. (type="+type+")");
49+
}
50+
51+
responseMap.put("message", "모먼트 검색에 성공했습니다.");
52+
53+
return ResponseEntity.status(HttpStatus.OK).body(responseMap.getMap());
54+
}
55+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package applesquare.moment.post.controller;
2+
3+
import applesquare.moment.common.dto.PageRequestDTO;
4+
import applesquare.moment.common.dto.PageResponseDTO;
5+
import applesquare.moment.common.exception.ResponseMap;
6+
import applesquare.moment.post.dto.PostDetailReadAllResponseDTO;
7+
import applesquare.moment.post.dto.PostThumbnailReadAllResponseDTO;
8+
import applesquare.moment.post.service.PostSearchService;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.HttpStatus;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.GetMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestParam;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
import java.util.Map;
18+
19+
@RestController
20+
@RequiredArgsConstructor
21+
@RequestMapping("/api/posts")
22+
public class PostSearchController {
23+
private final PostSearchService postSearchService;
24+
25+
26+
/**
27+
* 게시물 검색 API
28+
* @param type 게시물 조회 타입
29+
* @param size 페이지 크기
30+
* @param cursor 페이지 커서
31+
* @param keyword 검색 키워드
32+
* @return (status) 200,
33+
* (body) 검색 성공 메세지,
34+
* 게시물 목록
35+
*/
36+
@GetMapping("/search")
37+
public ResponseEntity<Map<String, Object>> searchDetail(@RequestParam(value = "type", required = false, defaultValue = "DETAIL") PostReadType type,
38+
@RequestParam(value = "size", required = false, defaultValue = "10") int size,
39+
@RequestParam(value = "cursor", required = false) String cursor,
40+
@RequestParam(value = "keyword", required = false) String keyword){
41+
// 페이지 요청 설정
42+
PageRequestDTO pageRequestDTO=PageRequestDTO.builder()
43+
.size(size)
44+
.cursor(cursor)
45+
.keyword(keyword)
46+
.build();
47+
48+
// 응답 객체 구성
49+
ResponseMap responseMap=new ResponseMap();
50+
51+
switch (type){
52+
case DETAIL:
53+
// 게시글 세부사항 목록 검색
54+
PageResponseDTO<PostDetailReadAllResponseDTO> detailPageResponseDTO=postSearchService.searchDetail(pageRequestDTO);
55+
responseMap.put("content", detailPageResponseDTO.getContent());
56+
responseMap.put("hasNext", detailPageResponseDTO.isHasNext());
57+
break;
58+
case THUMBNAIL:
59+
// 게시글 썸네일 목록 검색
60+
PageResponseDTO<PostThumbnailReadAllResponseDTO> thumbPageResponseDTO=postSearchService.searchThumbnail(pageRequestDTO);
61+
responseMap.put("content", thumbPageResponseDTO.getContent());
62+
responseMap.put("hasNext", thumbPageResponseDTO.isHasNext());
63+
break;
64+
}
65+
66+
responseMap.put("message", "게시글 검색에 성공했습니다.");
67+
68+
return ResponseEntity.status(HttpStatus.OK).body(responseMap.getMap());
69+
}
70+
}

src/main/java/applesquare/moment/post/dto/PostDetailReadAllResponseDTO.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
import applesquare.moment.file.model.MediaType;
44
import applesquare.moment.user.dto.UserProfileReadResponseDTO;
55
import com.fasterxml.jackson.annotation.JsonFormat;
6-
import lombok.AllArgsConstructor;
7-
import lombok.Builder;
8-
import lombok.Getter;
9-
import lombok.NoArgsConstructor;
6+
import lombok.*;
107

118
import java.time.LocalDateTime;
129
import java.util.List;
1310

1411
@Getter
12+
@Setter
1513
@Builder(toBuilder = true)
1614
@NoArgsConstructor
1715
@AllArgsConstructor
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package applesquare.moment.post.repository;
2+
3+
import java.util.List;
4+
5+
public interface CustomPostRepository {
6+
List<Long> searchPostIdsByKeyword(String keyword, Long cursor, int size);
7+
List<Long> searchMomentIdsByKeyword(String keyword, Long cursor, int size);
8+
}

src/main/java/applesquare/moment/post/repository/PostRepository.java

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import java.util.List;
1212

13-
public interface PostRepository extends JpaRepository<Post, Long> {
13+
public interface PostRepository extends JpaRepository<Post, Long>, CustomPostRepository {
1414
long countByWriterId(String userId);
1515

1616
// 게시물 목록 조회 (커서 페이징)
@@ -31,6 +31,39 @@ public interface PostRepository extends JpaRepository<Post, Long> {
3131
"AND (:cursor IS NULL OR p.id<:cursor)", nativeQuery = true)
3232
List<Tuple> findAllWithFirstFile(@Param("cursor") Long cursor, Pageable pageable);
3333

34+
// 비디오 게시물 목록 조회 (커서 페이징)
35+
@EntityGraph(attributePaths = {"files"})
36+
@Query("SELECT p " +
37+
"FROM Post p " +
38+
"INNER JOIN p.files sf " +
39+
"WHERE sf.contentType LIKE 'video%' " +
40+
"AND (:cursor IS NULL OR p.id<:cursor)")
41+
List<Post> findMomentAll(@Param("cursor") Long cursor,
42+
Pageable pageable);
43+
44+
// ====================================================================
45+
46+
// Post ID 목록으로 게시글 목록 조회
47+
@Query(value = "SELECT * " +
48+
"FROM post p " +
49+
"WHERE p.id IN :postIds " +
50+
"ORDER BY p.id DESC", nativeQuery = true)
51+
List<Post> findAllByPostIds(@Param("postIds") List<Long> postIds);
52+
53+
// Post ID 목록으로 게시물 썸네일 목록 조회
54+
@Query(value = "SELECT p.id AS postId, " +
55+
"sf.filename AS filename, " +
56+
"sf.content_type AS contentType " +
57+
"FROM post p " +
58+
"INNER JOIN post_files pf ON pf.post_id=p.id " +
59+
"INNER JOIN storage_file sf ON pf.file_id=sf.id " +
60+
"WHERE p.id IN :postIds " +
61+
"AND pf.file_order=0 " +
62+
"ORDER BY p.id DESC", nativeQuery = true)
63+
List<Tuple> findAllByPostIdsWithFirstFile(@Param("postIds") List<Long> postIds);
64+
65+
// ====================================================================
66+
3467
// 작성자 ID로 게시물 목록 조회 (커서 페이징)
3568
@EntityGraph(attributePaths = {"files"})
3669
@Query("SELECT p " +
@@ -55,6 +88,35 @@ List<Tuple> findAllWithFirstFileByWriterId(@Param("writerId") String writerId,
5588
@Param("cursor") Long cursor,
5689
Pageable pageable);
5790

91+
// 작성자 ID에 따라 비디오 게시물 목록 조회 (커서 페이징)
92+
@EntityGraph(attributePaths = {"files"})
93+
@Query("SELECT p " +
94+
"FROM Post p " +
95+
"INNER JOIN p.files sf " +
96+
"WHERE p.writer.id=:writerId " +
97+
"AND sf.contentType LIKE 'video%' " +
98+
"AND (:cursor IS NULL OR p.id<:cursor)")
99+
List<Post> findMomentAllByWriterId(@Param("writerId") String writerId,
100+
@Param("cursor") Long cursor,
101+
Pageable pageable);
102+
103+
// 작성자 ID에 따라 비디오 게시물 썸네일 목록 조회
104+
@Query(value = "SELECT p.id AS postId, " +
105+
"p.view_count AS viewCount, " +
106+
"sf.filename AS filename " +
107+
"FROM post p " +
108+
"INNER JOIN post_files pf ON pf.post_id=p.id " +
109+
"INNER JOIN storage_file sf ON pf.file_id=sf.id " +
110+
"WHERE p.writer_id=:writerId " +
111+
"AND (:cursor IS NULL OR p.id<:cursor) " +
112+
"AND pf.file_order=0 " +
113+
"AND sf.content_type LIKE 'video%'", nativeQuery = true)
114+
List<Tuple> findMomentAllWithFirstFileByWriterId(@Param("writerId") String writerId,
115+
@Param("cursor") Long cursor,
116+
Pageable pageable);
117+
118+
// ====================================================================
119+
58120
// 특정 유저가 좋아요 누른 게시물 목록 조회 (커서 페이징)
59121
@EntityGraph(attributePaths = {"files"})
60122
@Query("SELECT p " +
@@ -83,53 +145,16 @@ List<Tuple> findLikedPostAllWithFirstFileByUserId(@Param("userId") String userId
83145
@Param("cursor") Long cursor,
84146
Pageable pageable);
85147

86-
// 비디오 게시물 목록 조회 (커서 페이징)
87-
@EntityGraph(attributePaths = {"files"})
88-
@Query("SELECT p " +
89-
"FROM Post p " +
90-
"INNER JOIN p.files sf " +
91-
"WHERE sf.contentType LIKE 'video%' " +
92-
"AND (:cursor IS NULL OR p.id<:cursor)")
93-
List<Post> findMomentAll(@Param("cursor") Long cursor,
94-
Pageable pageable);
95-
96-
// 작성자 ID에 따라 비디오 게시물 목록 조회 (커서 페이징)
97-
@EntityGraph(attributePaths = {"files"})
98-
@Query("SELECT p " +
99-
"FROM Post p " +
100-
"INNER JOIN p.files sf " +
101-
"WHERE p.writer.id=:writerId " +
102-
"AND sf.contentType LIKE 'video%' " +
103-
"AND (:cursor IS NULL OR p.id<:cursor)")
104-
List<Post> findMomentAllByWriterId(@Param("writerId") String writerId,
105-
@Param("cursor") Long cursor,
106-
Pageable pageable);
107-
108-
// 작성자 ID에 따라 비디오 게시물 썸네일 목록 조회
109-
@Query(value = "SELECT p.id AS postId, " +
110-
"p.view_count AS viewCount, " +
111-
"sf.filename AS filename " +
112-
"FROM post p " +
113-
"INNER JOIN post_files pf ON pf.post_id=p.id " +
114-
"INNER JOIN storage_file sf ON pf.file_id=sf.id " +
115-
"WHERE p.writer_id=:writerId " +
116-
"AND (:cursor IS NULL OR p.id<:cursor) " +
117-
"AND pf.file_order=0 " +
118-
"AND sf.content_type LIKE 'video%'", nativeQuery = true)
119-
List<Tuple> findMomentAllWithFirstFileByWriterId(@Param("writerId") String writerId,
120-
@Param("cursor") Long cursor,
121-
Pageable pageable);
122-
123148
// 특정 유저가 좋아요 누른 비디오 게시물 목록 조회 (커서 페이징)
124149
@EntityGraph(attributePaths = {"files"})
125150
@Query("SELECT p " +
126151
"FROM Post p " +
127152
"INNER JOIN PostLike pl ON p.id=pl.postId " +
128153
"INNER JOIN p.files sf " +
129154
"WHERE pl.userId=:userId " +
130-
"AND (:cursor IS NULL OR p.id<:cursor) " +
131-
"AND sf.contentType LIKE 'video%'")
155+
"AND (:cursor IS NULL OR p.id<:cursor) " +
156+
"AND sf.contentType LIKE 'video%'")
132157
List<Post> findLikedMomentAllByUserId(@Param("userId") String userId,
133-
@Param("cursor") Long cursor,
134-
Pageable pageable);
158+
@Param("cursor") Long cursor,
159+
Pageable pageable);
135160
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package applesquare.moment.post.repository.impl;
2+
3+
import applesquare.moment.post.repository.CustomPostRepository;
4+
import com.querydsl.core.types.dsl.BooleanExpression;
5+
import com.querydsl.jpa.impl.JPAQueryFactory;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.transaction.annotation.Transactional;
8+
9+
import java.util.List;
10+
11+
import static applesquare.moment.file.model.QStorageFile.storageFile;
12+
import static applesquare.moment.post.model.QPost.post;
13+
import static applesquare.moment.tag.model.QTag.tag;
14+
import static applesquare.moment.user.model.QUserInfo.userInfo;
15+
16+
@Transactional
17+
@RequiredArgsConstructor
18+
public class CustomPostRepositoryImpl implements CustomPostRepository {
19+
private final JPAQueryFactory queryFactory;
20+
21+
/**
22+
* 키워드로 게시물 검색
23+
* - 검색 속성 : 게시물 내용, 작성자 닉네임, 태그명
24+
* - 정렬 기준 : 최신순
25+
*
26+
* @param keyword 검색 키워드
27+
* @param cursor 페이지 커서
28+
* @param size 페이지 크기
29+
* @return 검색 조건에 부합하는 Post ID 목록
30+
*/
31+
@Override
32+
public List<Long> searchPostIdsByKeyword(String keyword, Long cursor, int size){
33+
// 검색 조건에 부합하는 게시물 ID 조회
34+
BooleanExpression cursorCondition=null;
35+
if(cursor!=null){
36+
cursorCondition=post.id.lt(cursor);
37+
}
38+
BooleanExpression keywordCondition=post.content.contains(keyword)
39+
.or(userInfo.nickname.contains(keyword))
40+
.or(tag.name.contains(keyword));
41+
42+
return queryFactory
43+
.selectDistinct(post.id)
44+
.from(post)
45+
.leftJoin(post.tags, tag)
46+
.leftJoin(post.writer, userInfo)
47+
.where(keywordCondition.and(cursorCondition))
48+
.orderBy(post.id.desc())
49+
.limit(size)
50+
.fetch();
51+
}
52+
53+
/**
54+
* 키워드로 모먼트 검색
55+
* - 검색 속성 : 게시물 내용, 작성자 닉네임, 태그명
56+
* - 정렬 기준 : 최신순
57+
*
58+
* @param keyword 검색 키워드
59+
* @param cursor 페이지 커서
60+
* @param size 페이지 크기
61+
* @return 검색 조건에 부합하는 모먼트 목록
62+
*/
63+
@Override
64+
public List<Long> searchMomentIdsByKeyword(String keyword, Long cursor, int size){
65+
// 검색 조건에 부합하는 게시물 ID 조회
66+
BooleanExpression cursorCondition=null;
67+
if(cursor!=null){
68+
cursorCondition=post.id.lt(cursor);
69+
}
70+
71+
return queryFactory
72+
.selectDistinct(post.id)
73+
.from(post)
74+
.leftJoin(post.tags, tag)
75+
.leftJoin(post.files, storageFile)
76+
.leftJoin(post.writer, userInfo)
77+
.where(storageFile.contentType.startsWith("video")
78+
.and(
79+
post.content.contains(keyword)
80+
.or(userInfo.nickname.contains(keyword))
81+
.or(tag.name.contains(keyword))
82+
)
83+
.and(cursorCondition)
84+
)
85+
.orderBy(post.id.desc())
86+
.limit(size)
87+
.fetch();
88+
}
89+
}

0 commit comments

Comments
 (0)