Skip to content

Commit

Permalink
[feat #45] 컨텐츠 검색 API (#59)
Browse files Browse the repository at this point in the history
* feat : 필터링 조건 dto 및 응답 dto

* feat : 필터링 검색 api, usecase

* feat : 필터링 쿼리 및 테스트(기존 쿼리 활용)

* edit : user entity join 제거

* fix : 테스트 코드 오류 수정
  • Loading branch information
dlswns2480 authored Aug 4, 2024
1 parent 066f418 commit 2609c42
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import com.pokit.common.dto.SliceResponseDto
import com.pokit.common.wrapper.ResponseWrapper.wrapOk
import com.pokit.common.wrapper.ResponseWrapper.wrapSlice
import com.pokit.common.wrapper.ResponseWrapper.wrapUnit
import com.pokit.content.dto.ContentsResponse
import com.pokit.content.dto.request.ContentSearchParams
import com.pokit.content.dto.request.CreateContentRequest
import com.pokit.content.dto.request.UpdateContentRequest
import com.pokit.content.dto.request.toDto
import com.pokit.content.dto.response.BookMarkContentResponse
import com.pokit.content.dto.response.ContentResponse
import com.pokit.content.dto.response.toResponse
import com.pokit.content.exception.ContentErrorCode
import com.pokit.content.model.Content
import com.pokit.content.port.`in`.ContentUseCase
import io.swagger.v3.oas.annotations.Operation
import jakarta.validation.Valid
Expand Down Expand Up @@ -98,17 +99,20 @@ class ContentController(
@Operation(summary = "카테고리 내 컨텐츠 목록 조회")
fun getContents(
@AuthenticationPrincipal user: PrincipalUser,
@PathVariable("categoryId") contentId: Long,
@PathVariable("categoryId") categoryId: Long,
@PageableDefault(
page = 0,
size = 10,
sort = ["createdAt"],
direction = Sort.Direction.DESC
) pageable: Pageable,
@RequestParam(required = false) isRead: Boolean?,
@RequestParam(required = false) favorites: Boolean?
): ResponseEntity<SliceResponseDto<Content>>? {
return contentUseCase.getContents(user.id, contentId, pageable, isRead, favorites)
condition: ContentSearchParams,
): ResponseEntity<SliceResponseDto<ContentsResponse>> {
return contentUseCase.getContents(
user.id,
condition.copy(categoryId = categoryId).toDto(),
pageable
)
.wrapSlice()
.wrapOk()
}
Expand All @@ -123,5 +127,26 @@ class ContentController(
.toResponse()
.wrapOk()
}

@GetMapping
@Operation(summary = "컨텐츠 검색 API")
fun searchContent(
@AuthenticationPrincipal user: PrincipalUser,
@PageableDefault(
page = 0,
size = 10,
sort = ["createdAt"],
direction = Sort.Direction.DESC
) pageable: Pageable,
condition: ContentSearchParams,
): ResponseEntity<SliceResponseDto<ContentsResponse>> {
return contentUseCase.getContents(
user.id,
condition.toDto(),
pageable
)
.wrapSlice()
.wrapOk()
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.pokit.content.dto.request

import org.springframework.format.annotation.DateTimeFormat
import java.time.LocalDate

data class ContentSearchParams(
val categoryId: Long?,
val isRead: Boolean?,
val favorites: Boolean?,
@DateTimeFormat(pattern = "yyyy.MM.dd")
val startDate: LocalDate?,
@DateTimeFormat(pattern = "yyyy.MM.dd")
val endDate: LocalDate?,
val categoryIds: List<Long>?
)

internal fun ContentSearchParams.toDto() = ContentSearchCondition(
categoryId = this.categoryId,
isRead = this.isRead,
favorites = this.favorites,
startDate = this.startDate,
endDate = this.endDate,
categoryIds = this.categoryIds
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.pokit.out.persistence.content.impl

import com.pokit.content.dto.ContentsResponse
import com.pokit.content.dto.request.ContentSearchCondition
import com.pokit.content.model.Content
import com.pokit.content.port.out.ContentPort
import com.pokit.log.model.LogType
Expand All @@ -10,7 +12,8 @@ import com.pokit.out.persistence.content.persist.ContentRepository
import com.pokit.out.persistence.content.persist.QContentEntity.contentEntity
import com.pokit.out.persistence.content.persist.toDomain
import com.pokit.out.persistence.log.persist.QUserLogEntity.userLogEntity
import com.pokit.out.persistence.user.persist.QUserEntity.userEntity
import com.querydsl.core.Tuple
import com.querydsl.core.types.Predicate
import com.querydsl.core.types.dsl.DateTimePath
import com.querydsl.jpa.impl.JPAQuery
import com.querydsl.jpa.impl.JPAQueryFactory
Expand All @@ -20,7 +23,9 @@ import org.springframework.data.domain.SliceImpl
import org.springframework.data.domain.Sort
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Repository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime

@Repository
class ContentAdapter(
Expand All @@ -46,27 +51,28 @@ class ContentAdapter(

override fun loadAllByUserIdAndContentId(
userId: Long,
categoryId: Long,
condition: ContentSearchCondition,
pageable: Pageable,
read: Boolean?,
favorites: Boolean?
): Slice<Content> {
): Slice<ContentsResponse> {
var hasNext = false
val order = pageable.sort.getOrderFor("createdAt")

val query = queryFactory.select(contentEntity)
val query = queryFactory.select(contentEntity, categoryEntity.name, userLogEntity.count())
.from(contentEntity)
.leftJoin(userLogEntity).on(userLogEntity.contentId.eq(contentEntity.id))
.join(categoryEntity).on(categoryEntity.id.eq(contentEntity.categoryId))
.join(userEntity).on(userEntity.id.eq(categoryEntity.userId))

FavoriteOrNot(favorites, query) // 북마크 조인 여부
ReadOrNot(read, query) // 읽음 로그 조인 여부
FavoriteOrNot(condition.favorites, query) // 북마크 조인 여부

query.where(
userEntity.id.eq(userId),
categoryEntity.id.eq(categoryId),
contentEntity.deleted.isFalse
categoryEntity.userId.eq(userId),
condition.categoryId?.let { categoryEntity.id.eq(it) },
isUnread(condition.isRead),
contentEntity.deleted.isFalse,
dateBetween(condition.startDate, condition.endDate),
categoryIn(condition.categoryIds)
)
.groupBy(contentEntity)
.orderBy(getSort(contentEntity.createdAt, order!!))
.limit(pageable.pageSize + 1L)

Expand All @@ -78,38 +84,62 @@ class ContentAdapter(
contentEntityList.removeAt(contentEntityList.size - 1)
}

val contents = contentEntityList.map { it.toDomain() }
val contents = contentEntityList.map {
ContentsResponse.of(
it[contentEntity]!!.toDomain(),
it[categoryEntity.name]!!,
it[userLogEntity.count()]!!
)
}

return SliceImpl(contents, pageable, hasNext)
}

override fun deleteByUserId(userId: Long) {
contentRepository.deleteByUserId(userId)
private fun isUnread(read: Boolean?): Predicate? {
return read?.let {
userLogEntity.id.isNull.or(userLogEntity.type.ne(LogType.READ))
}
}

private fun ReadOrNot(
read: Boolean?,
query: JPAQuery<ContentEntity>
): JPAQuery<ContentEntity>? {
return read
?.let {
query
.leftJoin(userLogEntity)
.on(userLogEntity.contentId.eq(contentEntity.id))
.where(userLogEntity.id.isNull.or(userLogEntity.type.ne(LogType.READ)))
}

private fun categoryIn(categoryIds: List<Long>?): Predicate? {
if (categoryIds.isNullOrEmpty()) {
return null
}

return contentEntity.categoryId.`in`(categoryIds)
}

private fun dateBetween(startDate: LocalDate?, endDate: LocalDate?): Predicate? {
if (startDate == null || endDate == null) {
return null
}

val startDateTime = startDate.atStartOfDay()
val endDateTime = endDate.atTime(LocalTime.MAX)

val isAfter = contentEntity.createdAt.after(startDateTime)
val isBefore = contentEntity.createdAt.before(endDateTime)

return isAfter.and(isBefore)
}

override fun deleteByUserId(userId: Long) {
contentRepository.deleteByUserId(userId)
}

private fun FavoriteOrNot(
favorites: Boolean?,
query: JPAQuery<ContentEntity>
): JPAQuery<ContentEntity>? {
query: JPAQuery<Tuple>
): JPAQuery<Tuple>? {
return favorites
?.let {
query
.join(bookmarkEntity)
.on(bookmarkEntity.contentId.eq(contentEntity.id)
.and(bookmarkEntity.deleted.isFalse))
.on(
bookmarkEntity.contentId.eq(contentEntity.id)
.and(bookmarkEntity.deleted.isFalse)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.pokit.out.persistence.content.impl

import com.pokit.bookmark.BookmarkFixture
import com.pokit.category.CategoryFixture
import com.pokit.category.model.Category
import com.pokit.category.model.CategoryImage
import com.pokit.content.ContentFixture
import com.pokit.log.model.LogType
Expand All @@ -12,6 +13,7 @@ import com.pokit.out.persistence.category.persist.*
import com.pokit.out.persistence.config.QueryDslConfig
import com.pokit.out.persistence.content.persist.ContentEntity
import com.pokit.out.persistence.content.persist.ContentRepository
import com.pokit.out.persistence.content.persist.QContentEntity.*
import com.pokit.out.persistence.content.persist.toDomain
import com.pokit.out.persistence.log.persist.UserLogEntity
import com.pokit.out.persistence.log.persist.UserLogRepository
Expand Down Expand Up @@ -70,6 +72,8 @@ class ContentAdapterTest(
val userLog = UserLog(savedContent2.id, savedUser.id, LogType.READ)
userLogRepository.save(UserLogEntity.of(userLog))

val condition = ContentFixture.getCondition(savedCategory.id)

val pageRequest = PageRequest.of(0, 10, Sort.by("createdAt").descending())

When("유저 아이디와 컨텐츠 아이디로 조회 시") {
Expand All @@ -91,34 +95,96 @@ class ContentAdapterTest(
}
}

When("특정 카테고리 내의 컨텐츠 목록을 조회할 때") {
When("컨텐츠 목록을 조회할 때") {
When("즐겨찾기한 컨텐츠만 조회하면") {
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, savedCategory.id, pageRequest, null, true
savedUser.id, condition.copy(favorites = true), pageRequest
)
Then("두개의 컨텐츠 중 즐겨찾기 한 하나의 컨텐츠만 조회된다.") {
result.content.size shouldBe 1
val favoriteContent = result.content[0]
favoriteContent.id shouldBe savedContent2.id
favoriteContent.contentId shouldBe savedContent2.id
}
}
When("필터링 조건이 아무것도 없다면") {
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, savedCategory.id, pageRequest, null, null
savedUser.id, condition, pageRequest
)
Then("목록이 전체 조회된다.") {
result.content.size shouldBe 2
}
}
When("안 읽은 컨텐츠를 조회하면") {
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, savedCategory.id, pageRequest, false, null
savedUser.id, condition.copy(isRead = false), pageRequest
)
Then("안 읽은 컨텐츠 하나만 조회된다 (savedContent2)") {
result.content.size shouldBe 1
result.content[0].id shouldBe savedContent.id
result.content[0].contentId shouldBe savedContent.id
}
}

When("유저 로그가 없다면") {
userLogRepository.deleteAll()
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, condition, pageRequest
)
Then("모든 컨텐츠들은 안읽음 상태이다.") {
result.content.size shouldBe 2
result.content.map {
it.isRead shouldBe false
}
}
}
val userLog = UserLog(savedContent2.id, savedUser.id, LogType.READ)
userLogRepository.save(UserLogEntity.of(userLog))

When("유저로그가 있는 컨텐츠를 조회하면") {

val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, condition, pageRequest
)
Then("컨텐츠는 읽음 상태로 조회된다.") {
val readContent = result.content
.findLast { it.isRead } // 읽음 상태인 컨텐츠 조회
readContent!!.contentId shouldBe userLog.contentId
}
}
val anotherImage = CategoryImage(2, "www.s3.com")
val savedAnotherImage = categoryImageRepository.save(CategoryImageEntity.of(anotherImage))

val anotherCategory = Category(
userId = savedUser.id,
categoryName = "다른 카테고리",
categoryImage = savedAnotherImage.toDomain()
)
val savedAnotherCategory = categoryRepository.save(CategoryEntity.of(anotherCategory))

val content3 = ContentFixture.getContent(savedAnotherCategory.id)
contentRepository.save(ContentEntity.of(content3))


When("카테고리명 하나로 필터링할 때") {
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id, condition.copy(categoryId = null, categoryIds = mutableListOf(savedCategory.id)), pageRequest
)
Then("해당 카테고리의 컨텐츠들이 조회된다.") {
result.content.size shouldBe 2
}
}

When("카테고리명 두개로 필터링할 때") {
val result = contentAdapter.loadAllByUserIdAndContentId(
savedUser.id,
condition.copy(categoryId = null, categoryIds = mutableListOf(savedCategory.id, savedAnotherCategory.id)),
pageRequest
)
Then("둘 중 하나라도 만족하면 조회된다.") {
result.content.size shouldBe 3
}
}


}
}
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pokit.content

import com.pokit.content.dto.ContentCommand
import com.pokit.content.dto.request.ContentSearchCondition
import com.pokit.content.model.Content
import com.pokit.content.model.ContentType

Expand Down Expand Up @@ -48,5 +49,14 @@ class ContentFixture {
memo = "네이버우어",
alertYn = "YES"
)

fun getCondition(categoryId: Long) = ContentSearchCondition(
categoryId = categoryId,
isRead = null,
favorites = null,
startDate = null,
endDate = null,
categoryIds = null
)
}
}
Loading

0 comments on commit 2609c42

Please sign in to comment.