diff --git a/src/main/kotlin/com/stepbookstep/server/domain/book/application/BookQueryService.kt b/src/main/kotlin/com/stepbookstep/server/domain/book/application/BookQueryService.kt index d249035..dff09e0 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/book/application/BookQueryService.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/book/application/BookQueryService.kt @@ -74,24 +74,28 @@ class BookQueryService( fun filter( level: Int?, pageRange: String?, - origin: String?, - genre: String?, + origins: List?, + genres: List?, keyword: String?, cursor: Long? ): BookFilterResponse { + // 쉼표로 구분된 문자열도 지원 + val parsedOrigins = origins?.flatMap { it.split(",") }?.map { it.trim() }?.filter { it.isNotBlank() } + val parsedGenres = genres?.flatMap { it.split(",") }?.map { it.trim() }?.filter { it.isNotBlank() } + // 필터 없이 검색어만 입력한 경우 예외 처리 - val hasFilter = level != null || pageRange != null || origin != null || genre != null + val hasFilter = level != null || pageRange != null || !parsedOrigins.isNullOrEmpty() || !parsedGenres.isNullOrEmpty() if (!hasFilter && !keyword.isNullOrBlank()) { throw CustomException(ErrorCode.FILTER_REQUIRED, null) } // 유효성 검증 - validateFilterParams(level, pageRange, origin, genre) + validateFilterParams(level, pageRange, parsedOrigins, parsedGenres) val spec = Specification.where(BookSpecification.withLevel(level)) .and(BookSpecification.withPageRange(pageRange)) - .and(BookSpecification.withOrigin(origin)) - .and(BookSpecification.withGenre(genre)) + .and(BookSpecification.withOrigins(parsedOrigins)) + .and(BookSpecification.withGenres(parsedGenres)) .and(BookSpecification.withKeyword(keyword)) .and(BookSpecification.withCursor(cursor)) @@ -111,8 +115,8 @@ class BookQueryService( private fun validateFilterParams( level: Int?, pageRange: String?, - origin: String?, - genre: String? + origins: List?, + genres: List? ) { if (level != null && level !in VALID_LEVELS) { throw CustomException(ErrorCode.INVALID_DIFFICULTY, null) @@ -120,11 +124,15 @@ class BookQueryService( if (pageRange != null && pageRange !in VALID_PAGE_RANGES) { throw CustomException(ErrorCode.INVALID_PAGE_RANGE, null) } - if (origin != null && origin !in VALID_ORIGINS) { - throw CustomException(ErrorCode.INVALID_ORIGIN, null) + origins?.forEach { origin -> + if (origin !in VALID_ORIGINS) { + throw CustomException(ErrorCode.INVALID_ORIGIN, null) + } } - if (genre != null && genre !in VALID_GENRES) { - throw CustomException(ErrorCode.INVALID_GENRE, null) + genres?.forEach { genre -> + if (genre !in VALID_GENRES) { + throw CustomException(ErrorCode.INVALID_GENRE, null) + } } } } diff --git a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookSpecification.kt b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookSpecification.kt index 4000dd3..27c8457 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookSpecification.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookSpecification.kt @@ -45,27 +45,27 @@ object BookSpecification { } /** - * 국가별 분류 필터 + * 국가별 분류 필터 (복수 선택 가능 - OR 조건) */ - fun withOrigin(origin: String?): Specification { + fun withOrigins(origins: List?): Specification { return Specification { root, _, cb -> - if (origin.isNullOrBlank()) { + if (origins.isNullOrEmpty()) { null } else { - cb.equal(root.get("origin"), origin) + root.get("origin").`in`(origins) } } } /** - * 장르별 분류 필터 + * 장르별 분류 필터 (복수 선택 가능 - OR 조건) */ - fun withGenre(genre: String?): Specification { + fun withGenres(genres: List?): Specification { return Specification { root, _, cb -> - if (genre.isNullOrBlank()) { + if (genres.isNullOrEmpty()) { null } else { - cb.equal(root.get("genre"), genre) + root.get("genre").`in`(genres) } } } diff --git a/src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt b/src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt index e2c8272..eb6dd9a 100644 --- a/src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt +++ b/src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt @@ -68,8 +68,8 @@ class BookController( ## 필터 옵션 - **level**: 난이도 (1, 2, 3) - **pageRange**: 분량 (~200, 250, 350, 500, 650, 651~) - 단일 선택, 선택한 값 이하 모두 포함 - - **origin**: 국가별 (한국소설, 영미소설, 중국소설, 일본소설, 프랑스소설, 독일소설) - - **genre**: 장르별 (로맨스, 희곡, 무협소설, 판타지/환상문학, 역사소설, 라이트노벨, 추리/미스터리, 과학소설(SF), 액션/스릴러, 호러/공포소설) + - **origins**: 국가별 (한국소설, 영미소설, 중국소설, 일본소설, 프랑스소설, 독일소설) - 복수 선택 가능 + - **genres**: 장르별 (로맨스, 희곡, 무협소설, 판타지/환상문학, 역사소설, 라이트노벨, 추리/미스터리, 과학소설(SF), 액션/스릴러, 호러/공포소설) - 복수 선택 가능 - **keyword**: 검색어 (제목, 저자, 출판사에서 검색) - 필터 선택 후 사용 가능 ## 페이지네이션 @@ -77,7 +77,8 @@ class BookController( - **hasNext**: 다음 페이지 존재 여부 - 정렬: id 오름차순 - 모든 필터는 선택 사항이며, 복수 필터 적용 시 AND 조건으로 검색됩니다. + 모든 필터는 선택 사항이며, 서로 다른 필터 간에는 AND 조건으로 검색됩니다. + 동일 필터 내 복수 선택 시 OR 조건으로 검색됩니다. keyword 입력 시 필터링된 결과 내에서 추가로 검색됩니다. 유효하지 않은 필터 값을 입력하면 400 Bad Request 에러가 반환됩니다. """ @@ -86,12 +87,12 @@ class BookController( fun filterBooks( @Parameter(description = "난이도") @RequestParam(required = false) level: Int?, @Parameter(description = "분량 (단일 선택: ~200, 250, 350, 500, 650, 651~)") @RequestParam(required = false) pageRange: String?, - @Parameter(description = "국가별 분류") @RequestParam(required = false) origin: String?, - @Parameter(description = "장르별 분류") @RequestParam(required = false) genre: String?, + @Parameter(description = "국가별 분류 (복수 선택 가능)") @RequestParam(required = false) origins: List?, + @Parameter(description = "장르별 분류 (복수 선택 가능)") @RequestParam(required = false) genres: List?, @Parameter(description = "검색어 (제목, 저자, 출판사)") @RequestParam(required = false) keyword: String?, @Parameter(description = "마지막으로 조회한 bookId (첫 요청 시 생략)") @RequestParam(required = false) cursor: Long? ): ResponseEntity> { - val response = bookQueryService.filter(level, pageRange, origin, genre, keyword, cursor) + val response = bookQueryService.filter(level, pageRange, origins, genres, keyword, cursor) return ResponseEntity.ok(ApiResponse.ok(response)) } }