Skip to content

Commit 5e45e01

Browse files
authored
Merge pull request #59 from Leets-Official/feat/home-list
[feat] 홈 목록 조회 및 도서 상세 조회 API 수정
2 parents fe3e985 + 7e66440 commit 5e45e01

9 files changed

Lines changed: 237 additions & 97 deletions

File tree

src/main/kotlin/com/stepbookstep/server/domain/book/domain/Book.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ class Book(
5858
@Column(name = "category_id", nullable = false)
5959
val categoryId: Long,
6060

61-
@Column(name = "genre_id", nullable = false)
62-
val genreId: Long,
61+
@Column(name = "genre_id", nullable = true)
62+
val genreId: Long?,
6363

6464
@Column(nullable = false)
6565
val weight: Int = 0,

src/main/kotlin/com/stepbookstep/server/domain/book/domain/BookRepository.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,19 @@ interface BookRepository : JpaRepository<Book, Long>, JpaSpecificationExecutor<B
2525

2626
@Query("SELECT b FROM Book b WHERE b.isBestseller = true")
2727
fun findAllBestsellers(): List<Book>
28+
29+
@Query("SELECT b FROM Book b WHERE b.itemPage BETWEEN :minPage AND :maxPage")
30+
fun findAllByPageRange(@Param("minPage") minPage: Int, @Param("maxPage") maxPage: Int): List<Book>
31+
32+
@Query("SELECT b FROM Book b WHERE b.score BETWEEN :minScore AND :maxScore")
33+
fun findAllByScoreRange(@Param("minScore") minScore: Int, @Param("maxScore") maxScore: Int): List<Book>
34+
35+
@Query("SELECT b FROM Book b WHERE b.level = 3")
36+
fun findAllByLevel3(): List<Book>
37+
38+
@Query("SELECT b FROM Book b WHERE b.categoryId = :categoryId")
39+
fun findAllByCategoryId(@Param("categoryId") categoryId: Long): List<Book>
40+
41+
@Query("SELECT b FROM Book b WHERE b.genreId = :genreId")
42+
fun findAllByGenreId(@Param("genreId") genreId: Long): List<Book>
2843
}

