-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 키워드 검색 및 search_logs 저장 기능 추가 #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.daramg.server.search.application; | ||
|
|
||
| import com.daramg.server.composer.domain.Composer; | ||
| import com.daramg.server.composer.repository.ComposerRepository; | ||
| import com.daramg.server.post.domain.*; | ||
| import com.daramg.server.post.repository.PostRepository; | ||
| import com.daramg.server.search.domain.SearchLog; | ||
| import com.daramg.server.search.dto.SearchResponseDto; | ||
| import com.daramg.server.search.repository.SearchLogRepository; | ||
| import com.daramg.server.user.domain.User; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional | ||
| public class SearchService { | ||
|
|
||
| private final ComposerRepository composerRepository; | ||
| private final PostRepository postRepository; | ||
| private final SearchLogRepository searchLogRepository; | ||
|
|
||
| public SearchResponseDto search(String keyword, User user) { | ||
| searchLogRepository.save(SearchLog.of(keyword, user != null ? user.getId() : null)); | ||
|
|
||
| List<Composer> composers = composerRepository | ||
| .findByKoreanNameContainingOrEnglishNameContaining(keyword, keyword); | ||
|
|
||
| List<Post> posts = postRepository | ||
| .findByTitleContainingAndPostStatusAndIsBlockedFalse(keyword, PostStatus.PUBLISHED); | ||
|
|
||
| return new SearchResponseDto( | ||
| composers.stream().map(SearchResponseDto.ComposerResult::from).toList(), | ||
| posts.stream().map(p -> SearchResponseDto.PostResult.from(p, resolvePostType(p))).toList() | ||
| ); | ||
| } | ||
|
|
||
| private PostType resolvePostType(Post post) { | ||
| if (post instanceof StoryPost) return PostType.STORY; | ||
| if (post instanceof FreePost) return PostType.FREE; | ||
| if (post instanceof CurationPost) return PostType.CURATION; | ||
| throw new IllegalStateException("Unknown post type: " + post.getClass()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||||||||||
| package com.daramg.server.search.domain; | ||||||||||||||
|
|
||||||||||||||
| import jakarta.persistence.*; | ||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||
| import lombok.Getter; | ||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||
|
|
||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||
|
|
||||||||||||||
| @Entity | ||||||||||||||
| @Getter | ||||||||||||||
| @Table(name = "search_logs") | ||||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||||
| public class SearchLog { | ||||||||||||||
|
|
||||||||||||||
| @Id | ||||||||||||||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||||||||||||||
| private Long id; | ||||||||||||||
|
|
||||||||||||||
| @Column(name = "keyword", nullable = false) | ||||||||||||||
| private String keyword; | ||||||||||||||
|
|
||||||||||||||
| @Column(name = "searched_at", nullable = false) | ||||||||||||||
| private LocalDateTime searchedAt; | ||||||||||||||
|
|
||||||||||||||
| @Column(name = "user_id") | ||||||||||||||
| private Long userId; | ||||||||||||||
|
|
||||||||||||||
| private SearchLog(String keyword, Long userId) { | ||||||||||||||
| this.keyword = keyword; | ||||||||||||||
| this.userId = userId; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| @PrePersist | ||||||||||||||
| protected void onCreate() { | ||||||||||||||
| this.searchedAt = LocalDateTime.now(); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+35
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| public static SearchLog of(String keyword, Long userId) { | ||||||||||||||
| return new SearchLog(keyword, userId); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.daramg.server.search.dto; | ||
|
|
||
| import com.daramg.server.composer.domain.Composer; | ||
| import com.daramg.server.post.domain.Post; | ||
| import com.daramg.server.post.domain.PostType; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| public record SearchResponseDto( | ||
| List<ComposerResult> composers, | ||
| List<PostResult> posts | ||
| ) { | ||
| public record ComposerResult( | ||
| Long id, | ||
| String koreanName, | ||
| String englishName | ||
| ) { | ||
| public static ComposerResult from(Composer composer) { | ||
| return new ComposerResult( | ||
| composer.getId(), | ||
| composer.getKoreanName(), | ||
| composer.getEnglishName() | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| public record PostResult( | ||
| Long id, | ||
| String title, | ||
| PostType type, | ||
| String writerNickname, | ||
| LocalDateTime createdAt | ||
| ) { | ||
|
Comment on lines
+33
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| public static PostResult from(Post post, PostType type) { | ||
| return new PostResult( | ||
| post.getId(), | ||
| post.getTitle(), | ||
| type, | ||
| post.getUser().getNickname(), | ||
| post.getCreatedAt() | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.daramg.server.search.presentation; | ||
|
|
||
| import com.daramg.server.search.application.SearchService; | ||
| import com.daramg.server.search.dto.SearchResponseDto; | ||
| import com.daramg.server.user.domain.User; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/search") | ||
| public class SearchController { | ||
|
|
||
| private final SearchService searchService; | ||
|
|
||
| @GetMapping | ||
| @ResponseStatus(HttpStatus.OK) | ||
| public SearchResponseDto search( | ||
| @RequestParam String keyword, | ||
| @AuthenticationPrincipal User user | ||
| ) { | ||
| return searchService.search(keyword, user); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.daramg.server.search.repository; | ||
|
|
||
| import com.daramg.server.search.domain.SearchLog; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| @Repository | ||
| public interface SearchLogRepository extends JpaRepository<SearchLog, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| CREATE TABLE search_logs ( | ||
| id BIGINT AUTO_INCREMENT PRIMARY KEY, | ||
| keyword VARCHAR(255) NOT NULL, | ||
| searched_at DATETIME NOT NULL, | ||
| user_id BIGINT NULL | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package com.daramg.server.search.presentation; | ||
|
|
||
| import com.daramg.server.post.domain.PostType; | ||
| import com.daramg.server.search.application.SearchService; | ||
| import com.daramg.server.search.dto.SearchResponseDto; | ||
| import com.daramg.server.testsupport.support.ControllerTestSupport; | ||
| import com.epages.restdocs.apispec.ResourceSnippetParameters; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.restdocs.payload.JsonFieldType; | ||
| import org.springframework.test.context.bean.override.mockito.MockitoBean; | ||
| import org.springframework.test.web.servlet.ResultActions; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; | ||
| import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; | ||
| import static com.epages.restdocs.apispec.ResourceDocumentation.resource; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.ArgumentMatchers.eq; | ||
| import static org.mockito.Mockito.when; | ||
| import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; | ||
| import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
|
|
||
| @WebMvcTest(SearchController.class) | ||
| public class SearchControllerTest extends ControllerTestSupport { | ||
|
|
||
| @MockitoBean | ||
| private SearchService searchService; | ||
|
|
||
| @Test | ||
| void 키워드로_작곡가와_게시글을_검색한다() throws Exception { | ||
| // given | ||
| String keyword = "모차르트"; | ||
|
|
||
| SearchResponseDto.ComposerResult composer = new SearchResponseDto.ComposerResult( | ||
| 1L, | ||
| "볼프강 아마데우스 모차르트", | ||
| "Wolfgang Amadeus Mozart" | ||
| ); | ||
|
|
||
| SearchResponseDto.PostResult post = new SearchResponseDto.PostResult( | ||
| 10L, | ||
| "모차르트의 피아노 협주곡", | ||
| PostType.STORY, | ||
| "작성자닉네임", | ||
| LocalDateTime.of(2024, 3, 1, 12, 0, 0) | ||
| ); | ||
|
|
||
| SearchResponseDto response = new SearchResponseDto(List.of(composer), List.of(post)); | ||
|
|
||
| when(searchService.search(eq(keyword), any())).thenReturn(response); | ||
|
|
||
| // when | ||
| ResultActions result = mockMvc.perform(get("/search") | ||
| .param("keyword", keyword) | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| ); | ||
|
|
||
| // then | ||
| result.andExpect(status().isOk()) | ||
| .andDo(document("검색", | ||
| resource(ResourceSnippetParameters.builder() | ||
| .tag("Search API") | ||
| .summary("키워드 검색") | ||
| .description("키워드로 작곡가와 게시글을 검색하고, 검색 기록을 저장합니다.") | ||
| .queryParameters( | ||
| parameterWithName("keyword").description("검색 키워드") | ||
| ) | ||
| .responseFields( | ||
| fieldWithPath("composers").type(JsonFieldType.ARRAY).description("검색된 작곡가 목록"), | ||
| fieldWithPath("composers[].id").type(JsonFieldType.NUMBER).description("작곡가 ID"), | ||
| fieldWithPath("composers[].koreanName").type(JsonFieldType.STRING).description("작곡가 한국어 이름"), | ||
| fieldWithPath("composers[].englishName").type(JsonFieldType.STRING).description("작곡가 영어 이름"), | ||
| fieldWithPath("posts").type(JsonFieldType.ARRAY).description("검색된 게시글 목록"), | ||
| fieldWithPath("posts[].id").type(JsonFieldType.NUMBER).description("게시글 ID"), | ||
| fieldWithPath("posts[].title").type(JsonFieldType.STRING).description("게시글 제목"), | ||
| fieldWithPath("posts[].type").type(JsonFieldType.STRING).description("게시글 타입 (FREE, CURATION, STORY)"), | ||
| fieldWithPath("posts[].writerNickname").type(JsonFieldType.STRING).description("작성자 닉네임"), | ||
| fieldWithPath("posts[].createdAt").type(JsonFieldType.STRING).description("게시글 작성 시각") | ||
| ) | ||
| .build() | ||
| ) | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
resolvePostTypemethod usesinstanceofchecks, which can become cumbersome and less maintainable as morePostsubtypes are introduced. Consider adding an abstractgetPostType()method to thePostclass (or an interface) that each subclass implements to return its specific type. This would make theresolvePostTypemethod simpler and more extensible.