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 @@ -2,6 +2,8 @@

import com.example.booklog.domain.library.books.entity.Books;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
Expand Down Expand Up @@ -58,6 +60,45 @@ public interface BooksRepository extends JpaRepository<Books, Long> {
"JOIN b.bookAuthors ba " +
"WHERE ba.author.id IN :authorIds")
long countBooksByAuthorIds(@Param("authorIds") List<Long> authorIds);

/**
* 도서 제목으로 검색 (페이징 및 정렬 지원)
*
* [정렬 옵션]
* - LATEST: publishedDate DESC, id DESC
* - OLDEST: publishedDate ASC, id ASC
* - TITLE: title ASC
* - AUTHOR: 첫 번째 저자명 ASC (BookAuthors.authorOrder = 1)
*
* @param keyword 검색 키워드 (제목 LIKE 검색)
* @param pageable 페이징 및 정렬 정보
* @return 도서 페이지
*/
@Query("""
SELECT DISTINCT b FROM Books b
LEFT JOIN FETCH b.bookAuthors ba
LEFT JOIN FETCH ba.author a
WHERE b.title LIKE %:keyword%
""")
@QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false"))
Page<Books> searchByTitle(@Param("keyword") String keyword, Pageable pageable);

/**
* 도서 제목 또는 저자명으로 검색 (페이징 및 정렬 지원)
*
* @param keyword 검색 키워드
* @param pageable 페이징 및 정렬 정보
* @return 도서 페이지
*/
@Query("""
SELECT DISTINCT b FROM Books b
LEFT JOIN FETCH b.bookAuthors ba
LEFT JOIN FETCH ba.author a
WHERE b.title LIKE %:keyword%
OR a.name LIKE %:keyword%
""")
@QueryHints(@QueryHint(name = "hibernate.query.passDistinctThrough", value = "false"))
Page<Books> searchByTitleOrAuthor(@Param("keyword") String keyword, Pageable pageable);
}


Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
package com.example.booklog.domain.search.controller;

import com.example.booklog.domain.library.books.dto.BookSearchResponse;
import com.example.booklog.domain.search.dto.AuthorSearchResponse;
import com.example.booklog.domain.search.dto.*;
import com.example.booklog.domain.search.service.AuthorSearchService;
import com.example.booklog.domain.search.service.BookSearchService;
import com.example.booklog.domain.search.service.IntegratedSearchService;
import com.example.booklog.domain.search.service.SearchKeywordService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.TimeUnit;

/**
* 통합 검색 API 컨트롤러
* - 통합 검색: /api/v1/search (GET)
* - 도서 검색: /api/v1/search/books
* - 작가 검색: /api/v1/search/authors
* - 통합 검색: /api/v1/search (추후 구현)
* - 검색어 저장: /api/v1/search/keywords (POST)
* - 최근 검색어 조회: /api/v1/search/recent (GET)
* - 추천 검색어 조회: /api/v1/search/recommendations (GET)
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/search")
public class SearchController {

private final IntegratedSearchService integratedSearchService;
private final BookSearchService bookSearchService;
private final AuthorSearchService authorSearchService;
private final SearchKeywordService searchKeywordService;

/**
* 도서 검색
* GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기}
* GET /api/v1/search/books?query={검색어}&page={페이지}&size={크기}&sort={정렬}
*
* [정렬 옵션]
* - latest: 최신순 (출판일 내림차순) - 기본값
* - oldest: 오래된순 (출판일 오름차순)
* - title: 제목순 (가나다순)
* - author: 저자순 (첫 번째 저자 기준 가나다순)
*
* @param query 검색어 (필수)
* @param page 페이지 번호 (1부터 시작, 기본값: 1)
* @param size 페이지 크기 (기본값: 10)
* @param sort 정렬 기준 (기본값: latest)
* @return 도서 검색 결과
*/
@GetMapping("/books")
public BookSearchResponse searchBooks(
@RequestParam String query,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "latest") String sort
) {
return bookSearchService.searchBooks(query, page, size);
BookSortType sortType = BookSortType.from(sort);
return bookSearchService.searchBooks(query, page, size, sortType);
}

