From d4c6d7ca57be8b6d52643e916bc0e860ae8829f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=9D=B8=EC=A4=80?= <54973090+dlswns2480@users.noreply.github.com> Date: Mon, 29 Jul 2024 12:11:40 +0900 Subject: [PATCH] =?UTF-8?q?[feat=20#33]=20=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API(=EB=AA=A9=EB=A1=9D,=20=EC=83=81?= =?UTF-8?q?=EC=84=B8)=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : QueryDsl 설정 추가 * feat : 엔티티 필드 수정 * feat : UserLog 정의 * feat : 컨텐츠 조회 영속성 계층 * feat : 컨텐츠 조회 어플리케이션 계층 * feat : 컨텐츠 조회 API * feat : 컨텐츠 조회 dto * chore : out-persistence 테스트용 yml 파일 * feat : UserLog 저장 방식 수정 * rename : yml local 프로파일 --- .../com/pokit/content/ContentController.kt | 37 ++++++++ .../content/dto/response/ContentResponse.kt | 22 ++++- adapters/out-persistence/build.gradle.kts | 28 ++++++ .../bookmark/impl/BookMarkAdapter.kt | 8 ++ .../bookmark/persist/BookmarkEntity.kt | 2 +- .../category/persist/CategoryEntity.kt | 2 +- .../out/persistence/config/QueryDslConfig.kt | 16 ++++ .../content/impl/ContentAdapter.kt | 87 ++++++++++++++++++- .../content/persist/ContentEntity.kt | 1 + .../out/persistence/log/UserLogAdapter.kt | 16 ++++ .../persistence/log/persist/UserLogEntity.kt | 14 +++ .../log/persist/UserLogRepository.kt | 7 ++ .../content/impl/ContentAdapterTest.kt | 57 +++++++++++- .../src/test/resources/application-local.yml | 12 +++ .../pokit/bookmark/port/out/BookmarkPort.kt | 2 + .../pokit/content/port/in/ContentUseCase.kt | 13 +++ .../com/pokit/content/port/out/ContentPort.kt | 10 +++ .../content/port/service/ContentService.kt | 43 ++++++++- .../com/pokit/log/port/out/UserLogPort.kt | 7 ++ build.gradle.kts | 3 +- .../dto/response/GetContentResponse.kt | 42 +++++++++ .../kotlin/com/pokit/content/model/Content.kt | 4 +- .../kotlin/com/pokit/log/model/UserLog.kt | 7 ++ 23 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/config/QueryDslConfig.kt create mode 100644 adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/UserLogAdapter.kt create mode 100644 adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogRepository.kt create mode 100644 adapters/out-persistence/src/test/resources/application-local.yml create mode 100644 application/src/main/kotlin/com/pokit/log/port/out/UserLogPort.kt create mode 100644 domain/src/main/kotlin/com/pokit/content/dto/response/GetContentResponse.kt create mode 100644 domain/src/main/kotlin/com/pokit/log/model/UserLog.kt diff --git a/adapters/in-web/src/main/kotlin/com/pokit/content/ContentController.kt b/adapters/in-web/src/main/kotlin/com/pokit/content/ContentController.kt index 18ac55e3..3d66a70a 100644 --- a/adapters/in-web/src/main/kotlin/com/pokit/content/ContentController.kt +++ b/adapters/in-web/src/main/kotlin/com/pokit/content/ContentController.kt @@ -4,7 +4,9 @@ import com.pokit.auth.config.ErrorOperation import com.pokit.auth.model.PrincipalUser import com.pokit.auth.model.toDomain import com.pokit.category.exception.CategoryErrorCode +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.request.CreateContentRequest import com.pokit.content.dto.request.UpdateContentRequest @@ -13,9 +15,13 @@ 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 +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -87,4 +93,35 @@ class ContentController( return contentUseCase.cancelBookmark(user, contentId) .wrapUnit() } + + @GetMapping("/{categoryId}") + @Operation(summary = "카테고리 내 컨텐츠 목록 조회") + fun getContents( + @AuthenticationPrincipal user: PrincipalUser, + @PathVariable("categoryId") contentId: 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>? { + return contentUseCase.getContents(user.id, contentId, pageable, isRead, favorites) + .wrapSlice() + .wrapOk() + } + + @PostMapping("/{contentId}") + @Operation(summary = "컨텐츠 상세조회 API") + fun getContent( + @AuthenticationPrincipal user: PrincipalUser, + @PathVariable("contentId") contentId: Long + ): ResponseEntity { + return contentUseCase.getContent(user.id, contentId) + .toResponse() + .wrapOk() + } } + diff --git a/adapters/in-web/src/main/kotlin/com/pokit/content/dto/response/ContentResponse.kt b/adapters/in-web/src/main/kotlin/com/pokit/content/dto/response/ContentResponse.kt index 702d92b1..06bf3fa4 100644 --- a/adapters/in-web/src/main/kotlin/com/pokit/content/dto/response/ContentResponse.kt +++ b/adapters/in-web/src/main/kotlin/com/pokit/content/dto/response/ContentResponse.kt @@ -1,19 +1,37 @@ package com.pokit.content.dto.response +import com.fasterxml.jackson.annotation.JsonFormat import com.pokit.content.model.Content data class ContentResponse( val contentId: Long, + val categoryId: Long, val data: String, val title: String, val memo: String, - val alertYn: String + val alertYn: String, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:") + val createdAt: String, + val favorites: Boolean = false ) fun Content.toResponse() = ContentResponse( contentId = this.id, + categoryId = this.categoryId, data = this.data, title = this.title, memo = this.memo, - alertYn = this.alertYn + alertYn = this.alertYn, + createdAt = this.createdAt.toString() +) + +fun GetContentResponse.toResponse() = ContentResponse( + contentId = this.contentId, + categoryId = this.categoryId, + data = this.data, + title = this.title, + memo = this.memo, + alertYn = this.alertYn, + createdAt = this.createdAt.toString(), + favorites = this.favorites ) diff --git a/adapters/out-persistence/build.gradle.kts b/adapters/out-persistence/build.gradle.kts index 95d650fa..19fc15f2 100644 --- a/adapters/out-persistence/build.gradle.kts +++ b/adapters/out-persistence/build.gradle.kts @@ -27,6 +27,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("com.mysql:mysql-connector-j") + // QueryDSL + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + kapt("com.querydsl:querydsl-apt:5.0.0:jakarta") + kapt("jakarta.annotation:jakarta.annotation-api") + kapt("jakarta.persistence:jakarta.persistence-api") + // 테스팅 testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.1") @@ -40,3 +46,25 @@ tasks { withType { enabled = true } withType { enabled = false } } + +val generated = file("src/main/generated") + +tasks.withType { + options.generatedSourceOutputDirectory.set(generated) +} + +sourceSets { + main { + kotlin.srcDirs += generated + } +} + +tasks.named("clean") { + doLast { + generated.deleteRecursively() + } +} + +kapt { + generateStubs = true +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/impl/BookMarkAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/impl/BookMarkAdapter.kt index 09dcf529..394f7b9e 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/impl/BookMarkAdapter.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/impl/BookMarkAdapter.kt @@ -24,4 +24,12 @@ class BookMarkAdapter( false )?.delete() } + + override fun loadByContentIdAndUserId(contentId: Long, userId: Long): Bookmark? { + return bookMarkRepository.findByContentIdAndUserIdAndDeleted( + contentId, + userId, + false + )?.toDomain() + } } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/persist/BookmarkEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/persist/BookmarkEntity.kt index d0cdd43f..08fb6e3a 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/persist/BookmarkEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/bookmark/persist/BookmarkEntity.kt @@ -19,7 +19,7 @@ class BookmarkEntity( val userId: Long, @Column(name = "deleted") - var deleted: Boolean = true + var deleted: Boolean = false ) { fun delete() { this.deleted = true diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/category/persist/CategoryEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/category/persist/CategoryEntity.kt index 7ebd5473..5ea0c54c 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/category/persist/CategoryEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/category/persist/CategoryEntity.kt @@ -20,7 +20,7 @@ class CategoryEntity( var name: String, @OneToOne - @JoinColumn(name = "image_id") + @JoinColumn(name = "image_id", foreignKey = ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) val image: CategoryImageEntity, ) : BaseEntity() { diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/config/QueryDslConfig.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/config/QueryDslConfig.kt new file mode 100644 index 00000000..352fa8bc --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/config/QueryDslConfig.kt @@ -0,0 +1,16 @@ +package com.pokit.out.persistence.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class QueryDslConfig { + @PersistenceContext + lateinit var entityManager: EntityManager + + @Bean + fun queryFactory() = JPAQueryFactory(entityManager) +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt index f609ca5b..a7aacd37 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt @@ -2,15 +2,30 @@ package com.pokit.out.persistence.content.impl import com.pokit.content.model.Content import com.pokit.content.port.out.ContentPort +import com.pokit.log.model.LogType +import com.pokit.out.persistence.bookmark.persist.QBookmarkEntity.bookmarkEntity +import com.pokit.out.persistence.category.persist.QCategoryEntity.categoryEntity import com.pokit.out.persistence.content.persist.ContentEntity 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.types.dsl.DateTimePath +import com.querydsl.jpa.impl.JPAQuery +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +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.LocalDateTime @Repository class ContentAdapter( - private val contentRepository: ContentRepository + private val contentRepository: ContentRepository, + private val queryFactory: JPAQueryFactory ) : ContentPort { override fun loadByUserIdAndId(userId: Long, id: Long) = contentRepository.findByUserIdAndIdAndDeleted(userId, id) ?.run { toDomain() } @@ -28,4 +43,74 @@ class ContentAdapter( override fun fetchContentCountByCategoryId(categoryId: Long): Int = contentRepository.countByCategoryId(categoryId) + + override fun loadAllByUserIdAndContentId( + userId: Long, + categoryId: Long, + pageable: Pageable, + read: Boolean?, + favorites: Boolean? + ): Slice { + var hasNext = false + val order = pageable.sort.getOrderFor("createdAt") + + val query = queryFactory.select(contentEntity) + .from(contentEntity) + .join(categoryEntity).on(categoryEntity.id.eq(contentEntity.categoryId)) + .join(userEntity).on(userEntity.id.eq(categoryEntity.userId)) + + FavoriteOrNot(favorites, query) // 북마크 조인 여부 + ReadOrNot(read, query) // 읽음 로그 조인 여부 + + query.where( + userEntity.id.eq(userId), + categoryEntity.id.eq(categoryId), + contentEntity.deleted.isFalse + ) + .orderBy(getSort(contentEntity.createdAt, order!!)) + .limit(pageable.pageSize + 1L) + + + val contentEntityList = query.fetch() + + if (contentEntityList.size > pageable.pageSize) { + hasNext = true + contentEntityList.removeAt(contentEntityList.size - 1) + } + + val contents = contentEntityList.map { it.toDomain() } + + return SliceImpl(contents, pageable, hasNext) + } + + private fun ReadOrNot( + read: Boolean?, + query: JPAQuery + ): JPAQuery? { + return read + ?.let { + query + .leftJoin(userLogEntity) + .on(userLogEntity.contentId.eq(contentEntity.id)) + .where(userLogEntity.id.isNull.or(userLogEntity.type.ne(LogType.READ))) + } + } + + private fun FavoriteOrNot( + favorites: Boolean?, + query: JPAQuery + ): JPAQuery? { + return favorites + ?.let { + query + .join(bookmarkEntity) + .on(bookmarkEntity.contentId.eq(contentEntity.id) + .and(bookmarkEntity.deleted.isFalse)) + } + } + + private fun getSort(property: DateTimePath, order: Sort.Order) = + if (order.isDescending) property.desc() + else property.asc() + } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentEntity.kt index 367014f8..f2ca65d5 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentEntity.kt @@ -60,4 +60,5 @@ fun ContentEntity.toDomain() = Content( title = this.title, memo = this.memo, alertYn = this.alertYn, + createdAt = this.createdAt ) diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/UserLogAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/UserLogAdapter.kt new file mode 100644 index 00000000..c9ffa74d --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/UserLogAdapter.kt @@ -0,0 +1,16 @@ +package com.pokit.out.persistence.log + +import com.pokit.log.model.UserLog +import com.pokit.log.port.out.UserLogPort +import com.pokit.out.persistence.log.persist.UserLogEntity +import com.pokit.out.persistence.log.persist.UserLogRepository +import com.pokit.out.persistence.log.persist.toDomain +import org.springframework.stereotype.Repository + +@Repository +class UserLogAdapter( + private val userLogRepository: UserLogRepository +) : UserLogPort { + override fun persist(userLog: UserLog) = + userLogRepository.save(UserLogEntity.of(userLog)).toDomain() +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogEntity.kt index 8f88b9ca..c4192dbd 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogEntity.kt @@ -1,6 +1,7 @@ package com.pokit.out.persistence.log.persist import com.pokit.log.model.LogType +import com.pokit.log.model.UserLog import com.pokit.out.persistence.BaseEntity import jakarta.persistence.* @@ -21,4 +22,17 @@ class UserLogEntity( @Enumerated(EnumType.STRING) val type: LogType, ) : BaseEntity() { + companion object { + fun of(userLog: UserLog) = UserLogEntity( + contentId = userLog.contentId, + userId = userLog.contentId, + type = userLog.type + ) + } } + +internal fun UserLogEntity.toDomain() = UserLog( + contentId = this.contentId, + userId = this.userId, + type = this.type +) diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogRepository.kt new file mode 100644 index 00000000..002e3dbc --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/log/persist/UserLogRepository.kt @@ -0,0 +1,7 @@ +package com.pokit.out.persistence.log.persist + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserLogRepository : JpaRepository { + fun findByContentIdAndUserId(contentId: Long, userId: Long): UserLogEntity? +} diff --git a/adapters/out-persistence/src/test/kotlin/com/pokit/out/persistence/content/impl/ContentAdapterTest.kt b/adapters/out-persistence/src/test/kotlin/com/pokit/out/persistence/content/impl/ContentAdapterTest.kt index 73450269..e9cf42c4 100644 --- a/adapters/out-persistence/src/test/kotlin/com/pokit/out/persistence/content/impl/ContentAdapterTest.kt +++ b/adapters/out-persistence/src/test/kotlin/com/pokit/out/persistence/content/impl/ContentAdapterTest.kt @@ -1,12 +1,20 @@ package com.pokit.out.persistence.content.impl +import com.pokit.bookmark.BookmarkFixture import com.pokit.category.CategoryFixture import com.pokit.category.model.CategoryImage import com.pokit.content.ContentFixture +import com.pokit.log.model.LogType +import com.pokit.log.model.UserLog +import com.pokit.out.persistence.bookmark.persist.BookMarkRepository +import com.pokit.out.persistence.bookmark.persist.BookmarkEntity 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.toDomain +import com.pokit.out.persistence.log.persist.UserLogEntity +import com.pokit.out.persistence.log.persist.UserLogRepository import com.pokit.out.persistence.user.persist.UserEntity import com.pokit.out.persistence.user.persist.UserRepository import com.pokit.support.TestContainerSupport @@ -17,18 +25,24 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Repository import org.springframework.test.context.ContextConfiguration @DataJpaTest(includeFilters = [ComponentScan.Filter(Repository::class)]) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration(initializers = [TestContainerSupport::class]) +@Import(QueryDslConfig::class) class ContentAdapterTest( @Autowired private val contentAdapter: ContentAdapter, @Autowired private val contentRepository: ContentRepository, @Autowired private val userRepository: UserRepository, @Autowired private val categoryRepository: CategoryRepository, - @Autowired private val categoryImageRepository: CategoryImageRepository + @Autowired private val categoryImageRepository: CategoryImageRepository, + @Autowired private val bookMarkRepository: BookMarkRepository, + @Autowired private val userLogRepository: UserLogRepository ) : BehaviorSpec({ Given("유저의 컨텐츠 관련 DB 작업을 수행할 때") { @@ -47,6 +61,17 @@ class ContentAdapterTest( val content = ContentFixture.getContent(savedCategory.id) val savedContent = contentRepository.save(ContentEntity.of(content)).toDomain() + val content2 = ContentFixture.getContent(savedCategory.id) + val savedContent2 = contentRepository.save(ContentEntity.of(content2)) + + val bookmark = BookmarkFixture.getBookmark(savedContent2.id, savedUser.id) + bookMarkRepository.save(BookmarkEntity.of(bookmark)) + + val userLog = UserLog(savedContent2.id, savedUser.id, LogType.READ) + userLogRepository.save(UserLogEntity.of(userLog)) + + val pageRequest = PageRequest.of(0, 10, Sort.by("createdAt").descending()) + When("유저 아이디와 컨텐츠 아이디로 조회 시") { val findContent = contentAdapter.loadByUserIdAndId(savedUser.id, savedContent.id)!! Then("해당 유저가 저장한 컨텐츠가 조회된다.") { @@ -65,5 +90,35 @@ class ContentAdapterTest( findContent shouldBe null } } + + When("특정 카테고리 내의 컨텐츠 목록을 조회할 때") { + When("즐겨찾기한 컨텐츠만 조회하면") { + val result = contentAdapter.loadAllByUserIdAndContentId( + savedUser.id, savedCategory.id, pageRequest, null, true + ) + Then("두개의 컨텐츠 중 즐겨찾기 한 하나의 컨텐츠만 조회된다.") { + result.content.size shouldBe 1 + val favoriteContent = result.content[0] + favoriteContent.id shouldBe savedContent2.id + } + } + When("필터링 조건이 아무것도 없다면") { + val result = contentAdapter.loadAllByUserIdAndContentId( + savedUser.id, savedCategory.id, pageRequest, null, null + ) + Then("목록이 전체 조회된다.") { + result.content.size shouldBe 2 + } + } + When("안 읽은 컨텐츠를 조회하면") { + val result = contentAdapter.loadAllByUserIdAndContentId( + savedUser.id, savedCategory.id, pageRequest, false, null + ) + Then("안 읽은 컨텐츠 하나만 조회된다 (savedContent2)") { + result.content.size shouldBe 1 + result.content[0].id shouldBe savedContent.id + } + } + } } }) diff --git a/adapters/out-persistence/src/test/resources/application-local.yml b/adapters/out-persistence/src/test/resources/application-local.yml new file mode 100644 index 00000000..6a3f18f3 --- /dev/null +++ b/adapters/out-persistence/src/test/resources/application-local.yml @@ -0,0 +1,12 @@ +spring: + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + default_batch_fetch_size: 10 +logging.level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace \ No newline at end of file diff --git a/application/src/main/kotlin/com/pokit/bookmark/port/out/BookmarkPort.kt b/application/src/main/kotlin/com/pokit/bookmark/port/out/BookmarkPort.kt index 3b9645cd..c475622e 100644 --- a/application/src/main/kotlin/com/pokit/bookmark/port/out/BookmarkPort.kt +++ b/application/src/main/kotlin/com/pokit/bookmark/port/out/BookmarkPort.kt @@ -6,4 +6,6 @@ interface BookmarkPort { fun persist(bookmark: Bookmark): Bookmark fun delete(userId: Long, contentId: Long) + + fun loadByContentIdAndUserId(contentId: Long, userId: Long): Bookmark? } diff --git a/application/src/main/kotlin/com/pokit/content/port/in/ContentUseCase.kt b/application/src/main/kotlin/com/pokit/content/port/in/ContentUseCase.kt index 64086382..0e57895e 100644 --- a/application/src/main/kotlin/com/pokit/content/port/in/ContentUseCase.kt +++ b/application/src/main/kotlin/com/pokit/content/port/in/ContentUseCase.kt @@ -2,8 +2,11 @@ package com.pokit.content.port.`in` import com.pokit.content.dto.ContentCommand import com.pokit.content.dto.response.BookMarkContentResponse +import com.pokit.content.dto.response.GetContentResponse import com.pokit.content.model.Content import com.pokit.user.model.User +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice interface ContentUseCase { fun bookmarkContent(user: User, contentId: Long): BookMarkContentResponse @@ -14,4 +17,14 @@ interface ContentUseCase { fun delete(user: User, contentId: Long) fun cancelBookmark(user: User, contentId: Long) + + fun getContents( + userId: Long, + categoryId: Long, + pageable: Pageable, + isRead: Boolean?, + favorites: Boolean? + ): Slice + + fun getContent(userId: Long, contentId: Long): GetContentResponse } diff --git a/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt b/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt index 8a448a00..f3920a4f 100644 --- a/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt +++ b/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt @@ -1,6 +1,8 @@ package com.pokit.content.port.out import com.pokit.content.model.Content +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice interface ContentPort { fun loadByUserIdAndId(userId: Long, id: Long): Content? @@ -10,4 +12,12 @@ interface ContentPort { fun delete(content: Content) fun fetchContentCountByCategoryId(categoryId: Long): Int + + fun loadAllByUserIdAndContentId( + userId: Long, + categoryId: Long, + pageable: Pageable, + read: Boolean?, + favorites: Boolean? + ): Slice } diff --git a/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt b/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt index 9e6679e6..4a0628c0 100644 --- a/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt +++ b/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt @@ -8,12 +8,19 @@ import com.pokit.category.port.out.CategoryPort import com.pokit.common.exception.NotFoundCustomException import com.pokit.content.dto.ContentCommand import com.pokit.content.dto.response.BookMarkContentResponse +import com.pokit.content.dto.response.GetContentResponse +import com.pokit.content.dto.response.toGetContentResponse import com.pokit.content.dto.toDomain import com.pokit.content.exception.ContentErrorCode import com.pokit.content.model.Content import com.pokit.content.port.`in`.ContentUseCase import com.pokit.content.port.out.ContentPort +import com.pokit.log.model.LogType +import com.pokit.log.model.UserLog +import com.pokit.log.port.out.UserLogPort import com.pokit.user.model.User +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -22,7 +29,8 @@ import org.springframework.transaction.annotation.Transactional class ContentService( private val contentPort: ContentPort, private val bookMarkPort: BookmarkPort, - private val categoryPort: CategoryPort + private val categoryPort: CategoryPort, + private val userLogPort: UserLogPort ) : ContentUseCase { @Transactional @@ -62,6 +70,39 @@ class ContentService( bookMarkPort.delete(user.id, contentId) } + override fun getContents( + userId: Long, + categoryId: Long, + pageable: Pageable, + isRead: Boolean?, + favorites: Boolean? + ): Slice { + val contents = contentPort.loadAllByUserIdAndContentId( + userId, + categoryId, + pageable, + isRead, + favorites + ) + + return contents + } + + @Transactional + override fun getContent(userId: Long, contentId: Long): GetContentResponse { + val userLog = UserLog( + contentId, userId, LogType.READ + ) + userLogPort.persist(userLog) // 읽음 처리 + + val content = verifyContent(userId, contentId) + val bookmark = bookMarkPort.loadByContentIdAndUserId(contentId, userId) + + return bookmark + ?.let { content.toGetContentResponse(it) } // 즐겨찾기 true + ?: content.toGetContentResponse() // 즐겨찾기 false + } + private fun verifyContent(userId: Long, contentId: Long): Content { return contentPort.loadByUserIdAndId(userId, contentId) ?: throw NotFoundCustomException(ContentErrorCode.NOT_FOUND_CONTENT) diff --git a/application/src/main/kotlin/com/pokit/log/port/out/UserLogPort.kt b/application/src/main/kotlin/com/pokit/log/port/out/UserLogPort.kt new file mode 100644 index 00000000..6d111036 --- /dev/null +++ b/application/src/main/kotlin/com/pokit/log/port/out/UserLogPort.kt @@ -0,0 +1,7 @@ +package com.pokit.log.port.out + +import com.pokit.log.model.UserLog + +interface UserLogPort { + fun persist(userLog: UserLog): UserLog +} diff --git a/build.gradle.kts b/build.gradle.kts index 61eb78db..797cdf11 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "1.9.24" id("org.jlleitschuh.gradle.ktlint") version "12.1.1" + kotlin("kapt") version "1.9.21" } repositories { @@ -39,7 +40,7 @@ subprojects { // kotest testImplementation("io.kotest:kotest-runner-junit5-jvm:5.8.1") testImplementation("io.kotest:kotest-assertions-core-jvm:5.8.1") - + // logging implementation("io.github.oshai:kotlin-logging-jvm:7.0.0") } diff --git a/domain/src/main/kotlin/com/pokit/content/dto/response/GetContentResponse.kt b/domain/src/main/kotlin/com/pokit/content/dto/response/GetContentResponse.kt new file mode 100644 index 00000000..d14044dd --- /dev/null +++ b/domain/src/main/kotlin/com/pokit/content/dto/response/GetContentResponse.kt @@ -0,0 +1,42 @@ +package com.pokit.content.dto.response + +import com.pokit.bookmark.model.Bookmark +import com.pokit.content.model.Content +import java.time.LocalDateTime + +data class GetContentResponse( + val contentId: Long, + val categoryId: Long, + val data: String, + val title: String, + val memo: String, + val alertYn: String, + val createdAt: LocalDateTime, + val favorites: Boolean = false +) + +fun Content.toGetContentResponse(bookmark: Bookmark): GetContentResponse { + return GetContentResponse( + contentId = this.id, + categoryId = this.categoryId, + data = this.data, + title = this.title, + memo = this.memo, + alertYn = this.alertYn, + createdAt = this.createdAt, + favorites = true + ) +} + +fun Content.toGetContentResponse(): GetContentResponse { + return GetContentResponse( + contentId = this.id, + categoryId = this.categoryId, + data = this.data, + title = this.title, + memo = this.memo, + alertYn = this.alertYn, + createdAt = this.createdAt, + favorites = false + ) +} diff --git a/domain/src/main/kotlin/com/pokit/content/model/Content.kt b/domain/src/main/kotlin/com/pokit/content/model/Content.kt index 8881ba01..8e6f17f3 100644 --- a/domain/src/main/kotlin/com/pokit/content/model/Content.kt +++ b/domain/src/main/kotlin/com/pokit/content/model/Content.kt @@ -1,6 +1,7 @@ package com.pokit.content.model import com.pokit.content.dto.ContentCommand +import java.time.LocalDateTime data class Content( val id: Long = 0L, @@ -10,8 +11,9 @@ data class Content( var title: String, var memo: String, var alertYn: String, + val createdAt: LocalDateTime = LocalDateTime.now() ) { - fun modify(contetCommand: ContentCommand){ + fun modify(contetCommand: ContentCommand) { this.categoryId = contetCommand.categoryId this.data = contetCommand.data this.title = contetCommand.title diff --git a/domain/src/main/kotlin/com/pokit/log/model/UserLog.kt b/domain/src/main/kotlin/com/pokit/log/model/UserLog.kt new file mode 100644 index 00000000..9b09711b --- /dev/null +++ b/domain/src/main/kotlin/com/pokit/log/model/UserLog.kt @@ -0,0 +1,7 @@ +package com.pokit.log.model + +data class UserLog( + val contentId: Long, + val userId: Long, + val type: LogType +)