-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathBookQueryService.kt
More file actions
138 lines (121 loc) · 5.4 KB
/
BookQueryService.kt
File metadata and controls
138 lines (121 loc) · 5.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
package com.stepbookstep.server.domain.book.application
import com.stepbookstep.server.domain.book.domain.Book
import com.stepbookstep.server.domain.book.domain.BookRepository
import com.stepbookstep.server.domain.book.domain.BookSpecification
import com.stepbookstep.server.domain.book.presentation.dto.BookFilterResponse
import com.stepbookstep.server.domain.reading.domain.UserBookRepository
import com.stepbookstep.server.global.response.CustomException
import com.stepbookstep.server.global.response.ErrorCode
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class BookQueryService(
private val bookRepository: BookRepository,
private val bookCacheService: BookCacheService,
private val userBookRepository: UserBookRepository
) {
companion object {
private val logger = LoggerFactory.getLogger(BookQueryService::class.java)
private const val PAGE_SIZE = 20
private val VALID_LEVELS = setOf(1, 2, 3)
private val VALID_PAGE_RANGES = setOf("~200", "250", "350", "500", "650", "651~")
private val VALID_ORIGINS = setOf("한국소설", "영미소설", "중국소설", "일본소설", "프랑스소설", "독일소설")
private val VALID_GENRES = setOf(
"로맨스", "희곡", "무협소설", "판타지/환상문학", "역사소설",
"라이트노벨", "추리/미스터리", "과학소설(SF)", "액션/스릴러", "호러/공포소설"
)
}
fun findById(id: Long): Book {
return bookCacheService.getBookDetail(id)
?: throw CustomException(ErrorCode.BOOK_NOT_FOUND, null)
}
fun search(userId: Long, keyword: String?): List<Book> {
return if (keyword.isNullOrBlank()) {
val userLevel = calculateUserLevel(userId)
bookCacheService.getBooksByLevel(userLevel).shuffled().take(4)
} else {
val results = bookRepository.searchByKeyword(keyword)
if (results.isEmpty()) {
throw CustomException(ErrorCode.NOT_SEARCH, null)
}
results
}
}
private fun calculateUserLevel(userId: Long): Int {
val userBooks = userBookRepository.findReadingAndFinishedBooksByUserId(userId)
if (userBooks.isEmpty()) {
logger.info("[BookSearch] userId={}, 독서 히스토리 없음 → level=1", userId)
return 1
}
val avgScore = userBooks.map { it.book.score }.average().toInt()
val level = when {
avgScore <= 35 -> 1
avgScore <= 65 -> 2
else -> 3
}
logger.info("[BookSearch] userId={}, avgScore={}, level={}", userId, avgScore, level)
return level
}
fun filter(
level: Int?,
pageRange: String?,
origins: List<String>?,
genres: List<String>?,
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 || !parsedOrigins.isNullOrEmpty() || !parsedGenres.isNullOrEmpty()
if (!hasFilter && !keyword.isNullOrBlank()) {
throw CustomException(ErrorCode.FILTER_REQUIRED, null)
}
// 유효성 검증
validateFilterParams(level, pageRange, parsedOrigins, parsedGenres)
val spec = Specification.where(BookSpecification.withLevel(level))
.and(BookSpecification.withPageRange(pageRange))
.and(BookSpecification.withOrigins(parsedOrigins))
.and(BookSpecification.withGenres(parsedGenres))
.and(BookSpecification.withKeyword(keyword))
.and(BookSpecification.withCursor(cursor))
// PAGE_SIZE + 1개 조회하여 다음 페이지 존재 여부 확인
val pageable = PageRequest.of(0, PAGE_SIZE + 1, Sort.by(Sort.Direction.ASC, "id"))
val result = bookRepository.findAll(spec, pageable).content
val hasNext = result.size > PAGE_SIZE
val books = if (hasNext) result.dropLast(1) else result
return BookFilterResponse.of(
books = books,
hasNext = hasNext
)
}
private fun validateFilterParams(
level: Int?,
pageRange: String?,
origins: List<String>?,
genres: List<String>?
) {
if (level != null && level !in VALID_LEVELS) {
throw CustomException(ErrorCode.INVALID_DIFFICULTY, null)
}
if (pageRange != null && pageRange !in VALID_PAGE_RANGES) {
throw CustomException(ErrorCode.INVALID_PAGE_RANGE, null)
}
origins?.forEach { origin ->
if (origin !in VALID_ORIGINS) {
throw CustomException(ErrorCode.INVALID_ORIGIN, null)
}
}
genres?.forEach { genre ->
if (genre !in VALID_GENRES) {
throw CustomException(ErrorCode.INVALID_GENRE, null)
}
}
}
}