From d501aaa6ff15d9187d3adcc711b51db669601a3e Mon Sep 17 00:00:00 2001 From: Minjae Chung Date: Sun, 22 Feb 2026 18:44:18 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B0=8F=20search=5Flogs=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/config/SecurityConfig.java | 1 + .../repository/ComposerRepository.java | 3 + .../post/repository/PostRepository.java | 4 + .../search/application/SearchService.java | 47 ++++++++++ .../server/search/domain/SearchLog.java | 42 +++++++++ .../server/search/dto/SearchResponseDto.java | 45 ++++++++++ .../search/presentation/SearchController.java | 26 ++++++ .../repository/SearchLogRepository.java | 9 ++ .../db/migration/V8__create_search_logs.sql | 6 ++ .../presentation/SearchControllerTest.java | 89 +++++++++++++++++++ 10 files changed, 272 insertions(+) create mode 100644 src/main/java/com/daramg/server/search/application/SearchService.java create mode 100644 src/main/java/com/daramg/server/search/domain/SearchLog.java create mode 100644 src/main/java/com/daramg/server/search/dto/SearchResponseDto.java create mode 100644 src/main/java/com/daramg/server/search/presentation/SearchController.java create mode 100644 src/main/java/com/daramg/server/search/repository/SearchLogRepository.java create mode 100644 src/main/resources/db/migration/V8__create_search_logs.sql create mode 100644 src/test/java/com/daramg/server/search/presentation/SearchControllerTest.java diff --git a/src/main/java/com/daramg/server/auth/config/SecurityConfig.java b/src/main/java/com/daramg/server/auth/config/SecurityConfig.java index c879ef0..9594d20 100644 --- a/src/main/java/com/daramg/server/auth/config/SecurityConfig.java +++ b/src/main/java/com/daramg/server/auth/config/SecurityConfig.java @@ -52,6 +52,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/users/check-nickname").permitAll() .requestMatchers("/composers/{composerId}/posts").permitAll() .requestMatchers(HttpMethod.GET, "/notice/**").permitAll() + .requestMatchers(HttpMethod.GET, "/search").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/docs/**").permitAll() .requestMatchers("/h2-console/**").permitAll() // 추가: h2 db 접근 diff --git a/src/main/java/com/daramg/server/composer/repository/ComposerRepository.java b/src/main/java/com/daramg/server/composer/repository/ComposerRepository.java index b1644ab..1e0415a 100644 --- a/src/main/java/com/daramg/server/composer/repository/ComposerRepository.java +++ b/src/main/java/com/daramg/server/composer/repository/ComposerRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ComposerRepository extends JpaRepository { + List findByKoreanNameContainingOrEnglishNameContaining(String koreanKeyword, String englishKeyword); } diff --git a/src/main/java/com/daramg/server/post/repository/PostRepository.java b/src/main/java/com/daramg/server/post/repository/PostRepository.java index 750f389..2536f6e 100644 --- a/src/main/java/com/daramg/server/post/repository/PostRepository.java +++ b/src/main/java/com/daramg/server/post/repository/PostRepository.java @@ -1,6 +1,7 @@ package com.daramg.server.post.repository; import com.daramg.server.post.domain.Post; +import com.daramg.server.post.domain.PostStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,9 +9,12 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.List; @Repository public interface PostRepository extends JpaRepository { + List findByTitleContainingAndPostStatusAndIsBlockedFalse(String keyword, PostStatus postStatus); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" update Post p diff --git a/src/main/java/com/daramg/server/search/application/SearchService.java b/src/main/java/com/daramg/server/search/application/SearchService.java new file mode 100644 index 0000000..5bf2e1f --- /dev/null +++ b/src/main/java/com/daramg/server/search/application/SearchService.java @@ -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 composers = composerRepository + .findByKoreanNameContainingOrEnglishNameContaining(keyword, keyword); + + List 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()); + } +} diff --git a/src/main/java/com/daramg/server/search/domain/SearchLog.java b/src/main/java/com/daramg/server/search/domain/SearchLog.java new file mode 100644 index 0000000..fbeede2 --- /dev/null +++ b/src/main/java/com/daramg/server/search/domain/SearchLog.java @@ -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(); + } + + public static SearchLog of(String keyword, Long userId) { + return new SearchLog(keyword, userId); + } +} diff --git a/src/main/java/com/daramg/server/search/dto/SearchResponseDto.java b/src/main/java/com/daramg/server/search/dto/SearchResponseDto.java new file mode 100644 index 0000000..df17c48 --- /dev/null +++ b/src/main/java/com/daramg/server/search/dto/SearchResponseDto.java @@ -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 composers, + List 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 + ) { + public static PostResult from(Post post, PostType type) { + return new PostResult( + post.getId(), + post.getTitle(), + type, + post.getUser().getNickname(), + post.getCreatedAt() + ); + } + } +} diff --git a/src/main/java/com/daramg/server/search/presentation/SearchController.java b/src/main/java/com/daramg/server/search/presentation/SearchController.java new file mode 100644 index 0000000..f7abbe6 --- /dev/null +++ b/src/main/java/com/daramg/server/search/presentation/SearchController.java @@ -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); + } +} diff --git a/src/main/java/com/daramg/server/search/repository/SearchLogRepository.java b/src/main/java/com/daramg/server/search/repository/SearchLogRepository.java new file mode 100644 index 0000000..9d923f9 --- /dev/null +++ b/src/main/java/com/daramg/server/search/repository/SearchLogRepository.java @@ -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 { +} diff --git a/src/main/resources/db/migration/V8__create_search_logs.sql b/src/main/resources/db/migration/V8__create_search_logs.sql new file mode 100644 index 0000000..91d35ff --- /dev/null +++ b/src/main/resources/db/migration/V8__create_search_logs.sql @@ -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 +); diff --git a/src/test/java/com/daramg/server/search/presentation/SearchControllerTest.java b/src/test/java/com/daramg/server/search/presentation/SearchControllerTest.java new file mode 100644 index 0000000..2a9ca41 --- /dev/null +++ b/src/test/java/com/daramg/server/search/presentation/SearchControllerTest.java @@ -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() + ) + )); + } +}