/**
Expand All @@ -53,27 +79,112 @@ public AuthorSearchResponse searchAuthors(
}

/**
* 통합 검색 (추후 구현)
* GET /api/v1/search?query={검색어}&page={페이지}&size={크기}
* 검색어 저장 API
* POST /api/v1/search/keywords
*
* [호출 시점]
* - 검색 실행 시
* - 추천 검색어 클릭 시
* - 최근 검색어 클릭 시
*
* [동작 방식]
* - 동일 검색어가 이미 존재하면 삭제 후 재생성 (최신순 유지)
* - 최대 10개 제한, 초과 시 가장 오래된 검색어 삭제
*
* @param userId 사용자 ID (임시로 @RequestParam 사용, 추후 인증 적용 시 @AuthenticationPrincipal 등으로 변경)
* @param request 검색어 저장 요청
*/
// TODO: 통합 검색 구현
// @GetMapping
// public IntegratedSearchResponse search(...)
@PostMapping("/keywords")
@ResponseStatus(HttpStatus.CREATED)
public void saveSearchKeyword(
@RequestParam Long userId, // TODO: 인증 구현 후 변경 필요
@RequestBody SearchKeywordSaveRequest request
) {
searchKeywordService.saveSearchKeyword(userId, request.getKeyword());
}

/**
* 최근 검색어 조회 (추후 구현)
* 최근 검색어 조회 API
* GET /api/v1/search/recent
*
* [호출 시점]
* - 검색 화면 진입 시 (검색창이 비어 있을 때)
*
* [응답 데이터]
* - 사용자가 이전에 검색 실행을 통해 입력한 검색어 목록
* - 최신순 정렬
* - 최대 10개
*
* @param userId 사용자 ID (임시로 @RequestParam 사용, 추후 인증 적용 시 변경)
* @return 최근 검색어 목록
*/
// TODO: 최근 검색어 구현
// @GetMapping("/recent")
// public RecentSearchResponse getRecentSearches()
@GetMapping("/recent")
public RecentSearchResponse getRecentSearches(
@RequestParam Long userId // TODO: 인증 구현 후 변경 필요
) {
return searchKeywordService.getRecentSearches(userId);
}

/**
* 추천 검색어 조회 (추후 구현)
* 추천 검색어 조회 API
* GET /api/v1/search/recommendations
*
* [호출 시점]
* - 검색 화면 진입 시 (검색창이 비어 있을 때)
*
* [응답 데이터]
* - 운영자가 관리하는 추천 검색어 목록
* - 우선순위 순으로 정렬
* - 로그인 여부와 무관하게 사용 가능
*
* @return 추천 검색어 목록
*/
@GetMapping("/recommendations")
public RecommendationSearchResponse getRecommendations() {
return searchKeywordService.getRecommendations();
}

