diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleDao.kt index 317fab404..d6edb2219 100644 --- a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleDao.kt +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleDao.kt @@ -6,6 +6,7 @@ import com.few.api.repo.dao.article.command.InsertFullArticleRecordCommand import com.few.api.repo.dao.article.query.SelectArticleRecordQuery import com.few.api.repo.dao.article.query.SelectWorkBookArticleRecordQuery import com.few.api.repo.dao.article.query.SelectWorkbookMappedArticleRecordsQuery +import com.few.api.repo.dao.article.record.SelectArticleContentsRecord import com.few.api.repo.dao.article.record.SelectArticleRecord import com.few.api.repo.dao.article.record.SelectWorkBookArticleRecord import com.few.api.repo.dao.article.record.SelectWorkBookMappedArticleRecord @@ -99,6 +100,13 @@ class ArticleDao( return mstId.getValue(ArticleMst.ARTICLE_MST.ID) } + fun insertArticleIfoCommand( + mstId: Long, + command: InsertFullArticleRecordCommand, + ) = dslContext.insertInto(ArticleIfo.ARTICLE_IFO) + .set(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID, mstId) + .set(ArticleIfo.ARTICLE_IFO.CONTENT, command.content) + fun insertArticleMstCommand(command: InsertFullArticleRecordCommand) = dslContext.insertInto(ArticleMst.ARTICLE_MST) .set(ArticleMst.ARTICLE_MST.MEMBER_ID, command.writerId) @@ -106,10 +114,14 @@ class ArticleDao( .set(ArticleMst.ARTICLE_MST.TITLE, command.title) .set(ArticleMst.ARTICLE_MST.CATEGORY_CD, command.category) - fun insertArticleIfoCommand( - mstId: Long, - command: InsertFullArticleRecordCommand, - ) = dslContext.insertInto(ArticleIfo.ARTICLE_IFO) - .set(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID, mstId) - .set(ArticleIfo.ARTICLE_IFO.CONTENT, command.content) + fun selectArticleContents(articleIds: Set): List = + selectArticleContentsQuery(articleIds) + .fetchInto(SelectArticleContentsRecord::class.java) + + fun selectArticleContentsQuery(articleIds: Set) = dslContext.select( + ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.`as`(SelectArticleContentsRecord::articleId.name), + ArticleIfo.ARTICLE_IFO.CONTENT.`as`(SelectArticleContentsRecord::content.name) + ).from(ArticleIfo.ARTICLE_IFO) + .where(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.`in`(articleIds)) + .and(ArticleIfo.ARTICLE_IFO.DELETED_AT.isNull) } \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleMainCardDao.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleMainCardDao.kt new file mode 100644 index 000000000..3a06182eb --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/ArticleMainCardDao.kt @@ -0,0 +1,115 @@ +package com.few.api.repo.dao.article + +import jooq.jooq_dsl.tables.ArticleMst.ARTICLE_MST +import jooq.jooq_dsl.tables.Member.MEMBER +import com.few.api.repo.dao.article.record.ArticleMainCardRecord +import com.few.api.repo.dao.article.support.CommonJsonMapper +import com.few.api.repo.dao.article.support.ArticleMainCardMapper +import jooq.jooq_dsl.tables.ArticleMainCard.ARTICLE_MAIN_CARD +import jooq.jooq_dsl.tables.MappingWorkbookArticle.MAPPING_WORKBOOK_ARTICLE +import jooq.jooq_dsl.tables.Workbook.WORKBOOK +import org.jooq.* +import org.jooq.impl.DSL.* +import org.springframework.stereotype.Repository + +@Repository +class ArticleMainCardDao( + private val dslContext: DSLContext, + private val commonJsonMapper: CommonJsonMapper, + private val articleMainCardMapper: ArticleMainCardMapper, +) { + + fun selectArticleMainCardsRecord(articleIds: Set): Set { + return selectArticleMainCardsRecordQuery(articleIds) + .fetch(articleMainCardMapper) + .toSet() + } + + private fun selectArticleMainCardsRecordQuery(articleIds: Set) = dslContext.select( + ARTICLE_MAIN_CARD.ID.`as`(ArticleMainCardRecord::articleId.name), + ARTICLE_MAIN_CARD.TITLE.`as`(ArticleMainCardRecord::articleTitle.name), + ARTICLE_MAIN_CARD.MAIN_IMAGE_URL.`as`(ArticleMainCardRecord::mainImageUrl.name), + ARTICLE_MAIN_CARD.CATEGORY_CD.`as`(ArticleMainCardRecord::categoryCd.name), + ARTICLE_MAIN_CARD.CREATED_AT.`as`(ArticleMainCardRecord::createdAt.name), + ARTICLE_MAIN_CARD.WRITER_ID.`as`(ArticleMainCardRecord::writerId.name), + ARTICLE_MAIN_CARD.WRITER_EMAIL.`as`(ArticleMainCardRecord::writerEmail.name), + jsonGetAttributeAsText( + ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, + "name" + ).`as`(ArticleMainCardRecord::writerName.name), + jsonGetAttribute(ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, "url").`as`(ArticleMainCardRecord::writerImgUrl.name), + ARTICLE_MAIN_CARD.WORKBOOKS.`as`(ArticleMainCardRecord::workbooks.name) + ).from(ARTICLE_MAIN_CARD) + .where(ARTICLE_MAIN_CARD.ID.`in`(articleIds)) + .query + + fun selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbook(articleIds: Set): Set { + return selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbookQuery(articleIds) + .fetch(articleMainCardMapper) + .toSet() + } + + private fun selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbookQuery(articleIds: Set) = + dslContext.select( + ARTICLE_MST.ID.`as`(ArticleMainCardRecord::articleId.name), + ARTICLE_MST.TITLE.`as`(ArticleMainCardRecord::articleTitle.name), + ARTICLE_MST.MAIN_IMAGE_URL.`as`(ArticleMainCardRecord::mainImageUrl.name), + ARTICLE_MST.CATEGORY_CD.`as`(ArticleMainCardRecord::categoryCd.name), + ARTICLE_MST.CREATED_AT.`as`(ArticleMainCardRecord::createdAt.name), + MEMBER.ID.`as`(ArticleMainCardRecord::writerId.name), + MEMBER.EMAIL.`as`(ArticleMainCardRecord::writerEmail.name), + jsonGetAttributeAsText(MEMBER.DESCRIPTION, "name").`as`(ArticleMainCardRecord::writerName.name), + jsonGetAttribute(MEMBER.DESCRIPTION, "url").`as`(ArticleMainCardRecord::writerImgUrl.name), + jsonArrayAgg( + jsonObject( + key("id").value(WORKBOOK.ID), + key("title").value(WORKBOOK.TITLE) + ) + ).`as`(ArticleMainCardRecord::workbooks.name) + ) + .from(ARTICLE_MST) + .join(MEMBER).on(ARTICLE_MST.MEMBER_ID.eq(MEMBER.ID)).and(ARTICLE_MST.DELETED_AT.isNull) + .and(MEMBER.DELETED_AT.isNull) + .leftJoin(MAPPING_WORKBOOK_ARTICLE).on(ARTICLE_MST.ID.eq(MAPPING_WORKBOOK_ARTICLE.ARTICLE_ID)).and(MAPPING_WORKBOOK_ARTICLE.DELETED_AT.isNull) + .leftJoin(WORKBOOK).on(MAPPING_WORKBOOK_ARTICLE.WORKBOOK_ID.eq(WORKBOOK.ID)).and(WORKBOOK.DELETED_AT.isNull) + .where(ARTICLE_MST.ID.`in`(articleIds)) + .groupBy(ARTICLE_MST.ID) + .query + + fun insertArticleMainCardsBulk(commands: Set) { + dslContext.batch( + commands.map { command -> insertArticleMainCardsBulkQuery(command) } + ).execute() + } + + fun insertArticleMainCardsBulkQuery(command: ArticleMainCardRecord) = + dslContext.insertInto( + ARTICLE_MAIN_CARD, + ARTICLE_MAIN_CARD.ID, + ARTICLE_MAIN_CARD.TITLE, + ARTICLE_MAIN_CARD.MAIN_IMAGE_URL, + ARTICLE_MAIN_CARD.CATEGORY_CD, + ARTICLE_MAIN_CARD.CREATED_AT, + ARTICLE_MAIN_CARD.WRITER_ID, + ARTICLE_MAIN_CARD.WRITER_EMAIL, + ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, + ARTICLE_MAIN_CARD.WORKBOOKS + ).values( + command.articleId, + command.articleTitle, + command.mainImageUrl.toString(), + command.categoryCd, + command.createdAt, + command.writerId, + command.writerEmail, + JSON.valueOf( + commonJsonMapper.toJsonStr( + mapOf( + "name" to command.writerName, + "url" to command.writerImgUrl + ) + ) + ), + JSON.valueOf(articleMainCardMapper.toJsonStr(command.workbooks)) + ) +} \ No newline at end of file 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 index 66926ac93..9aec5f988 100644 --- 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 @@ -2,8 +2,14 @@ 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 com.few.api.repo.dao.article.query.SelectArticlesOrderByViewsQuery +import com.few.api.repo.dao.article.query.SelectRankByViewsQuery +import com.few.api.repo.dao.article.record.SelectArticleViewsRecord import jooq.jooq_dsl.tables.ArticleViewCount.ARTICLE_VIEW_COUNT import org.jooq.DSLContext +import org.jooq.Record2 +import org.jooq.SelectLimitPercentStep +import org.jooq.impl.DSL.* import org.springframework.stereotype.Repository @Repository @@ -24,6 +30,13 @@ class ArticleViewCountDao( .onDuplicateKeyUpdate() .set(ARTICLE_VIEW_COUNT.VIEW_COUNT, ARTICLE_VIEW_COUNT.VIEW_COUNT.plus(1)) + fun insertArticleViewCountToZero(query: ArticleViewCountQuery) = insertArticleViewCountToZeroQuery(query).execute() + + fun insertArticleViewCountToZeroQuery(query: ArticleViewCountQuery) = dslContext.insertInto(ARTICLE_VIEW_COUNT) + .set(ARTICLE_VIEW_COUNT.ARTICLE_ID, query.articleId) + .set(ARTICLE_VIEW_COUNT.VIEW_COUNT, 0) + .set(ARTICLE_VIEW_COUNT.CATEGORY_CD, query.categoryType.code) + fun selectArticleViewCount(command: ArticleViewCountCommand): Long? { return selectArticleViewCountQuery(command).fetchOneInto(Long::class.java) } @@ -34,4 +47,52 @@ class ArticleViewCountDao( .where(ARTICLE_VIEW_COUNT.ARTICLE_ID.eq(command.articleId)) .and(ARTICLE_VIEW_COUNT.DELETED_AT.isNull) .query + + fun selectRankByViews(query: SelectRankByViewsQuery): Long? { + return selectRankByViewsQuery(query) + .fetchOneInto(Long::class.java) + } + + fun selectRankByViewsQuery(query: SelectRankByViewsQuery) = dslContext + .select(field("row_rank_tb.offset", Long::class.java)) + .from( + dslContext + .select( + ARTICLE_VIEW_COUNT.ARTICLE_ID, + rowNumber().over(orderBy(ARTICLE_VIEW_COUNT.VIEW_COUNT.desc())).`as`("offset") + ) + .from(ARTICLE_VIEW_COUNT) + .asTable("row_rank_tb") + ) + .where(field("row_rank_tb.${ARTICLE_VIEW_COUNT.ARTICLE_ID.name}")!!.eq(query.articleId)) + .query + + fun selectArticlesOrderByViews(query: SelectArticlesOrderByViewsQuery): Set { + return selectArticlesOrderByViewsQuery(query) + .fetchInto(SelectArticleViewsRecord::class.java) + .toSet() + } + + fun selectArticlesOrderByViewsQuery(query: SelectArticlesOrderByViewsQuery): SelectLimitPercentStep> { + val articleViewCountOffsetTb = select() + .from(ARTICLE_VIEW_COUNT) + .where(ARTICLE_VIEW_COUNT.DELETED_AT.isNull) + .orderBy(ARTICLE_VIEW_COUNT.VIEW_COUNT.desc()) + .limit(query.offset, Long.MAX_VALUE) + .asTable("article_view_count_offset_tb") + + val baseQuery = dslContext.select( + field("article_view_count_offset_tb.article_id").`as`(SelectArticleViewsRecord::articleId.name), + field("article_view_count_offset_tb.view_count").`as`(SelectArticleViewsRecord::views.name) + ) + .from(articleViewCountOffsetTb) + + return if (query.category != null) { + baseQuery.where(field("article_view_count_offset_tb.category_cd").eq(query.category.code)) + .limit(10) + } else { + baseQuery + .limit(10) + } // TODO: .query로 리턴하면 리턴 타입이 달라져 못받는 문제 + } } \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectArticlesOrderByViewsQuery.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectArticlesOrderByViewsQuery.kt new file mode 100644 index 000000000..e61dfbe9c --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectArticlesOrderByViewsQuery.kt @@ -0,0 +1,8 @@ +package com.few.api.repo.dao.article.query + +import com.few.data.common.code.CategoryType + +data class SelectArticlesOrderByViewsQuery( + val offset: Long, + val category: CategoryType?, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectRankByViewsQuery.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectRankByViewsQuery.kt new file mode 100644 index 000000000..ca8777e0c --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/query/SelectRankByViewsQuery.kt @@ -0,0 +1,5 @@ +package com.few.api.repo.dao.article.query + +data class SelectRankByViewsQuery( + val articleId: Long, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/ArticleMainCardRecord.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/ArticleMainCardRecord.kt new file mode 100644 index 000000000..3833a6634 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/ArticleMainCardRecord.kt @@ -0,0 +1,32 @@ +package com.few.api.repo.dao.article.record + +import java.net.URL +import java.time.LocalDateTime + +data class ArticleMainCardRecord( + val articleId: Long, + val articleTitle: String, + val mainImageUrl: URL, + val categoryCd: Byte, + val createdAt: LocalDateTime, + val writerId: Long, + val writerEmail: String, + val writerName: String, + val writerImgUrl: URL, + val workbooks: List = emptyList(), +) { + var content: String = "" + set(value) { + field = value + } + + var views: Long = 0L + set(value) { + field = value + } +} + +data class WorkbookRecord( + val id: Long?, + val title: String?, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleContentsRecord.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleContentsRecord.kt new file mode 100644 index 000000000..1b649fe95 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleContentsRecord.kt @@ -0,0 +1,6 @@ +package com.few.api.repo.dao.article.record + +data class SelectArticleContentsRecord( + val articleId: Long, + val content: String, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleViewsRecord.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleViewsRecord.kt new file mode 100644 index 000000000..e33b88b72 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/record/SelectArticleViewsRecord.kt @@ -0,0 +1,6 @@ +package com.few.api.repo.dao.article.record + +data class SelectArticleViewsRecord( + val articleId: Long, + val views: Long, +) \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/ArticleMainCardMapper.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/ArticleMainCardMapper.kt new file mode 100644 index 000000000..09b37ef92 --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/ArticleMainCardMapper.kt @@ -0,0 +1,36 @@ +package com.few.api.repo.dao.article.support + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.few.api.repo.dao.article.record.ArticleMainCardRecord +import com.few.api.repo.dao.article.record.WorkbookRecord +import org.jooq.JSON +import org.jooq.RecordMapper +import org.jooq.Record +import org.springframework.stereotype.Component +import java.net.URL +import java.time.LocalDateTime + +@Component +class ArticleMainCardMapper( + private val objectMapper: ObjectMapper, +) : RecordMapper { + override fun map(record: Record): ArticleMainCardRecord { + val workbooksJsonArrayStr: String = record.get(ArticleMainCardRecord::workbooks.name, JSON::class.java).data() + + return ArticleMainCardRecord( + articleId = record.get(ArticleMainCardRecord::articleId.name, Long::class.java), + articleTitle = record.get(ArticleMainCardRecord::articleTitle.name, String::class.java), + mainImageUrl = record.get(ArticleMainCardRecord::mainImageUrl.name, URL::class.java), + categoryCd = record.get(ArticleMainCardRecord::categoryCd.name, Byte::class.java), + createdAt = record.get(ArticleMainCardRecord::createdAt.name, LocalDateTime::class.java), + writerId = record.get(ArticleMainCardRecord::writerId.name, Long::class.java), + writerEmail = record.get(ArticleMainCardRecord::writerEmail.name, String::class.java), + writerName = record.get(ArticleMainCardRecord::writerName.name, String::class.java), + writerImgUrl = record.get(ArticleMainCardRecord::writerImgUrl.name, URL::class.java), + workbooks = objectMapper.readValue>(workbooksJsonArrayStr) + ) + } + + fun toJsonStr(workbooks: List) = objectMapper.writeValueAsString(workbooks) +} \ No newline at end of file diff --git a/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/CommonJsonMapper.kt b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/CommonJsonMapper.kt new file mode 100644 index 000000000..6906a316c --- /dev/null +++ b/api-repo/src/main/kotlin/com/few/api/repo/dao/article/support/CommonJsonMapper.kt @@ -0,0 +1,13 @@ +package com.few.api.repo.dao.article.support + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Component + +@Component +class CommonJsonMapper( // TODO: common 성 패키지 위치로 이동 + private val objectMapper: ObjectMapper, +) { + fun toJsonStr(map: Map): String { + return objectMapper.writeValueAsString(map) + } +} \ No newline at end of file diff --git a/api-repo/src/main/resources/application-api-repo-local.yml b/api-repo/src/main/resources/application-api-repo-local.yml index b34cdc23b..14bd2167f 100644 --- a/api-repo/src/main/resources/application-api-repo-local.yml +++ b/api-repo/src/main/resources/application-api-repo-local.yml @@ -1,7 +1,7 @@ spring: datasource: hikari: - jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true + jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&allowMultiQueries=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/api-repo/src/main/resources/application-api-repo-prd.yml b/api-repo/src/main/resources/application-api-repo-prd.yml index 163bc8b11..e22587a0c 100644 --- a/api-repo/src/main/resources/application-api-repo-prd.yml +++ b/api-repo/src/main/resources/application-api-repo-prd.yml @@ -1,7 +1,7 @@ spring: datasource: hikari: - jdbcUrl: ${DB_HOSTNAME}/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true + jdbcUrl: ${DB_HOSTNAME}/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&allowMultiQueries=true username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/api-repo/src/test/kotlin/com/few/api/repo/RepoTestContainerInitializer.kt b/api-repo/src/test/kotlin/com/few/api/repo/RepoTestContainerInitializer.kt index fe1f1ec55..f187895b2 100644 --- a/api-repo/src/test/kotlin/com/few/api/repo/RepoTestContainerInitializer.kt +++ b/api-repo/src/test/kotlin/com/few/api/repo/RepoTestContainerInitializer.kt @@ -1,13 +1,13 @@ package com.few.api.repo +import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.context.ApplicationContextInitializer import org.springframework.context.ConfigurableApplicationContext import org.testcontainers.containers.DockerComposeContainer import java.io.File class RepoTestContainerInitializer : ApplicationContextInitializer { - private val log: org.slf4j.Logger = - org.slf4j.LoggerFactory.getLogger(RepoTestContainerInitializer::class.java) + private val log = KotlinLogging.logger {} companion object { private const val MYSQL = "mysql" @@ -19,10 +19,10 @@ class RepoTestContainerInitializer : ApplicationContextInitializer { useCaseIn.contentSource } + else -> { throw IllegalArgumentException("Unsupported content type: ${useCaseIn.contentType}") } @@ -116,6 +121,12 @@ class AddArticleUseCase( problemDao.insertProblems(commands) } + ArticleViewCountQuery( + articleMstId, + CategoryType.fromCode(CategoryType.convertToCode(useCaseIn.category)) + ?: throw NotFoundException("article.invalid.category") + ).let { articleViewCountDao.insertArticleViewCountToZero(it) } + return AddArticleUseCaseOut(articleMstId) } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt index 3b0df3b7f..f793a3f0e 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt @@ -1,15 +1,148 @@ package com.few.api.domain.article.usecase -import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn -import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseOut +import com.few.api.domain.article.usecase.dto.* +import com.few.api.exception.common.NotFoundException +import com.few.api.repo.dao.article.ArticleDao +import com.few.api.repo.dao.article.ArticleMainCardDao +import com.few.api.repo.dao.article.ArticleViewCountDao +import com.few.api.repo.dao.article.query.SelectArticlesOrderByViewsQuery +import com.few.api.repo.dao.article.query.SelectRankByViewsQuery +import com.few.api.repo.dao.article.record.ArticleMainCardRecord +import com.few.api.repo.dao.article.record.SelectArticleContentsRecord +import com.few.api.repo.dao.article.record.SelectArticleViewsRecord +import com.few.data.common.code.CategoryType import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.util.* +import kotlin.Comparator @Component -class ReadArticlesUseCase { +class ReadArticlesUseCase( + private val articleViewCountDao: ArticleViewCountDao, + private val articleMainCardDao: ArticleMainCardDao, + private val articleDao: ArticleDao, +) { - @Transactional(readOnly = true) + @Transactional // TODO: read only로 할 수 있도록 점검 fun execute(useCaseIn: ReadArticlesUseCaseIn): ReadArticlesUseCaseOut { - return ReadArticlesUseCaseOut(emptyList()) // TODO: impl + // 1. 아티클 조회수에서 마지막 읽은 아티클아이디, 카테고리를 기반으로 조회수 상위 10개를 가져옴 + val offset = if (useCaseIn.prevArticleId <= 0) { + 0L + } else { + articleViewCountDao.selectRankByViews( + SelectRankByViewsQuery(useCaseIn.prevArticleId) + ) ?: 0 + } + + // 이번 스크롤에서 보여줄 아티클 ID에 대한 기준 (Criterion) + val articleViewsRecords: Set = articleViewCountDao.selectArticlesOrderByViews( + SelectArticlesOrderByViewsQuery( + offset, + CategoryType.fromCode(useCaseIn.categoryCd) + ) + ) + + // 2. TODO: 조회한 10개의 아티클 아이디를 기반으로 로컬 캐시에 있는지 조회(아티클 단건조회 캐시 사용) + // 3. TODO: 로컬캐시에 없으면 ARTICLE_MAIN_CARD 테이블에서 데이터가 있는지 조회 (컨텐츠는 article_ifo에서) + + // 4. ARTICLE_MAIN_CARD 테이블에도 없으면 조인 진행 후 ARTICLE_MAIN_CARD 테이블 및 캐시에 넣기 (컨텐츠는 article_ifo에서) + var existInArticleMainCardRecords: Set = + articleMainCardDao.selectArticleMainCardsRecord(articleViewsRecords.map { it.articleId }.toSet()) + + if (existInArticleMainCardRecords.size != articleViewsRecords.size) { + val existInArticleMainCardIds = existInArticleMainCardRecords.map { it.articleId }.toSet() + val notExistArticleMainCardTableArticleIds = articleViewsRecords + .filterNot { existInArticleMainCardIds.contains(it.articleId) } + .map { it.articleId } + .toSet() + + // join 진행하여 Select + val joinedArticleMainCardRecords: Set = articleMainCardDao + .selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbook(notExistArticleMainCardTableArticleIds) + + // 결과를 MainCard 테이블에 저장 + articleMainCardDao.insertArticleMainCardsBulk(joinedArticleMainCardRecords) // TODO: 트랜잭션 분리 점검 + + existInArticleMainCardRecords = (existInArticleMainCardRecords + joinedArticleMainCardRecords).toSet() + + // TODO: 결과를 로컬 캐시에 저장 + } + + // 아티클 컨텐츠 조회 + val selectArticleContentsRecords: List = + articleDao.selectArticleContents(existInArticleMainCardRecords.map { it.articleId }.toSet()) + setContentsToRecords(selectArticleContentsRecords, existInArticleMainCardRecords) + + val sortedArticles = updateAndSortArticleViews(existInArticleMainCardRecords, articleViewsRecords) + + val articleUseCaseOuts: List = sortedArticles.map { a -> + ReadArticleUseCaseOut( + id = a.articleId, + writer = WriterDetail( + id = a.writerId, + name = a.writerName, + url = a.writerImgUrl + ), + title = a.articleTitle, + content = a.content, + problemIds = emptyList(), + category = CategoryType.fromCode(a.categoryCd)?.displayName + ?: throw NotFoundException("article.invalid.category"), + createdAt = a.createdAt, + views = a.views, + workbooks = a.workbooks + .filter { it.id != null && it.title != null } + .map { WorkbookDetail(it.id, it.title) } + ) + }.toList() + + return ReadArticlesUseCaseOut(articleUseCaseOuts, sortedArticles.size != 10) + } + + private fun updateAndSortArticleViews( + articleRecords: Set, + articleViewsRecords: Set, + ): Set { + val sortedSet = TreeSet( + Comparator { a1, a2 -> + // views 값이 null일 경우 0으로 간주 + val views1 = a1.views ?: 0 + val views2 = a2.views ?: 0 + + // views 내림차순 정렬 + val viewComparison = views2.compareTo(views1) + + if (viewComparison != 0) { + viewComparison + } else { + // views가 같을 경우 articleId 내림차순 정렬(최신글) + val articleId1 = a1.articleId + val articleId2 = a2.articleId + articleId2.compareTo(articleId1) + } + } + ) + + val viewsMap = articleViewsRecords.associateBy({ it.articleId }, { it.views }) + + articleRecords.forEach { article -> + val updatedViews = viewsMap[article.articleId] ?: 0 + article.views = updatedViews + sortedSet.add(article) + } + + return sortedSet + } + + private fun setContentsToRecords( + articleContentsRecords: List, + articleMainCardRecords: Set, + ) { + val articleMainCardRecordsMap: Map = + articleMainCardRecords.associateBy { it.articleId } + + articleContentsRecords.map { articleContentRecord -> + articleMainCardRecordsMap[articleContentRecord.articleId]?.content = articleContentRecord.content + } } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt index 60aa85b8e..3a272c858 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt @@ -12,7 +12,7 @@ data class ReadArticleUseCaseOut( val category: String, val createdAt: LocalDateTime, val views: Long, - val includedWorkbooks: List = emptyList(), + val workbooks: List = emptyList(), ) data class WriterDetail( @@ -22,6 +22,6 @@ data class WriterDetail( ) data class WorkbookDetail( - val id: Long, - val title: String, + val id: Long?, + val title: String?, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt index 0b144cd51..23498772d 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt @@ -2,4 +2,5 @@ package com.few.api.domain.article.usecase.dto data class ReadArticlesUseCaseIn( val prevArticleId: Long, + val categoryCd: Byte, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt index fb2aedb64..d6effe237 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt @@ -2,4 +2,5 @@ package com.few.api.domain.article.usecase.dto data class ReadArticlesUseCaseOut( val articles: List, + val isLast: Boolean, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt index 6aa1fd04d..2d42ee9f1 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt @@ -4,12 +4,10 @@ import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn import com.few.api.domain.article.usecase.ReadArticleUseCase import com.few.api.domain.article.usecase.ReadArticlesUseCase import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn -import com.few.api.web.controller.article.response.ReadArticleResponse -import com.few.api.web.controller.article.response.ReadArticlesResponse -import com.few.api.web.controller.article.response.WorkbookInfo -import com.few.api.web.controller.article.response.WriterInfo +import com.few.api.web.controller.article.response.* import com.few.api.web.support.ApiResponse import com.few.api.web.support.ApiResponseGenerator +import com.few.data.common.code.CategoryType import jakarta.validation.constraints.Min import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -30,7 +28,10 @@ class ArticleController( @Min(value = 1, message = "{min.id}") articleId: Long, ): ApiResponse> { - val useCaseOut = ReadArticleUseCaseIn(articleId = articleId, memberId = 0L).let { useCaseIn: ReadArticleUseCaseIn -> //TODO: membberId검토 + val useCaseOut = ReadArticleUseCaseIn( + articleId = articleId, + memberId = 0L + ).let { useCaseIn: ReadArticleUseCaseIn -> //TODO: membberId검토 readArticleUseCase.execute(useCaseIn) } @@ -58,8 +59,12 @@ class ArticleController( required = false, defaultValue = "0" ) prevArticleId: Long, + @RequestParam( + required = false, + defaultValue = "-1" + ) categoryCd: Byte, ): ApiResponse> { - val useCaseOut = readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId)) + val useCaseOut = readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId, categoryCd)) val articles: List = useCaseOut.articles.map { a -> ReadArticleResponse( @@ -75,17 +80,27 @@ class ArticleController( category = a.category, createdAt = a.createdAt, views = a.views, - includedWorkbooks = a.includedWorkbooks.map { w -> - WorkbookInfo( - id = w.id, - title = w.title - ) - } + workbooks = a.workbooks.map { WorkbookInfo(it.id, it.title) } ) }.toList() - val response = ReadArticlesResponse(articles, articles.size != 10) // TODO refactor 'isLast' + val response = ReadArticlesResponse(articles, useCaseOut.isLast) return ApiResponseGenerator.success(response, HttpStatus.OK) } + + @GetMapping("/categories") + fun browseArticleCategories(): ApiResponse>> { + return ApiResponseGenerator.success( + mapOf( + "categories" to CategoryType.entries.map { + mapOf( + "code" to it.code, + "name" to it.displayName + ) + } + ), + HttpStatus.OK + ) + } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt index 5ae6c7a76..57096ae67 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt @@ -1,5 +1,6 @@ package com.few.api.web.controller.article.response +import com.fasterxml.jackson.annotation.JsonFormat import java.net.URL import java.time.LocalDateTime @@ -10,9 +11,10 @@ data class ReadArticleResponse( val content: String, val problemIds: List, val category: String, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") val createdAt: LocalDateTime, val views: Long, - val includedWorkbooks: List = emptyList(), + val workbooks: List = emptyList(), ) data class WriterInfo( @@ -22,6 +24,6 @@ data class WriterInfo( ) data class WorkbookInfo( - val id: Long, - val title: String, + val id: Long?, + val title: String?, ) \ No newline at end of file diff --git a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt index 4807efdcb..6409a7ce0 100644 --- a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt +++ b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt @@ -126,8 +126,8 @@ class ArticleControllerTest : ControllerTestSpec() { .fieldWithString("아티클 생성일"), PayloadDocumentation.fieldWithPath("data.views") .fieldWithNumber("아티클 조회수"), - PayloadDocumentation.fieldWithPath("data.includedWorkbooks") - .fieldWithArray("아티클이 포함된 학습지 정보(해당 API에서 사용되지 않음)") + PayloadDocumentation.fieldWithPath("data.articles[].workbooks") + .fieldWithArray("아티클이 포함된 학습지 정보(해당 API에선 사용되지 않음)") ) ) ).build() @@ -137,18 +137,20 @@ class ArticleControllerTest : ControllerTestSpec() { } @Test - @DisplayName("[GET] /api/v1/articles?prevArticleId={prevArticleId}") + @DisplayName("[GET] /api/v1/articles?prevArticleId={optional}?categoryCd={optional}") fun readArticles() { // given val api = "ReadArticles" val uri = UriComponentsBuilder.newInstance() .path("$BASE_URL") .queryParam("prevArticleId", 1L) + .queryParam("categoryCd", -1L) .build() .toUriString() // set usecase mock val prevArticleId = 1L - `when`(readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId))).thenReturn( + val categoryCd: Byte = -1 + `when`(readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId, categoryCd))).thenReturn( ReadArticlesUseCaseOut( listOf( ReadArticleUseCaseOut( @@ -163,13 +165,10 @@ class ArticleControllerTest : ControllerTestSpec() { problemIds = listOf(1L, 2L, 3L), category = "경제", createdAt = LocalDateTime.now(), - views = 1L, - includedWorkbooks = listOf( - WorkbookDetail(1L, "사소한 것들의 역사"), - WorkbookDetail(2L, "인모스트 경제레터") - ) + views = 1L ) - ) + ), + true ) ) @@ -183,6 +182,7 @@ class ArticleControllerTest : ControllerTestSpec() { .summary(api.toIdentifier()).privateResource(false).deprecated(false) .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) .queryParameters(parameterWithName("prevArticleId").description("이전까지 조회한 아티클 Id")) + .queryParameters(parameterWithName("categoryCd").description("아티클 카테고리 코드")) .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( *Description.describe( arrayOf( @@ -214,11 +214,11 @@ class ArticleControllerTest : ControllerTestSpec() { .fieldWithString("아티클 생성일"), PayloadDocumentation.fieldWithPath("data.articles[].views") .fieldWithNumber("아티클 조회수"), - PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks") + PayloadDocumentation.fieldWithPath("data.articles[].workbooks") .fieldWithArray("아티클이 포함된 학습지 정보"), - PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].id") + PayloadDocumentation.fieldWithPath("data.articles[].workbooks[].id") .fieldWithNumber("아티클이 포함된 학습지 정보(학습지ID)"), - PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].title") + PayloadDocumentation.fieldWithPath("data.articles[].workbooks[].title") .fieldWithString("아티클이 포함된 학습지 정보(학습지 제목)") ) ) @@ -227,4 +227,42 @@ class ArticleControllerTest : ControllerTestSpec() { ) ) } + + @Test + @DisplayName("[GET] /api/v1/articles/categories") + fun browseArticleCategories() { + // given + val api = "browseArticleCategories" + val uri = UriComponentsBuilder.newInstance() + .path("$BASE_URL/categories") + .build() + .toUriString() + + // when, then + this.webTestClient.get().uri(uri).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody().consumeWith( + WebTestClientRestDocumentation.document( + api.toIdentifier(), + ResourceDocumentation.resource( + ResourceSnippetParameters.builder().description("아티클 카테고리 code, name 조회") + .summary(api.toIdentifier()).privateResource(false).deprecated(false) + .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) + .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( + *Description.describe( + arrayOf( + PayloadDocumentation.fieldWithPath("data") + .fieldWithObject("data"), + PayloadDocumentation.fieldWithPath("data.categories") + .fieldWithArray("카테고리 목록"), + PayloadDocumentation.fieldWithPath("data.categories[].code") + .fieldWithNumber("카테고리 code"), + PayloadDocumentation.fieldWithPath("data.categories[].name") + .fieldWithString("카테고리 name") + ) + ) + ).build() + ) + ) + ) + } } \ No newline at end of file diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml index a3fcb692a..306d84c2e 100644 --- a/api/src/test/resources/application-test.yml +++ b/api/src/test/resources/application-test.yml @@ -1,7 +1,7 @@ spring: datasource: hikari: - jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true + jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&allowMultiQueries=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/batch/src/test/kotlin/com/few/batch/BatchTestContainerInitializer.kt b/batch/src/test/kotlin/com/few/batch/BatchTestContainerInitializer.kt index 835243456..735951a68 100644 --- a/batch/src/test/kotlin/com/few/batch/BatchTestContainerInitializer.kt +++ b/batch/src/test/kotlin/com/few/batch/BatchTestContainerInitializer.kt @@ -1,13 +1,13 @@ package com.few.batch +import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.context.ApplicationContextInitializer import org.springframework.context.ConfigurableApplicationContext import org.testcontainers.containers.DockerComposeContainer import java.io.File class BatchTestContainerInitializer : ApplicationContextInitializer { - private val log: org.slf4j.Logger = - org.slf4j.LoggerFactory.getLogger(BatchTestContainerInitializer::class.java) + private val log = KotlinLogging.logger {} companion object { private const val MYSQL = "mysql" @@ -19,10 +19,10 @@ class BatchTestContainerInitializer : ApplicationContextInitializer