From 28d00e86c39e7992b6f95d13b2e8be2e8be4d0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=ED=9B=88?= Date: Sun, 21 Jul 2024 22:09:22 +0900 Subject: [PATCH] =?UTF-8?q?[Feat/#221]=20=EC=95=84=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20(#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ARTICLE_VIEW_COUNT 테이블 생성, 아티클 조회수 순 정렬 등에 사용 예정 * fix: ARTICLE_VIEW_COUNT 테이블 deleted_at 추가 * feat: 아티클 조회시 article view his가 아닌 article view count를 보도록 수정(SL수정: count -> select) * test: ReadArticleUseCaseTest 수정 반영 * feat: ArticleViewCountHandler 추가 * fix: view_count 컬럼 기준 내림차순 정렬 인덱스로 변경 * feat: category_cd 컬럼 추가 in ARTICLE_VIEW_COUNT 테이블 and 인덱싱 * test: category_cd 컬럼 추가 테스트 반영 * refactor: article category 유효하지 않을 경우에 대한 검증 추가 * feat: article.invalid.category 에러코드 추가 --------- Co-authored-by: belljun3395 <195850@jnu.ac.kr> --- .../repo/dao/article/ArticleViewCountDao.kt | 31 +++++++++++++++++++ .../command/ArticleViewCountCommand.kt | 5 +++ .../article/query/ArticleViewCountQuery.kt | 8 +++++ .../handler/ArticleViewCountHandler.kt | 18 +++++++++++ .../handler/ArticleViewHisAsyncHandler.kt | 14 +++++++-- .../article/usecase/ReadArticleUseCase.kt | 15 +++++---- .../resources/messages/article.properties | 1 + .../article/usecase/ReadArticleUseCaseTest.kt | 18 +++++------ .../V1.00.0.14__manage_article_view_count.sql | 16 ++++++++++ 9 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleViewCountDao.kt create mode 100644 api-repo/src/main/kotlin/com/few/api/repo/dao/article/command/ArticleViewCountCommand.kt create mode 100644 api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/ArticleViewCountQuery.kt create mode 100644 api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewCountHandler.kt create mode 100644 data/db/migration/entity/V1.00.0.14__manage_article_view_count.sql diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleViewCountDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleViewCountDao.kt new file mode 100644 index 000000000..d1cd1c406 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleViewCountDao.kt @@ -0,0 +1,31 @@ +package com.few.api.repo.dao.article + +import com.few.api.repo.dao.article.command.ArticleViewCountCommand +import com.few.api.repo.dao.article.query.ArticleViewCountQuery +import jooq.jooq_dsl.Tables.ARTICLE_VIEW_COUNT +import org.jooq.DSLContext +import org.springframework.stereotype.Repository + +@Repository +class ArticleViewCountDao( + private val dslContext: DSLContext, +) { + + fun upsertArticleViewCount(query: ArticleViewCountQuery) { + dslContext.insertInto(ARTICLE_VIEW_COUNT) + .set(ARTICLE_VIEW_COUNT.ARTICLE_ID, query.articleId) + .set(ARTICLE_VIEW_COUNT.VIEW_COUNT, 1) + .set(ARTICLE_VIEW_COUNT.CATEGORY_CD, query.categoryType.code) + .onDuplicateKeyUpdate() + .set(ARTICLE_VIEW_COUNT.VIEW_COUNT, ARTICLE_VIEW_COUNT.VIEW_COUNT.plus(1)) + } + + fun selectArticleViewCount(command: ArticleViewCountCommand): Long? { + return dslContext.select( + ARTICLE_VIEW_COUNT.VIEW_COUNT + ).from(ARTICLE_VIEW_COUNT) + .where(ARTICLE_VIEW_COUNT.ARTICLE_ID.eq(command.articleId)) + .and(ARTICLE_VIEW_COUNT.DELETED_AT.isNull) + .fetchOneInto(Long::class.java) + } +} \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/command/ArticleViewCountCommand.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/command/ArticleViewCountCommand.kt new file mode 100644 index 000000000..1b90d8fa7 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/command/ArticleViewCountCommand.kt @@ -0,0 +1,5 @@ +package com.few.api.repo.dao.article.command + +data class ArticleViewCountCommand( + val articleId: Long, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/ArticleViewCountQuery.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/ArticleViewCountQuery.kt new file mode 100644 index 000000000..812695f0a --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/ArticleViewCountQuery.kt @@ -0,0 +1,8 @@ +package com.few.api.repo.dao.article.query + +import com.few.data.common.code.CategoryType + +data class ArticleViewCountQuery( + val articleId: Long, + val categoryType: CategoryType, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewCountHandler.kt b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewCountHandler.kt new file mode 100644 index 000000000..f8127ef21 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewCountHandler.kt @@ -0,0 +1,18 @@ +package com.few.api.domain.article.handler + +import com.few.api.repo.dao.article.ArticleViewCountDao +import com.few.api.repo.dao.article.command.ArticleViewCountCommand +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +@Component +class ArticleViewCountHandler( + private val articleViewCountDao: ArticleViewCountDao, +) { + @Transactional(isolation = Isolation.READ_UNCOMMITTED, propagation = Propagation.REQUIRES_NEW) + fun browseArticleViewCount(articleId: Long): Long { + return (articleViewCountDao.selectArticleViewCount(ArticleViewCountCommand(articleId)) ?: 0L) + 1L + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt index c8a22ac3f..020965d16 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt @@ -1,8 +1,11 @@ package com.few.api.domain.article.handler import com.few.api.config.DatabaseAccessThreadPoolConfig.Companion.DATABASE_ACCESS_POOL +import com.few.api.repo.dao.article.ArticleViewCountDao import com.few.api.repo.dao.article.ArticleViewHisDao import com.few.api.repo.dao.article.command.ArticleViewHisCommand +import com.few.api.repo.dao.article.query.ArticleViewCountQuery +import com.few.data.common.code.CategoryType import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component @@ -11,17 +14,24 @@ import org.springframework.transaction.annotation.Transactional @Component class ArticleViewHisAsyncHandler( private val articleViewHisDao: ArticleViewHisDao, + private val articleViewCountDao: ArticleViewCountDao, ) { private val log = KotlinLogging.logger {} @Async(value = DATABASE_ACCESS_POOL) @Transactional - fun addArticleViewHis(articleId: Long, memberId: Long) { + fun addArticleViewHis(articleId: Long, memberId: Long, categoryType: CategoryType) { try { articleViewHisDao.insertArticleViewHis(ArticleViewHisCommand(articleId, memberId)) log.debug { "Successfully inserted article view history for articleId: $articleId and memberId: $memberId" } + + articleViewCountDao.upsertArticleViewCount(ArticleViewCountQuery(articleId, categoryType)) + log.debug { "Successfully upserted article view count for articleId: $articleId and memberId: $memberId" } } catch (e: Exception) { - log.error { "Failed to insert article view history for articleId: $articleId and memberId: $memberId" } + log.error { + "Failed insertion article view history and upsertion article view count " + + "for articleId: $articleId and memberId: $memberId" + } } } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCase.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCase.kt index fc80437e4..7be0bf30f 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCase.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCase.kt @@ -1,5 +1,6 @@ package com.few.api.domain.article.usecase +import com.few.api.domain.article.handler.ArticleViewCountHandler import com.few.api.domain.article.handler.ArticleViewHisAsyncHandler import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseOut @@ -10,8 +11,6 @@ import com.few.api.domain.article.service.dto.BrowseArticleProblemIdsInDto import com.few.api.domain.article.service.dto.ReadWriterRecordInDto import com.few.api.exception.common.NotFoundException import com.few.api.repo.dao.article.ArticleDao -import com.few.api.repo.dao.article.ArticleViewHisDao -import com.few.api.repo.dao.article.query.ArticleViewHisCountQuery import com.few.api.repo.dao.article.query.SelectArticleRecordQuery import com.few.data.common.code.CategoryType import org.springframework.stereotype.Component @@ -22,8 +21,8 @@ class ReadArticleUseCase( private val articleDao: ArticleDao, private val readArticleWriterRecordService: ReadArticleWriterRecordService, private val browseArticleProblemsService: BrowseArticleProblemsService, - private val articleViewHisDao: ArticleViewHisDao, private val articleViewHisAsyncHandler: ArticleViewHisAsyncHandler, + private val articleViewCountHandler: ArticleViewCountHandler, ) { @Transactional(readOnly = true) @@ -41,9 +40,13 @@ class ReadArticleUseCase( browseArticleProblemsService.execute(query) } - val views = (articleViewHisDao.countArticleViews(ArticleViewHisCountQuery(useCaseIn.articleId)) ?: 0L) + 1L - - articleViewHisAsyncHandler.addArticleViewHis(useCaseIn.articleId, useCaseIn.memberId) + // ARTICLE VIEW HIS에 저장하기 전에 먼저 VIEW COUNT 조회하는 순서 변경 금지 + val views = articleViewCountHandler.browseArticleViewCount(useCaseIn.articleId) + articleViewHisAsyncHandler.addArticleViewHis( + useCaseIn.articleId, + useCaseIn.memberId, + CategoryType.fromCode(articleRecord.category) ?: throw NotFoundException("article.invalid.category") + ) return ReadArticleUseCaseOut( id = articleRecord.articleId, diff --git a/api/src/main/resources/messages/article.properties b/api/src/main/resources/messages/article.properties index 2d8bfbc17..71025d20e 100644 --- a/api/src/main/resources/messages/article.properties +++ b/api/src/main/resources/messages/article.properties @@ -1,2 +1,3 @@ article.notfound.id=\u0061\u0072\u0074\u0069\u0063\u006c\u0065\u002e\u006e\u006f\u0074\u0066\u006f\u0075\u006e\u0064\u002e\u0069\u0064 article.notfound.articleidworkbookid=\u0061\u0072\u0074\u0069\u0063\u006c\u0065\u002e\u006e\u006f\u0074\u0066\u006f\u0075\u006e\u0064\u002e\u0061\u0072\u0074\u0069\u0063\u006c\u0065\u0069\u0064\u0077\u006f\u0072\u006b\u0062\u006f\u006f\u006b\u0069 +article.invalid.category=\uc874\uc7ac\ud558\uc9c0\u0020\uc54a\ub294\u0020\uc544\ud2f0\ud074\u0020\uce74\ud14c\uace0\ub9ac\uc785\ub2c8\ub2e4\u000d diff --git a/api/src/test/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCaseTest.kt b/api/src/test/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCaseTest.kt index f333538f4..e832feb97 100644 --- a/api/src/test/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCaseTest.kt +++ b/api/src/test/kotlin/com/few/api/domain/article/usecase/ReadArticleUseCaseTest.kt @@ -1,5 +1,6 @@ package com.few.api.domain.article.usecase +import com.few.api.domain.article.handler.ArticleViewCountHandler import com.few.api.domain.article.handler.ArticleViewHisAsyncHandler import com.few.api.domain.article.service.BrowseArticleProblemsService import com.few.api.domain.article.service.ReadArticleWriterRecordService @@ -7,7 +8,6 @@ import com.few.api.domain.article.service.dto.BrowseArticleProblemsOutDto import com.few.api.domain.article.service.dto.ReadWriterOutDto import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn import com.few.api.repo.dao.article.ArticleDao -import com.few.api.repo.dao.article.ArticleViewHisDao import com.few.api.repo.dao.article.record.SelectArticleRecord import io.github.oshai.kotlinlogging.KotlinLogging import io.kotest.assertions.throwables.shouldThrow @@ -24,22 +24,22 @@ class ReadArticleUseCaseTest : BehaviorSpec({ lateinit var readArticleWriterRecordService: ReadArticleWriterRecordService lateinit var browseArticleProblemsService: BrowseArticleProblemsService lateinit var useCase: ReadArticleUseCase - lateinit var articleViewHisDao: ArticleViewHisDao lateinit var articleViewHisAsyncHandler: ArticleViewHisAsyncHandler + lateinit var articleViewCountHandler: ArticleViewCountHandler val useCaseIn = ReadArticleUseCaseIn(articleId = 1L, memberId = 1L) beforeContainer { articleDao = mockk() readArticleWriterRecordService = mockk() browseArticleProblemsService = mockk() - articleViewHisDao = mockk() articleViewHisAsyncHandler = mockk() + articleViewCountHandler = mockk() useCase = ReadArticleUseCase( articleDao, readArticleWriterRecordService, browseArticleProblemsService, - articleViewHisDao, - articleViewHisAsyncHandler + articleViewHisAsyncHandler, + articleViewCountHandler ) } @@ -64,8 +64,8 @@ class ReadArticleUseCaseTest : BehaviorSpec({ every { articleDao.selectArticleRecord(any()) } returns record every { readArticleWriterRecordService.execute(any()) } returns writerSvcOutDto every { browseArticleProblemsService.execute(any()) } returns probSvcOutDto - every { articleViewHisDao.countArticleViews(any()) } returns 1L - every { articleViewHisAsyncHandler.addArticleViewHis(any(), any()) } answers { + every { articleViewCountHandler.browseArticleViewCount(any()) } returns 1L + every { articleViewHisAsyncHandler.addArticleViewHis(any(), any(), any()) } answers { log.debug { "Inserting article view history asynchronously" } } @@ -75,8 +75,8 @@ class ReadArticleUseCaseTest : BehaviorSpec({ verify(exactly = 1) { articleDao.selectArticleRecord(any()) } verify(exactly = 1) { readArticleWriterRecordService.execute(any()) } verify(exactly = 1) { browseArticleProblemsService.execute(any()) } - verify(exactly = 1) { articleViewHisDao.countArticleViews(any()) } - verify(exactly = 1) { articleViewHisAsyncHandler.addArticleViewHis(any(), any()) } + verify(exactly = 1) { articleViewCountHandler.browseArticleViewCount(any()) } + verify(exactly = 1) { articleViewHisAsyncHandler.addArticleViewHis(any(), any(), any()) } } } diff --git a/data/db/migration/entity/V1.00.0.14__manage_article_view_count.sql b/data/db/migration/entity/V1.00.0.14__manage_article_view_count.sql new file mode 100644 index 000000000..2b647e9f8 --- /dev/null +++ b/data/db/migration/entity/V1.00.0.14__manage_article_view_count.sql @@ -0,0 +1,16 @@ +-- article 별 조회수 저장 테이블 +CREATE TABLE ARTICLE_VIEW_COUNT +( + article_id BIGINT NOT NULL, + view_count BIGINT NOT NULL, + category_cd TINYINT NOT NULL, + deleted_at TIMESTAMP NULL DEFAULT NULL, + CONSTRAINT article_view_count_pk PRIMARY KEY (article_id) +); + +-- 조회수 순으로 아티클 조회시 사용하기 위한 인덱스 +-- ex. SELECT * FROM ARTICLE_VIEW_COUNT ORDER BY view_count DESC LIMIT 10; +CREATE INDEX article_view_count_idx1 ON ARTICLE_VIEW_COUNT (view_count DESC); + +-- 카테고리 별 필터링을 위한 인덱스 +CREATE INDEX article_view_count_idx2 ON ARTICLE_VIEW_COUNT (category_cd);