/**
* 통합 검색 API
* GET /api/v1/search?query={검색어}&sort={정렬기준}
*
* [기능 설명]
* - 검색어에 대해 "작가"와 "도서"를 통합하여 조회
* - 전체 탭에서 사용되며, 각 영역별로 제한된 개수(5개)만 표시
* - 더 많은 결과를 보려면 개별 탭(/books, /authors)으로 이동
*
* [응답 구조]
* - authors: 작가 검색 결과 (요약 정보, 최대 5명)
* - books: 도서 검색 결과 (기본 정보, 최대 5권)
* - 각 영역의 totalCount로 전체 개수 파악 가능
*
* [캐시 정책]
* - Cache-Control: max-age=60 (60초)
* - 동일한 query + sort 조합에 대해 클라이언트 캐시 활용
*
* [경계 케이스]
* - 검색어가 없거나 공백인 경우: 400 Bad Request
* - 검색 결과가 없는 경우: 빈 배열과 totalCount=0 반환
* - 정렬 기준이 유효하지 않은 경우: 400 Bad Request
*
* @param query 검색어 (필수, 1~100자)
* @param sort 정렬 기준 (선택, latest|popular, 기본값: latest)
* @return 통합 검색 응답 (작가 + 도서)
*/
// TODO: 추천 검색어 구현
// @GetMapping("/recommendations")
// public RecommendationSearchResponse getRecommendations()
@GetMapping
public ResponseEntity<IntegratedSearchResponse> search(
@RequestParam String query,
@RequestParam(defaultValue = "latest") String sort
) {
IntegratedSearchResponse response = integratedSearchService.search(query, sort);

// 캐시 헤더 설정 (60초)
CacheControl cacheControl = CacheControl.maxAge(60, TimeUnit.SECONDS)
.cachePublic();

return ResponseEntity.ok()
.cacheControl(cacheControl)
.body(response);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.example.booklog.domain.search.dto;

import java.util.List;

/**
* 통합 검색 응답 내 작가 검색 결과
*
* [설계 근거]
* - 전체 검색 탭에서는 작가의 요약 정보만 필요 (책 목록 불포함)
* - totalCount를 통해 "작가 결과 n명" 표시 및 "더보기" 판단
* - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출)
*
* @param totalCount 총 검색된 작가 수
* @param items 작가 요약 정보 리스트 (최대 5개 등 제한)
*/
public record AuthorSearchResult(
int totalCount,
List<AuthorSummary> items
) {
/**
* AuthorSearchResponse로부터 변환
* 작가 검색 결과에서 책 목록을 제외한 요약 정보만 추출
*
* @param response 작가 검색 응답
* @return 작가 검색 결과 (요약)
*/
public static AuthorSearchResult from(AuthorSearchResponse response) {
List<AuthorSummary> summaries = response.items().stream()
.map(AuthorSummary::from)
.toList();

return new AuthorSearchResult(
response.totalCount(),
summaries
);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.example.booklog.domain.search.dto;

/**
* 통합 검색 응답에서 사용되는 작가 요약 정보
*
* [설계 근거]
* - 전체 탭에서는 작가의 대표작 목록이 필요 없음
* - UI 표시에 필요한 최소한의 정보만 포함 (이름, 프로필 이미지, 직업, 국적)
* - 작가 상세 페이지로 이동하기 위한 authorId 포함
*
* @param authorId 작가 ID
* @param name 작가명
* @param profileImageUrl 프로필 이미지 URL
* @param occupation 직업 (예: 소설가, 시인)
* @param nationality 국적
*/
public record AuthorSummary(
Long authorId,
String name,
String profileImageUrl,
String occupation,
String nationality
) {
/**
* AuthorSearchItemResponse로부터 변환
* 책 목록을 제외한 작가 정보만 추출
*
* @param item 작가 검색 아이템
* @return 작가 요약 정보
*/
public static AuthorSummary from(AuthorSearchItemResponse item) {
return new AuthorSummary(
item.authorId(),
item.name(),
item.profileImageUrl(),
item.occupation(),
item.nationality()
);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.booklog.domain.search.dto;

import com.example.booklog.domain.library.books.dto.BookSearchItemResponse;
import com.example.booklog.domain.library.books.dto.BookSearchResponse;

import java.util.List;

/**
* 통합 검색 응답 내 도서 검색 결과
*
* [설계 근거]
* - 전체 검색 탭에서는 도서의 기본 정보만 노출
* - totalCount를 통해 "도서 결과 n권" 표시 및 "더보기" 판단
* - 페이징 정보는 제외 (전체 탭에서는 제한된 개수만 노출)
*
* @param totalCount 총 검색된 도서 수
* @param items 도서 정보 리스트 (최대 5개 등 제한)
*/
public record BookSearchResult(
int totalCount,
List<BookSearchItemResponse> items
) {
/**
* BookSearchResponse로부터 변환
*
* @param response 도서 검색 응답
* @return 도서 검색 결과
*/
public static BookSearchResult from(BookSearchResponse response) {
return new BookSearchResult(
response.totalCount(),
response.items()
);
}
}

Loading