Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 접근
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Composer, Long> {
List<Composer> findByKoreanNameContainingOrEnglishNameContaining(String koreanKeyword, String englishKeyword);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

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

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByTitleContainingAndPostStatusAndIsBlockedFalse(String keyword, PostStatus postStatus);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Post p
Expand Down
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());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resolvePostType method uses instanceof checks, which can become cumbersome and less maintainable as more Post subtypes are introduced. Consider adding an abstract getPostType() method to the Post class (or an interface) that each subclass implements to return its specific type. This would make the resolvePostType method simpler and more extensible.

Suggested change
throw new IllegalStateException("Unknown post type: " + post.getClass());
throw new IllegalStateException("Unknown post type: " + post.getClass().getSimpleName());

}
}
42 changes: 42 additions & 0 deletions src/main/java/com/daramg/server/search/domain/SearchLog.java
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onCreate method sets searchedAt using LocalDateTime.now(). While this works, it's generally better practice to use Spring Data JPA's @CreatedDate annotation from AuditingEntityListener for automatic timestamping. This centralizes auditing concerns and reduces boilerplate code.

Suggested change
protected void onCreate() {
this.searchedAt = LocalDateTime.now();
}
@CreatedDate
@Column(name = "searched_at", nullable = false, updatable = false)
private LocalDateTime searchedAt;


public static SearchLog of(String keyword, Long userId) {
return new SearchLog(keyword, userId);
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/daramg/server/search/dto/SearchResponseDto.java
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The writerNickname field in PostResult directly exposes the user's nickname. While this might be acceptable for public search results, ensure that there are no privacy concerns with exposing this information. If the user can delete their account or change their nickname, this might lead to inconsistencies or broken references in historical search results if not handled carefully (e.g., by making writerNickname nullable or using a default value if the user no longer exists).

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> {
}
6 changes: 6 additions & 0 deletions src/main/resources/db/migration/V8__create_search_logs.sql
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()
)
));
}
}
Loading