src/main/kotlin/com/stepbookstep/server/domain/book/presentation/BookController.kt

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import com.stepbookstep.server.domain.book.application.BookQueryService
44
import com.stepbookstep.server.domain.book.presentation.dto.BookDetailResponse
55
import com.stepbookstep.server.domain.book.presentation.dto.BookFilterResponse
66
import com.stepbookstep.server.domain.book.presentation.dto.BookSearchResponse
7-
import com.stepbookstep.server.domain.book.presentation.dto.MyRecord
87
import com.stepbookstep.server.domain.reading.domain.UserBookRepository
98
import com.stepbookstep.server.global.response.ApiResponse
109
import com.stepbookstep.server.security.jwt.LoginUserId
@@ -22,28 +21,17 @@ class BookController(
2221
private val userBookRepository: UserBookRepository
2322
) {
2423

25-
@Operation(summary = "도서 상세 조회", description = "도서 ID로 상세 정보를 조회합니다. 북마크 여부와 독서 기록이 포함됩니다.")
24+
@Operation(summary = "도서 상세 조회", description = "도서 ID로 상세 정보를 조회합니다. 북마크 여부가 포함됩니다.")
2625
@GetMapping("/{bookId}")
2726
fun getBook(
2827
@Parameter(description = "도서 ID") @PathVariable bookId: Long,
2928
@Parameter(hidden = true) @LoginUserId userId: Long
3029
): ResponseEntity<ApiResponse<BookDetailResponse>> {
3130
val book = bookQueryService.findById(bookId)
32-
3331
val userBook = userBookRepository.findByUserIdAndBookId(userId, bookId)
34-
3532
val isBookmarked = userBook?.isBookmarked ?: false
36-
val myRecord = userBook?.let {
37-
MyRecord(
38-
status = it.status.name,
39-
startDate = it.createdAt.toLocalDate().toString(),
40-
endDate = it.finishedAt?.toLocalDate()?.toString(),
41-
currentPage = it.totalPagesRead,
42-
readPercent = it.progressPercent
43-
)
44-
}
4533

46-
val response = BookDetailResponse.from(book, isBookmarked = isBookmarked, myRecord = myRecord)
34+
val response = BookDetailResponse.from(book, isBookmarked = isBookmarked)
4735
return ResponseEntity.ok(ApiResponse.ok(response))
4836
}
4937

src/main/kotlin/com/stepbookstep/server/domain/book/presentation/dto/BookDetailResponse.kt

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@ import com.stepbookstep.server.domain.book.domain.Book
44

55
data class BookDetailResponse(
66
val bookInfo: BookInfo,
7-
val isBookmarked: Boolean,
8-
val myRecord: MyRecord?
7+
val isBookmarked: Boolean
98
) {
109
companion object {
11-
fun from(book: Book, isBookmarked: Boolean = false, myRecord: MyRecord? = null): BookDetailResponse {
10+
fun from(book: Book, isBookmarked: Boolean = false): BookDetailResponse {
1211
return BookDetailResponse(
1312
bookInfo = BookInfo.from(book),
14-
isBookmarked = isBookmarked,
15-
myRecord = myRecord
13+
isBookmarked = isBookmarked
1614
)
1715
}
1816
}
@@ -29,7 +27,8 @@ data class BookInfo(
2927
val priceStandard: Int,
3028
val link: String,
3129
val description: String,
32-
val tags: List<String>
30+
val tags: List<String>,
31+
val level: Int
3332
) {
3433
companion object {
3534
fun from(book: Book): BookInfo {
@@ -44,7 +43,8 @@ data class BookInfo(
4443
priceStandard = book.priceStandard,
4544
link = book.aladinLink,
4645
description = book.description,
47-
tags = BookTagBuilder.buildTags(book)
46+
tags = BookTagBuilder.buildTags(book),
47+
level = book.level
4848
)
4949
}
5050
}
@@ -73,10 +73,3 @@ object BookTagBuilder {
7373
}
7474
}
7575

76-
data class MyRecord(
77-
val status: String,
78-
val startDate: String?,
79-
val endDate: String?,
80-
val currentPage: Int,
81-
val readPercent: Int
82-
)

src/main/kotlin/com/stepbookstep/server/domain/home/application/HomeCacheService.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,19 @@ class HomeCacheService(
2626
fun getBestsellerBooks(): List<Book> {
2727
return bookRepository.findAllBestsellers()
2828
}
29+
30+
@Cacheable(value = ["level3Books"])
31+
fun getLevel3Books(): List<Book> {
32+
return bookRepository.findAllByLevel3()
33+
}
34+
35+
@Cacheable(value = ["categoryBooks"], key = "#categoryId")
36+
fun getBooksByCategoryId(categoryId: Long): List<Book> {
37+
return bookRepository.findAllByCategoryId(categoryId)
38+
}
39+
40+
@Cacheable(value = ["genreIdBooks"], key = "#genreId")
41+
fun getBooksByGenreId(genreId: Long): List<Book> {
42+
return bookRepository.findAllByGenreId(genreId)
43+
}
2944
}
Lines changed: 145 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,165 @@
11
package com.stepbookstep.server.domain.home.application
22

3-
import com.stepbookstep.server.domain.book.domain.BookGenre
3+
import com.stepbookstep.server.domain.book.domain.Book
4+
import com.stepbookstep.server.domain.book.domain.BookRepository
45
import com.stepbookstep.server.domain.home.presentation.dto.GenreBooks
56
import com.stepbookstep.server.domain.home.presentation.dto.HomeResponse
7+
import com.stepbookstep.server.domain.home.presentation.dto.ReadingStatistics
68
import com.stepbookstep.server.domain.home.presentation.dto.Recommendations
7-
import com.stepbookstep.server.global.response.CustomException
8-
import com.stepbookstep.server.global.response.ErrorCode
9+
import com.stepbookstep.server.domain.reading.application.StatisticsService
10+
import com.stepbookstep.server.domain.reading.domain.UserBookRepository
11+
import com.stepbookstep.server.domain.user.domain.UserCategoryPreferenceRepository
12+
import com.stepbookstep.server.domain.user.domain.UserGenrePreferenceRepository
913
import org.springframework.stereotype.Service
1014
import org.springframework.transaction.annotation.Transactional
15+
import java.time.Year
1116

1217
@Service
1318
@Transactional(readOnly = true)
1419
class HomeQueryService(
15-
private val homeCacheService: HomeCacheService
20+
private val homeCacheService: HomeCacheService,
21+
private val userCategoryPreferenceRepository: UserCategoryPreferenceRepository,
22+
private val userGenrePreferenceRepository: UserGenrePreferenceRepository,
23+
private val userBookRepository: UserBookRepository,
24+
private val bookRepository: BookRepository,
25+
private val statisticsService: StatisticsService
1626
) {
1727

18-
fun getHome(genreIds: List<Int>?): HomeResponse {
19-
val genre = when {
20-
genreIds.isNullOrEmpty() -> BookGenre.entries.random()
21-
else -> {
22-
val hasInvalidId = genreIds.any { it < 0 || it >= BookGenre.entries.size }
23-
if (hasInvalidId) throw CustomException(ErrorCode.INVALID_GENRE_ID)
24-
val genres = genreIds.map { BookGenre.entries[it] }
25-
genres.random()
26-
}
27-
}
28+
fun getHome(userId: Long): HomeResponse {
29+
val readingStatistics = getReadingStatistics(userId)
30+
val selectedBooks = selectGenreBooksForUser(userId)
2831

29-
val genreBooks = homeCacheService.getGenreBooks(genre.displayName).shuffled().take(20)
30-
val under200Books = homeCacheService.getUnder200Books().shuffled().take(20)
32+
val (lightReadsBooks, levelUpBooks) = getPersonalizedRecommendations(userId)
3133
val bestsellerBooks = homeCacheService.getBestsellerBooks().shuffled().take(20)
3234

3335
return HomeResponse(
34-
genreBooks = GenreBooks.of(genre, genreBooks),
35-
recommendations = Recommendations.of(under200Books, bestsellerBooks)
36-
// TODO: 독서 통계 부분은 추가 구현할 예정입니다.
36+
readingStatistics = readingStatistics,
37+
genreBooks = GenreBooks.of(
38+
type = selectedBooks.type,
39+
id = selectedBooks.id,
40+
name = selectedBooks.name,
41+
books = selectedBooks.books
42+
),
43+
recommendations = Recommendations.of(lightReadsBooks, levelUpBooks, bestsellerBooks)
44+
)
45+
}
46+
47+
private fun getReadingStatistics(userId: Long): ReadingStatistics {
48+
val currentYear = Year.now().value
49+
val stats = statisticsService.getReadingStatistics(userId, currentYear)
50+
51+
val favoriteCategory = stats.categoryPreference.categories
52+
.firstOrNull()?.categoryName
53+
54+
return ReadingStatistics(
55+
finishedBookCount = stats.bookSummary.finishedBookCount,
56+
cumulativeHours = stats.cumulativeTime.hours,
57+
achievementRate = stats.goalAchievement.achievementRate,
58+
favoriteCategory = favoriteCategory
3759
)
3860
}
61+
62+
private fun selectGenreBooksForUser(userId: Long): SelectedBooks {
63+
val categoryPreferences = userCategoryPreferenceRepository.findAllByUserId(userId)
64+
val genrePreferences = userGenrePreferenceRepository.findAllByUserId(userId)
65+
66+
val allPreferences = mutableListOf<Pair<String, Long>>()
67+
68+
categoryPreferences.forEach { pref ->
69+
allPreferences.add("category" to pref.categoryId)
70+
}
71+
genrePreferences.forEach { pref ->
72+
allPreferences.add("genre" to pref.genreId)
73+
}
74+
75+
// 선택한 preference가 없으면 랜덤으로 category 또는 genre 선택
76+
if (allPreferences.isEmpty()) {
77+
return selectRandomCategoryOrGenre()
78+
}
79+
80+
val shuffledPreferences = allPreferences.shuffled()
81+
for (preference in shuffledPreferences) {
82+
val books = when (preference.first) {
83+
"category" -> homeCacheService.getBooksByCategoryId(preference.second)
84+
"genre" -> homeCacheService.getBooksByGenreId(preference.second)
85+
else -> emptyList()
86+
}.shuffled().take(20)
87+
88+
if (books.isNotEmpty()) {
89+
val firstBook = books.first()
90+
val name = when (preference.first) {
91+
"category" -> firstBook.origin
92+
"genre" -> firstBook.genre
93+
else -> ""
94+
}
95+
return SelectedBooks(preference.first, preference.second, name, books)
96+
}
97+
}
98+
99+
// 선택한 preference에 매칭되는 책이 없으면 랜덤 선택
100+
return selectRandomCategoryOrGenre()
101+
}
102+
103+
private fun selectRandomCategoryOrGenre(): SelectedBooks {
104+
val allBooks = bookRepository.findAll()
105+
106+
// categoryId가 있는 책들의 고유 categoryId 목록
107+
val categoryIds = allBooks.mapNotNull { it.categoryId }.distinct()
108+
// genreId가 있는 책들의 고유 genreId 목록
109+
val genreIds = allBooks.mapNotNull { it.genreId }.distinct()
110+
111+
val allOptions = mutableListOf<Pair<String, Long>>()
112+
categoryIds.forEach { allOptions.add("category" to it) }
113+
genreIds.forEach { allOptions.add("genre" to it) }
114+
115+
if (allOptions.isEmpty()) {
116+
return SelectedBooks("none", 0L, "추천도서", emptyList())
117+
}
118+
119+
val selected = allOptions.random()
120+
val books = when (selected.first) {
121+
"category" -> allBooks.filter { it.categoryId == selected.second }
122+
"genre" -> allBooks.filter { it.genreId == selected.second }
123+
else -> emptyList()
124+
}.shuffled().take(20)
125+
126+
if (books.isEmpty()) {
127+
return SelectedBooks("none", 0L, "추천도서", emptyList())
128+
}
129+
130+
val firstBook = books.first()
131+
val name = when (selected.first) {
132+
"category" -> firstBook.origin
133+
"genre" -> firstBook.genre
134+
else -> ""
135+
}
136+
return SelectedBooks(selected.first, selected.second, name, books)
137+
}
138+
139+
private data class SelectedBooks(
140+
val type: String,
141+
val id: Long,
142+
val name: String,
143+
val books: List<Book>
144+
)
145+
146+
private fun getPersonalizedRecommendations(userId: Long): Pair<List<Book>, List<Book>> {
147+
val userBooks = userBookRepository.findReadingAndFinishedBooksByUserId(userId)
148+
149+
return if (userBooks.isEmpty()) {
150+
val lightReads = homeCacheService.getUnder200Books().shuffled().take(20)
151+
val levelUp = homeCacheService.getLevel3Books().shuffled().take(20)
152+
Pair(lightReads, levelUp)
153+
} else {
154+
val avgPageCount = userBooks.map { it.book.itemPage }.average().toInt()
155+
val avgScore = userBooks.map { it.book.score }.average().toInt()
156+
157+
val lightReads = bookRepository.findAllByPageRange(avgPageCount - 10, avgPageCount + 10)
158+
.shuffled().take(20)
159+
val levelUp = bookRepository.findAllByScoreRange(avgScore + 15, avgScore + 20)
160+
.shuffled().take(20)
161+
162+
Pair(lightReads, levelUp)
163+
}
164+
}
39165
}

0 commit comments

Comments
 (0)